From ebd9a163bc6b8f664e870a0e2eeeec9582e93059 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 29 Jul 2026 16:32:11 +1000 Subject: [PATCH 001/227] fix(desktop): auto-enable master notif switch when OS permission already granted Follow-up to e9475dd079. That commit fixed the case where the user clicked the "Enable OS notifications" button on a fresh install (permission NotRequested \u2192 Granted) but the master toggle stayed off. It missed the two closely-related cases the user was still hitting on v1.13.1: 1. Permission was already granted from a previous session or install (e.g. an earlier v1.13.0 build, or the user allowed it via System Settings \u2192 Notifications directly). In this state, permissionState == Granted, so the "Enable OS notifications" button never renders \u2014 the button label promised the whole handshake but the code path that flipped the master switch only ran under NotRequested. 2. On Windows/Linux `permissionState` defaults to `NotApplicable` from the moment the app starts. The master switch is off by default (first-launch UX choice) and nothing ever flips it, so the auto-dispatcher stayed muted forever unless the user found the switch manually. Fix: - Add `NotificationSettings.wasExplicitlyDisabled()` so the Settings screen can distinguish "master switch is off because it defaults off on first launch" (auto-enable is fine) from "master switch is off because the user turned it off" (leave alone). Backed by a new java.util.prefs key `explicitly_disabled` that flips true on `setEnabled(false)` and gets cleared on `setEnabled(true)`. - In `NotificationSettingsScreen`, a `LaunchedEffect(permissionState, enabled)` observes when the OS permission is Granted OR NotApplicable and the master switch is off. If the user has never explicitly turned it off, it auto-flips on \u2014 matching the "Enable OS notifications" contract for the paths the previous fix missed. - Also render a "Turn on desktop notifications" button in the Granted branch when the user has explicitly turned notifications off. That's the recovery path for users who deliberately opted out and later want to opt back in without hunting for the master switch two rows away. Behaviour on the fresh-install macOS path (permission NotRequested) is unchanged \u2014 that path still runs the `requestPermission()` flow inside the button's onClick, and the auto-enable happens via the same LaunchedEffect once permissionState flips to Granted. Tests (jvmTest, hermetic \u2014 UUID-scoped prefs nodes so tests never share state or pollute real user prefs): PreferencesNotificationSettingsExplicitDisableTest: - fresh install defaults to not-explicitly-disabled - turning off marks explicitly disabled - turning on clears the explicit-disable flag - flag persists across new instances on the same prefs node \ud83e\udd16 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../notifications/NotificationTypes.kt | 10 +++ .../PreferencesNotificationSettings.kt | 13 +++ ...NotificationSettingsExplicitDisableTest.kt | 86 +++++++++++++++++++ .../ui/settings/NotificationSettingsScreen.kt | 32 +++++++ 4 files changed, 141 insertions(+) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationSettingsExplicitDisableTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationTypes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationTypes.kt index bc3d5177d6..02f06b4147 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationTypes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationTypes.kt @@ -81,6 +81,16 @@ interface NotificationSettings { fun setEnabled(v: Boolean) + /** + * True iff the user has taken an explicit action to disable + * notifications (i.e. flipped the master switch OFF at some point). + * Used by the Settings screen to distinguish "master switch is off + * because it defaults to off on first launch" from "master switch + * is off because the user asked for it to be off". Only the former + * gets auto-enabled when the OS permission check passes. + */ + fun wasExplicitlyDisabled(): Boolean + fun setKindToggle( kind: NotifKind, v: Boolean, diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationSettings.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationSettings.kt index 349fab12ea..225b85d649 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationSettings.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationSettings.kt @@ -53,8 +53,20 @@ class PreferencesNotificationSettings( override fun setEnabled(v: Boolean) { _enabled.value = v prefs.putBoolean(KEY_ENABLED, v) + // Track explicit user intent so the Settings screen can auto-enable + // on next visit for users who never touched the switch, while + // respecting users who deliberately turned it off. Only false + // → "explicit disable"; going from off to on clears the flag so + // subsequent auto-enable heuristics work normally. + if (v) { + prefs.remove(KEY_EXPLICITLY_DISABLED) + } else { + prefs.putBoolean(KEY_EXPLICITLY_DISABLED, true) + } } + override fun wasExplicitlyDisabled(): Boolean = prefs.getBoolean(KEY_EXPLICITLY_DISABLED, false) + override fun setKindToggle( kind: NotifKind, v: Boolean, @@ -92,6 +104,7 @@ class PreferencesNotificationSettings( companion object { const val NODE = "com/vitorpamplona/amethyst/notifications" private const val KEY_ENABLED = "enabled" + private const val KEY_EXPLICITLY_DISABLED = "explicitly_disabled" private const val KEY_DND_UNTIL = "dnd_until" private const val KEY_PREVIEW = "preview_in_toast" diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationSettingsExplicitDisableTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationSettingsExplicitDisableTest.kt new file mode 100644 index 0000000000..4678d92b97 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationSettingsExplicitDisableTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation.notifications + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.UUID +import java.util.prefs.Preferences + +/** + * Pins the semantics of the new [NotificationSettings.wasExplicitlyDisabled] + * flag added to unblock the "Enable OS notifications button doesn't work on + * desktop" bug's second failure mode. + * + * The Settings screen auto-enables the master notifications switch when it + * detects that the OS permission is fine and the switch is off. That's the + * common path for users who click the "Enable OS notifications" button and + * expect it to fully take effect (button label promise). But it MUST NOT + * override users who deliberately turned notifications off. The + * [wasExplicitlyDisabled] flag is how we distinguish the two. + */ +class PreferencesNotificationSettingsExplicitDisableTest { + private fun freshNode(): Preferences { + // Use a UUID-scoped node so tests never share state and never + // pollute the real user prefs on the machine running CI/dev builds. + return Preferences.userRoot().node("amethyst-test-" + UUID.randomUUID()) + } + + @Test + fun `fresh install defaults to not-explicitly-disabled`() { + val settings = PreferencesNotificationSettings(freshNode()) + assertFalse( + "First launch must not look like a deliberate opt-out; otherwise auto-enable stays off forever", + settings.wasExplicitlyDisabled(), + ) + } + + @Test + fun `turning off marks explicitly disabled`() { + val prefs = freshNode() + val settings = PreferencesNotificationSettings(prefs) + settings.setEnabled(false) + assertTrue(settings.wasExplicitlyDisabled()) + } + + @Test + fun `turning on clears the explicit-disable flag`() { + val prefs = freshNode() + val settings = PreferencesNotificationSettings(prefs) + settings.setEnabled(false) + assertTrue(settings.wasExplicitlyDisabled()) + settings.setEnabled(true) + assertFalse( + "Toggling back on must clear the flag so subsequent OFF->auto-enable cycles work", + settings.wasExplicitlyDisabled(), + ) + } + + @Test + fun `flag persists across new instances on the same prefs node`() { + val prefs = freshNode() + PreferencesNotificationSettings(prefs).setEnabled(false) + // Second instance opens the same node \u2014 flag must survive process restart. + val reopened = PreferencesNotificationSettings(prefs) + assertTrue(reopened.wasExplicitlyDisabled()) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NotificationSettingsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NotificationSettingsScreen.kt index ef1c546c1d..161b929b68 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NotificationSettingsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NotificationSettingsScreen.kt @@ -132,6 +132,27 @@ fun NotificationSettingsScreen(onBack: (() -> Unit)? = null) { } } + // Handle the still-broken case that the previous fix missed: + // the user granted OS permission in a prior session (either via + // the older "Enable OS notifications" button whose auto-enable + // guard I initially forgot, via System Settings directly, or on + // Windows/Linux where permissionState defaults to NotApplicable). + // When they come back to Settings, permissionState == Granted so + // the "Enable OS notifications" button doesn't render, the master + // switch is still OFF from first-launch defaults, and there is no + // affordance that both tells them what's wrong and fixes it in + // one click. Auto-enable once per screen entry when we detect + // "permission is fine, but master switch is off and the user + // has never explicitly disabled it". PreferencesNotificationSettings + // exposes [wasExplicitlyDisabled] so we don't overrule a deliberate + // opt-out. + androidx.compose.runtime.LaunchedEffect(permissionState, enabled) { + val allowed = permissionState == PermissionState.Granted || permissionState == PermissionState.NotApplicable + if (allowed && !enabled && !settings.wasExplicitlyDisabled()) { + settings.setEnabled(true) + } + } + PlatformStatusCard( host = host, nativeAvailable = nativeAvailable, @@ -219,6 +240,17 @@ fun NotificationSettingsScreen(onBack: (() -> Unit)? = null) { } } PermissionState.Granted, PermissionState.NotApplicable -> { + // Turn-on button: renders only when master switch is + // off *and* the user explicitly disabled it before. + // The LaunchedEffect above auto-enables the switch + // for the common "never touched it" path; this button + // is the recovery for the deliberate-opt-out path. + if (!enabled) { + OutlinedButton( + onClick = { settings.setEnabled(true) }, + enabled = true, + ) { Text("Turn on desktop notifications") } + } OutlinedButton( onClick = { if (sendingTest) return@OutlinedButton From 6691e519e8ae93c68f55754d4003ec42adae843e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 09:48:01 -0400 Subject: [PATCH 002/227] feat(relays): record why each subscription exists, without telling relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The always-on notification says "connected to N relays" and nothing more. That count is emergent, not curated: NotificationRelayService owns no subscriptions, and nothing closes a socket when it stops being useful, so a relay stays connected exactly as long as some filter still references it. Neither a user nor a developer can tell whether those N relays are carrying DMs or re-dialling a stale outbox hint. The client already tracks every in-flight REQ per relay with its filters (INostrClient.activeRequests, which the Connected Relays screen reads). A filter says *what* is matched — kinds, authors, tags — but not *who asked*, and subIds are random. This adds the missing half. `Filter` becomes `open`, with `copy()` open too, and commons gets `ExplainedFilter` carrying a `SubPurpose`. The purpose rides on the filter, so it arrives with the data the relay screens already read — no parallel registry to keep in sync or leak on teardown. **It never reaches a relay.** FilterSerializer is registered against Filter and writes an explicit protocol field list, and Jackson applies a serializer registered for a class to its subclasses — so an ExplainedFilter serializes to byte-identical JSON. That is the point: telling relays what each REQ is *for* would hand them a ready-made fingerprint of client intent and correlate subscriptions that are deliberately kept apart. `copy()` is overridden because filters are copied on the live path — assemblers call copy(since = …) after every EOSE. Inheriting the base implementation would downgrade to a plain Filter on the first window refresh, so the purpose would survive the opening REQ and vanish seconds later. Both invariants are pinned by tests, and both were mutation-checked rather than assumed: - removing the copy() override -> `copy preserves the purpose` fails - unregistering FilterSerializer -> 3 tests fail, one reporting the literal leak: `purpose leaked to the wire: {…}` A test also asserts FiltersChanged does not see the new field, so tagging a filter cannot trigger a re-REQ storm across ~400 relays. Wired three assemblers as proof (metadata, reactions, outbox finder) and surfaced the derived set on BasicRelaySetupInfo.purposes for the Connected Relays screen. Verified on device: 8 relays attributed RELAY_LISTS through client.activeRequests() — purpleplag.es, user.kindpag.es, indexer.coracle.social, directory.yabu.me and friends, which is semantically right — across 2,709 sent REQs containing zero occurrences of "purpose" or any SubPurpose name. The remaining assemblers are untagged, which is why `purposes` is documented as "not yet attributed" rather than "idle". Notification-popup copy is deliberately not built yet: it should describe the measured background grouping, not the intended one. Co-Authored-By: Claude Opus 5 (1M context) --- .../loaders/UserOutboxFinderSubAssembler.kt | 6 +- .../relays/common/BasicRelaySetupInfo.kt | 8 + .../connected/ConnectedRelayListViewModel.kt | 5 + .../assemblers/MetadataFilterAssembler.kt | 6 +- .../assemblers/ReactionsFilterAssembler.kt | 7 +- .../subscriptions/ExplainedFilter.kt | 107 +++++++++++++ .../relayClient/subscriptions/SubPurpose.kt | 68 ++++++++ .../subscriptions/ExplainedFilterTest.kt | 150 ++++++++++++++++++ .../quartz/nip01Core/relay/filters/Filter.kt | 19 ++- 9 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt index a807cb69df..1710572657 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers @@ -116,7 +118,7 @@ class UserOutboxFinderSubAssembler( if (sortedUsers.isNotEmpty()) { RelayBasedFilter( relay = it.key, - filter = Filter(kinds = relayListKinds, authors = sortedUsers), + filter = ExplainedFilter(kinds = relayListKinds, authors = sortedUsers, purpose = SubPurpose.RELAY_LISTS), ) } else { null @@ -146,7 +148,7 @@ class UserOutboxFinderSubAssembler( fallbackRelays.map { relay -> RelayBasedFilter( relay = relay, - filter = Filter(kinds = relayListKinds, authors = sortedAbandoned), + filter = ExplainedFilter(kinds = relayListKinds, authors = sortedAbandoned, purpose = SubPurpose.RELAY_LISTS, purposeDetail = "outbox discovery for users whose relay list we lost"), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt index d4820a1b79..25d057fb4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -33,6 +34,13 @@ data class BasicRelaySetupInfo( val paidRelay: Boolean = false, val forcesTor: Boolean = false, val users: List = emptyList(), + /** + * What this relay is currently doing for us, derived from the purposes tagged onto its in-flight + * filters. Empty when nothing on this relay has been tagged yet — the assemblers are being + * migrated to [com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter] + * incrementally, so an empty set means "not yet attributed", never "idle". + */ + val purposes: Set = emptySet(), ) fun relaySetupInfoBuilder( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt index 4f6de20024..6babfdc831 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposes import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel @@ -53,8 +54,12 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { } } + // Every in-flight filter that has been tagged says why this relay is connected. + val purposes = reqs.values.flatten().purposes() + BasicRelaySetupInfo( relay = it, + purposes = purposes, relayStat = Amethyst.instance.relayStats.get(it), forcesTor = Amethyst.instance.torEvaluatorFlow.flow.value diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt index 1dffa08b4e..c81db0984c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt @@ -21,12 +21,13 @@ package com.vitorpamplona.amethyst.commons.relayClient.assemblers import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** @@ -72,10 +73,11 @@ class MetadataFilterAssembler( // Create filter for metadata (Kind 0) val filter = - Filter( + ExplainedFilter( kinds = listOf(MetadataEvent.KIND), authors = pubkeyList, limit = pubkeyList.size, + purpose = SubPurpose.PROFILE_METADATA, ) // Apply since times per relay diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt index 72c371f65d..0b2f8717f9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.commons.relayClient.assemblers import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent @@ -72,7 +73,9 @@ class ReactionsFilterAssembler( // Create filter for reactions (Kind 7) targeting these notes via e-tags val filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, + purposeDetail = "reactions on notes currently on screen", kinds = listOf(ReactionEvent.KIND), tags = mapOf("e" to noteIdList), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt new file mode 100644 index 0000000000..fd13dc8cdd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.subscriptions + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +/** + * A [Filter] that also records **why** the app asked for it. + * + * The client tracks every in-flight REQ per relay already (`INostrClient.activeRequests`), but a + * filter only says *what* is being matched — kinds, authors, tags. That is not enough to answer the + * question a user (or a developer chasing relay churn) actually has: *which of my relays are doing + * which job?* Carrying [purpose] on the filter itself means the answer travels with the data the + * relay screens already read, with no parallel registry to keep in sync or to leak on teardown. + * + * ## It never reaches a relay + * + * `FilterSerializer` is registered against [Filter] and writes an explicit protocol field list + * (`kinds`/`ids`/`authors`/`#tags`/`&tagsAll`/`since`/`until`/`limit`/`search`). Jackson applies a + * serializer registered for a class to its subclasses, so an [ExplainedFilter] serializes to + * byte-identical JSON — the purpose is dropped at the wire boundary by construction, not by an + * annotation someone can forget. `ExplainedFilterTest` pins that. + * + * This matters beyond tidiness: telling relays what each REQ is *for* would hand them a + * ready-made fingerprint of the client's intent, and correlate subscriptions that are deliberately + * kept separate. + * + * ## Copying + * + * [copy] is overridden because filters are copied on the live path — assemblers call + * `copy(since = …)` after every EOSE to advance the window. Inheriting the base implementation + * would downgrade the filter to a plain [Filter] on the first refresh, so the purpose would survive + * the opening REQ and then quietly disappear a few seconds later. + */ +class ExplainedFilter( + ids: List? = null, + authors: List? = null, + kinds: List? = null, + tags: Map>? = null, + tagsAll: Map>? = null, + since: Long? = null, + until: Long? = null, + limit: Int? = null, + search: String? = null, + val purpose: SubPurpose, + /** Free-form extra context for [SubPurpose.OTHER] or for narrowing a bucket while debugging. */ + val purposeDetail: String? = null, +) : Filter(ids, authors, kinds, tags, tagsAll, since, until, limit, search) { + override fun copy( + ids: List?, + authors: List?, + kinds: List?, + tags: Map>?, + tagsAll: Map>?, + since: Long?, + until: Long?, + limit: Int?, + search: String?, + ) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail) + + companion object { + /** Tags [filter] with a [purpose], preserving every protocol field. */ + fun of( + filter: Filter, + purpose: SubPurpose, + detail: String? = null, + ) = ExplainedFilter( + filter.ids, + filter.authors, + filter.kinds, + filter.tags, + filter.tagsAll, + filter.since, + filter.until, + filter.limit, + filter.search, + purpose, + detail, + ) + } +} + +/** The purpose behind this filter, or null when it was never tagged. */ +fun Filter.purposeOrNull(): SubPurpose? = (this as? ExplainedFilter)?.purpose + +/** The purposes behind a relay's in-flight filters, deduplicated — what this relay is doing for us. */ +fun Collection.purposes(): Set = mapNotNullTo(mutableSetOf()) { it.purposeOrNull() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt new file mode 100644 index 0000000000..c5c52bbd06 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.subscriptions + +/** + * Why a subscription exists — the client-side answer to "what is this relay doing for me?". + * + * Attached to an [ExplainedFilter] and **never sent to a relay** (see [ExplainedFilter]). It exists + * so the app can account for its own connections: the always-on notification reports a bare + * "connected to N relays", which is emergent rather than curated — nothing today can tell you + * whether those N are carrying your DMs or just re-dialling a stale outbox hint. + * + * An enum rather than a free-form string so the UI can group and localize by a stable key instead of + * matching on text. Use [other] with a label when a subscription genuinely doesn't fit a category — + * that keeps one-off debug filters describable without diluting the buckets a user sees. + */ +enum class SubPurpose { + /** Mentions, reactions, reposts, zaps addressed to me (`#p` = me). Inbox relays. */ + NOTIFICATIONS, + + /** NIP-17 gift wraps and NIP-04 legacy DMs. DM relays. */ + DIRECT_MESSAGES, + + /** Posts from the people I follow. Outbox relays. */ + HOME_FEED, + + /** Profiles (kind 0) — mine and everyone I render. */ + PROFILE_METADATA, + + /** Relay lists (NIP-65), DM relay lists, blocked/trusted relay sets. */ + RELAY_LISTS, + + /** Follow lists and follow-list-derived sets (kind 3, web of trust). */ + FOLLOW_LISTS, + + /** Group, channel and community chat (NIP-28/29, Concord, Buzz). */ + CHATS, + + /** Reports and moderation state (kind 1984, mute lists). */ + MODERATION, + + /** Cashu wallet, nutzaps and mint directories. */ + WALLET, + + /** A thread, profile page, hashtag or anything else opened on demand. Short-lived. */ + SCREEN_CONTENT, + + /** Anything not worth its own bucket; carry detail in [ExplainedFilter.purposeDetail]. */ + OTHER, +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt new file mode 100644 index 0000000000..ec96ce47a0 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.subscriptions + +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.FiltersChanged +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The two invariants [ExplainedFilter] rests on. Both fail silently in production if broken — the + * first leaks intent to relays, the second loses the purpose a few seconds after the app starts — + * so they are pinned here rather than left to review. + */ +class ExplainedFilterTest { + private val pubkey = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b" + private val other = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362d5a1f9e0b1c" + + private fun plain(since: Long? = null) = + Filter( + kinds = listOf(1, 7, 6, 9735), + authors = listOf(pubkey), + tags = mapOf("p" to listOf(other)), + since = since, + limit = 20, + ) + + private fun explained(since: Long? = null) = + ExplainedFilter( + kinds = listOf(1, 7, 6, 9735), + authors = listOf(pubkey), + tags = mapOf("p" to listOf(other)), + since = since, + limit = 20, + purpose = SubPurpose.NOTIFICATIONS, + purposeDetail = "inbox relays for the active account", + ) + + // ---- the wire must not learn why we asked ------------------------------- + + /** + * The privacy guarantee. It holds because `FilterSerializer` is registered for [Filter] and + * writes an explicit protocol field list, so subclass state is dropped regardless of the runtime + * type. If anyone swaps that for reflective serialization, this is what catches it. + */ + @Test + fun `serializes byte-identically to a plain filter`() { + assertEquals(plain().toJson(), explained().toJson()) + assertEquals(plain(since = 1_785_379_272).toJson(), explained(since = 1_785_379_272).toJson()) + } + + @Test + fun `neither the purpose nor its detail appears anywhere in the json`() { + val json = explained().toJson() + assertFalse("purpose leaked to the wire: $json", json.contains("NOTIFICATIONS", ignoreCase = true)) + assertFalse("purpose leaked to the wire: $json", json.contains("purpose", ignoreCase = true)) + assertFalse("detail leaked to the wire: $json", json.contains("inbox relays", ignoreCase = true)) + } + + /** The filter is serialized as part of a REQ, so check the real command too, not just the filter. */ + @Test + fun `the REQ command carrying it is identical as well`() { + assertEquals( + ReqCmd("subid1", listOf(plain())).toJson(), + ReqCmd("subid1", listOf(explained())).toJson(), + ) + } + + // ---- the purpose must survive the live path ----------------------------- + + /** + * Assemblers call `copy(since = …)` after every EOSE to advance the window. Without the override + * this returns a plain [Filter] and the purpose is gone — after the opening REQ, not at it, which + * is why review would not catch it. + */ + @Test + fun `copy preserves the purpose`() { + val advanced = explained().copy(since = 1_785_379_272) + + assertTrue("copy() must stay an ExplainedFilter", advanced is ExplainedFilter) + assertEquals(SubPurpose.NOTIFICATIONS, advanced.purposeOrNull()) + assertEquals("inbox relays for the active account", (advanced as ExplainedFilter).purposeDetail) + assertEquals(1_785_379_272L, advanced.since) + // and the protocol fields came along untouched + assertEquals(listOf(pubkey), advanced.authors) + assertEquals(listOf(1, 7, 6, 9735), advanced.kinds) + } + + @Test + fun `of() tags an existing filter without disturbing it`() { + val tagged = ExplainedFilter.of(plain(since = 99), SubPurpose.DIRECT_MESSAGES) + + assertEquals(plain(since = 99).toJson(), tagged.toJson()) + assertEquals(SubPurpose.DIRECT_MESSAGES, tagged.purposeOrNull()) + } + + // ---- it must stay invisible to the rest of the client ------------------- + + /** + * `FiltersChanged` compares named fields, so tagging a filter must not look like a filter change. + * If it did, every subscription would re-REQ on the next sync — invisible in tests, expensive on + * a phone talking to ~400 relays. + */ + @Test + fun `tagging a filter does not look like a change worth resending`() { + assertFalse(FiltersChanged.needsToResendRequest(listOf(plain()), listOf(explained()))) + assertFalse(FiltersChanged.needsToResendRequest(listOf(explained()), listOf(plain()))) + } + + @Test + fun `an untagged filter reports no purpose`() { + assertNull(plain().purposeOrNull()) + assertEquals(emptySet(), listOf(plain()).purposes()) + } + + @Test + fun `purposes() summarises what a relay is doing for us`() { + val filters = + listOf( + explained(), + ExplainedFilter.of(plain(), SubPurpose.DIRECT_MESSAGES), + ExplainedFilter.of(plain(), SubPurpose.NOTIFICATIONS), + plain(), + ) + + assertEquals(setOf(SubPurpose.NOTIFICATIONS, SubPurpose.DIRECT_MESSAGES), filters.purposes()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt index b21b7eec0f..7a04901adc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt @@ -46,9 +46,24 @@ import com.vitorpamplona.quartz.utils.Log * * This class performs validation on construction to ensure all string-based identifiers * follow Nostr requirements (64-char hex, onion addresses) and logs errors for invalid inputs. + * + * ## Subclassing + * + * `open` so a consumer can carry its own client-side metadata alongside a filter — e.g. Amethyst's + * `ExplainedFilter`, which records *why* a subscription exists so the UI can explain which relays + * serve which purpose. + * + * Subclass fields never reach a relay: `FilterSerializer` is registered for this type and writes an + * explicit protocol field list, so any extra state is dropped at the wire boundary regardless of the + * runtime type. That is deliberate — the purpose of a REQ is exactly the sort of thing a client + * should not hand to relays. + * + * A subclass MUST override [copy]. Filters are copied on the live path (`copy(since = …)` after each + * EOSE), so a subclass that inherits the base implementation is silently downgraded to a plain + * [Filter] on the first window refresh. */ @Stable -class Filter( +open class Filter( val ids: List? = null, val authors: List? = null, val kinds: List? = null, @@ -63,7 +78,7 @@ class Filter( fun match(event: Event) = FilterMatcher.match(event, ids, authors, kinds, tags, tagsAll, since, until) - fun copy( + open fun copy( ids: List? = this.ids, authors: List? = this.authors, kinds: List? = this.kinds, From 8c9475a3fbd3e3ecd38d2efe2dfc50b180f549d9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 09:59:23 -0400 Subject: [PATCH 003/227] feat(relays): tag every relay-bound filter with its purpose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the sweep: all 343 filters that reach a relay now carry a SubPurpose, so `client.activeRequests(relay)` can answer "what is this relay doing for me?" for every connection, not just the three assemblers wired as proof. Scope was bigger than the "12 assemblers" first estimated — that count was files calling newSubId(). The real target is filters wrapped in RelayBasedFilter, since those are the ones that reach the wire: 202 files. The other ~400 `Filter(` sites in the app query LocalCache and never leave the device, so they are deliberately untouched. Purposes assigned by subsystem: DIRECT_MESSAGES for the DM/giftwrap paths, NOTIFICATIONS for the inbox filters, FOLLOW_LISTS / PROFILE_METADATA / RELAY_LISTS for the account loaders, CHATS for public channels, relay groups, Concord, nests and Marmot, HOME_FEED for the follow feed, WALLET for Cashu and nutzaps, and SCREEN_CONTENT for everything opened on demand (thread, profile, hashtag, video, polls, music, podcasts, git repos, workouts, chess). Verified on device rather than by inspection — 369 relays attributed through client.activeRequests() after a cold start, with no untagged relay-bound filter left (`0` by grep). Observed distribution across relays: FOLLOW_LISTS 921 · HOME_FEED 895 · PROFILE_METADATA 881 · NOTIFICATIONS 690 CHATS 82 · SCREEN_CONTENT 81 · WALLET 38 · DIRECT_MESSAGES 17 · RELAY_LISTS 8 which reads correctly: the outbox fan-out puts follows/feed/metadata/notifications on almost every relay, while DMs and relay-list discovery stay on the few relays that actually serve them, and the account's own relay carries everything. That measurement also corrects an assumption behind the planned notification copy: relays are NOT partitioned by job. A typical relay serves four purposes at once, so "4 for notifications, 3 for DMs" would be wrong — per-purpose counts overlap and sum to more than the relay count. The popup wording needs to reflect that, which is why it is still not written here. Co-Authored-By: Claude Opus 5 (1M context) --- .../drafts/FilterDraftsAndReportsFromKey.kt | 6 ++- .../AccountFollowsLoaderSubAssembler.kt | 4 +- .../FilterAccountInfoAndListsFromKey.kt | 12 ++++-- .../FilterBasicAccountInfoFromKeys.kt | 9 ++-- .../FilterBookmarksAndReportsFromKey.kt | 6 ++- .../metadata/FilterFollowsAndMutesFromKey.kt | 6 ++- .../metadata/FilterLastPostsFromKey.kt | 6 ++- .../FilterNotificationsToPubkey.kt | 36 ++++++++++------ .../NwcNotificationsEoseManager.kt | 5 ++- .../FilterChannelMetadataCreationById.kt | 6 ++- .../FilterChannelMetadataUpdatesById.kt | 6 ++- .../FilterLiveStreamUpdatesByAddress.kt | 6 ++- .../loaders/FilterMissingAddressables.kt | 9 ++-- .../event/loaders/FilterMissingEvents.kt | 5 ++- .../FilterRepliesAndReactionsToAddresses.kt | 15 ++++--- .../FilterRepliesAndReactionsToNotes.kt | 12 ++++-- .../user/watchers/FilterReportsToKey.kt | 6 ++- .../user/watchers/FilterUserMetadataForKey.kt | 9 ++-- .../subassemblies/FilterByAuthor.kt | 6 ++- .../subassemblies/SearchPeopleByName.kt | 6 ++- .../subassemblies/SearchPostsByText.kt | 12 ++++-- .../datasource/FilterAppRecommendations.kt | 9 ++-- .../badges/datasource/FilterBadges.kt | 9 ++-- .../datasource/FilterReceivedBadgeAwards.kt | 6 ++- .../subassemblies/FilterCalendarsByAuthors.kt | 6 ++- .../FilterCalendarsByGeohashes.kt | 6 ++- .../subassemblies/FilterCalendarsByHashtag.kt | 6 ++- .../subassemblies/FilterCalendarsGlobal.kt | 6 ++- .../privateDM/datasource/FilterNip04DMs.kt | 9 ++-- .../ConcordChannelHistoryFilterAssembler.kt | 5 ++- .../FilterGoalForLiveActivity.kt | 9 ++-- .../FilterMessagesToEphemeralChat.kt | 6 ++- .../FilterMessagesToGeohashChat.kt | 6 ++- .../FilterMessagesToLiveStream.kt | 6 ++- .../FilterMessagesToPublicChat.kt | 6 ++- .../FilterMyMessagesToEphemeralChat.kt | 6 ++- .../FilterMyMessagesToLiveActivities.kt | 6 ++- .../FilterMyMessagesToPublicChat.kt | 6 ++- .../subassemblies/FilterRelayGroupState.kt | 9 ++-- .../RelayGroupCardWarmupFilterAssembler.kt | 6 ++- .../datasource/RelayGroupFilterBuilders.kt | 35 +++++++++------ .../FilterRelayGroupsByAuthors.kt | 12 ++++-- .../FilterRelayGroupsByGeohashes.kt | 6 ++- .../FilterRelayGroupsByHashtag.kt | 6 ++- .../FilterRelayGroupsDirectory.kt | 6 ++- .../FilterFollowingEphemeralChats.kt | 6 ++- .../datasource/FilterFollowingGeohashChats.kt | 6 ++- .../datasource/FilterFollowingPublicChats.kt | 6 ++- .../FilterLastMessageFollowingPublicChats.kt | 9 ++-- .../rooms/datasource/FilterNip04DMsFromMe.kt | 6 ++- .../rooms/datasource/FilterNip04DMsToMe.kt | 6 ++- .../CommunityRulesFilterSubAssembler.kt | 6 ++- .../datasource/FilterCommunityPosts.kt | 12 ++++-- .../FilterLongFormByAllCommunities.kt | 9 ++-- .../subassemblies/FilterLongFormByAuthors.kt | 6 ++- .../FilterLongFormByCommunity.kt | 9 ++-- .../subassemblies/FilterLongFormByGeohash.kt | 6 ++- .../subassemblies/FilterLongFormByHashtag.kt | 6 ++- .../subassemblies/FilterLongFormGlobal.kt | 6 ++- .../FilterPublicChatsByAllCommunities.kt | 9 ++-- .../FilterPublicChatsByAuthors.kt | 6 ++- .../FilterPublicChatsByCommunity.kt | 9 ++-- .../FilterPublicChatsByGeohash.kt | 6 ++- .../FilterPublicChatsByHashtag.kt | 6 ++- .../subassemblies/FilterPublicChatsGlobal.kt | 9 ++-- .../FilterFollowSetsByAllCommunities.kt | 9 ++-- .../FilterFollowSetsByAuthors.kt | 6 ++- .../FilterFollowSetsByCommunity.kt | 9 ++-- .../FilterFollowSetsByGeohash.kt | 6 ++- .../FilterFollowSetsByHashtag.kt | 6 ++- .../subassemblies/FilterFollowSetsGlobal.kt | 6 ++- .../FilterLiveActivitiesByAllCommunities.kt | 9 ++-- .../FilterLiveActivitiesByAuthors.kt | 9 ++-- .../FilterLiveActivitiesByCommunity.kt | 9 ++-- .../FilterLiveActivitiesByGeohash.kt | 6 ++- .../FilterLiveActivitiesByHashtag.kt | 6 ++- .../FilterLiveActivitiesGlobal.kt | 9 ++-- .../FilterCommunitiesByAllCommunities.kt | 9 ++-- .../FilterCommunitiesByAuthors.kt | 6 ++- .../FilterCommunitiesByCommunity.kt | 6 ++- .../FilterCommunitiesByGeohash.kt | 6 ++- .../FilterCommunitiesByHashtag.kt | 6 ++- .../subassemblies/FilterCommunitiesGlobal.kt | 9 ++-- .../FilterContentDVMsByAllCommunities.kt | 9 ++-- .../FilterContentDVMsByAuthors.kt | 6 ++- .../FilterContentDVMsByCommunity.kt | 9 ++-- .../FilterContentDVMsByGeohash.kt | 6 ++- .../FilterContentDVMsByHashtag.kt | 6 ++- .../subassemblies/FilterContentDVMsGlobal.kt | 6 ++- .../FilterClassifiedsByAllCommunities.kt | 9 ++-- .../FilterClassifiedsByAuthors.kt | 6 ++- .../FilterClassifiedsByCommunity.kt | 9 ++-- .../FilterClassifiedsByGeohash.kt | 6 ++- .../FilterClassifiedsByHashtag.kt | 6 ++- .../subassemblies/FilterClassifiedsGlobal.kt | 6 ++- .../FilterBrowseEmojiSetsByAuthors.kt | 6 ++- .../FilterBrowseEmojiSetsByHashtag.kt | 6 ++- .../FilterBrowseEmojiSetsGlobal.kt | 6 ++- .../datasource/FilterPostsByGeohash.kt | 9 ++-- .../datasource/FilterRepositoryContent.kt | 6 ++- .../FilterGitRepositoriesByAllCommunities.kt | 15 ++++--- .../FilterGitRepositoriesByAuthors.kt | 6 ++- .../FilterGitRepositoriesByGeohashes.kt | 6 ++- .../FilterGitRepositoriesByHashtag.kt | 6 ++- .../FilterGitRepositoriesGlobal.kt | 6 ++- .../hashtag/datasource/FilterHashtagLabels.kt | 6 ++- .../datasource/FilterPostsByHashtags.kt | 12 ++++-- .../FilterHighlightsByAuthors.kt | 6 ++- .../FilterHighlightsByGeohashes.kt | 6 ++- .../FilterHighlightsByHashtag.kt | 6 ++- .../subassemblies/FilterHighlightsGlobal.kt | 6 ++- .../nip01Core/FilterHomePostsByGeohashes.kt | 6 ++- .../nip01Core/FilterHomePostsByGlobal.kt | 12 ++++-- .../nip01Core/FilterHomePostsByHashtags.kt | 6 ++- .../nip01Core/FilterHomePostsByRelay.kt | 12 ++++-- .../nip22Comments/FilterPostsByScopes.kt | 6 ++- .../nip65Follows/FilterHomePostsByAuthors.kt | 12 ++++-- .../FilterHomePostsByAllCommunities.kt | 9 ++-- .../FilterHomePostsFromCommunities.kt | 9 ++-- .../FilterHomePostsByAlgoFeedIds.kt | 12 ++++-- .../FilterLongsByAllCommunities.kt | 15 ++++--- .../subassemblies/FilterLongsByAuthors.kt | 6 ++- .../subassemblies/FilterLongsByGeohashes.kt | 6 ++- .../subassemblies/FilterLongsByHashtag.kt | 6 ++- .../subassemblies/FilterLongsGlobal.kt | 6 ++- .../FilterMusicEventsByAuthors.kt | 6 ++- .../FilterMusicEventsByCommunities.kt | 15 ++++--- .../FilterMusicEventsByGeohashes.kt | 6 ++- .../FilterMusicEventsByHashtag.kt | 6 ++- .../subassemblies/FilterMusicEventsGlobal.kt | 6 ++- .../subassemblies/FilterNappletsByAuthors.kt | 6 ++- .../subassemblies/FilterNappletsGlobal.kt | 6 ++- .../datasource/NestRoomFilterAssembler.kt | 11 +++-- .../NestRoomLivenessProbeAssembler.kt | 5 ++- .../FilterNestsByAllCommunities.kt | 9 ++-- .../subassemblies/FilterNestsByAuthors.kt | 9 ++-- .../subassemblies/FilterNestsByCommunity.kt | 9 ++-- .../subassemblies/FilterNestsByGeohash.kt | 6 ++- .../subassemblies/FilterNestsByHashtag.kt | 6 ++- .../subassemblies/FilterNestsGlobal.kt | 9 ++-- .../subassemblies/FilterNsitesByAuthors.kt | 6 ++- .../subassemblies/FilterNsitesGlobal.kt | 6 ++- .../FilterPicturesByAllCommunities.kt | 15 ++++--- .../subassemblies/FilterPicturesByAuthors.kt | 6 ++- .../FilterPicturesByGeohashes.kt | 6 ++- .../subassemblies/FilterPicturesByHashtag.kt | 6 ++- .../subassemblies/FilterPicturesGlobal.kt | 6 ++- .../podcasts/datasource/FilterMyPodcast.kt | 9 ++-- .../podcasts/datasource/FilterOnePodcast.kt | 9 ++-- .../FilterPodcastEventsByAuthors.kt | 6 ++- .../FilterPodcastEventsByCommunities.kt | 15 ++++--- .../FilterPodcastEventsByGeohashes.kt | 6 ++- .../FilterPodcastEventsByHashtag.kt | 6 ++- .../FilterPodcastEventsGlobal.kt | 6 ++- .../FilterPollsByAllCommunities.kt | 15 ++++--- .../subassemblies/FilterPollsByAuthors.kt | 6 ++- .../subassemblies/FilterPollsByGeohashes.kt | 6 ++- .../subassemblies/FilterPollsByHashtag.kt | 6 ++- .../subassemblies/FilterPollsGlobal.kt | 6 ++- .../datasource/FilterUserProfileFollowers.kt | 6 ++- .../datasource/FilterUserProfileLastSeen.kt | 6 ++- .../datasource/FilterUserProfileLists.kt | 6 ++- .../datasource/FilterUserProfileMedia.kt | 9 ++-- .../datasource/FilterUserProfileMetadata.kt | 6 ++- .../datasource/FilterUserProfilePosts.kt | 12 ++++-- .../FilterUserProfileZapReceived.kt | 6 ++- .../relay/datasource/FilterPostsByRelay.kt | 9 ++-- .../RelayInfoNip66FilterSubAssembler.kt | 6 ++- .../FilterPollsByAllCommunities.kt | 15 ++++--- .../subassemblies/FilterShortsByAuthors.kt | 6 ++- .../subassemblies/FilterShortsByGeohashes.kt | 6 ++- .../subassemblies/FilterShortsByHashtag.kt | 6 ++- .../subassemblies/FilterShortsGlobal.kt | 6 ++- .../FilterSoftwareAppsByAuthors.kt | 6 ++- .../FilterSoftwareAppsByHashtag.kt | 6 ++- .../subassemblies/FilterSoftwareAppsGlobal.kt | 6 ++- .../FilterEventsInThreadForRoot.kt | 15 ++++--- .../url/datasource/FilterPostsByUrl.kt | 6 ++- .../FilterPictureAndVideoByGeohash.kt | 9 ++-- .../FilterPictureAndVideoByHashtag.kt | 9 ++-- .../nip01Core/FilterPictureAndVideoGlobal.kt | 9 ++-- .../FilterPictureAndVideoByAuthors.kt | 9 ++-- .../FilterPictureAndVideoByAllCommunities.kt | 15 ++++--- .../FilterPictureAndVideoByCommunity.kt | 15 ++++--- .../datasource/OnchainZapsFilterAssembler.kt | 9 ++-- .../FilterWorkoutsByAllCommunities.kt | 15 ++++--- .../subassemblies/FilterWorkoutsByAuthors.kt | 6 ++- .../FilterWorkoutsByGeohashes.kt | 6 ++- .../subassemblies/FilterWorkoutsByHashtag.kt | 6 ++- .../subassemblies/FilterWorkoutsGlobal.kt | 6 ++- .../actions/ConcordSubscriptionPlanner.kt | 13 ++++-- .../subscription/ChessFilterBuilder.kt | 43 ++++++++++++++----- .../CashuMintDirectoryFilterAssembler.kt | 11 +++-- .../assemblers/CashuWalletFilterAssembler.kt | 9 ++-- .../assemblers/ContactCardFilters.kt | 9 ++-- .../nip17Dm/FilterGiftWrapsToPubkey.kt | 6 ++- 196 files changed, 1065 insertions(+), 524 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt index e4525e9537..4f7ed60e8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -42,7 +43,8 @@ fun filterDraftsFromKey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = DraftKinds, authors = listOf(pubkey), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 5a88cff00b..6a3e4ca2d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -165,7 +167,7 @@ class AccountFollowsLoaderSubAssembler( if (users.isNotEmpty()) { RelayBasedFilter( relay = relay, - filter = Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), authors = users.sorted()), + filter = ExplainedFilter(purpose = SubPurpose.FOLLOW_LISTS, kinds = listOf(AdvertisedRelayListEvent.KIND), authors = users.sorted()), ) } else { null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index f45feb7d97..783b3ce15c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsByAuthorInTheRelay +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent @@ -28,7 +30,6 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -117,7 +118,8 @@ fun filterAccountInfoAndListsFromKey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PROFILE_METADATA, kinds = AccountInfoAndListsFromKeyKinds, authors = listOf(pubkey), limit = 20, @@ -127,7 +129,8 @@ fun filterAccountInfoAndListsFromKey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PROFILE_METADATA, kinds = AccountInfoAndListsFromKeyKinds2, authors = listOf(pubkey), limit = 80, @@ -144,7 +147,8 @@ fun filterAccountInfoAndListsFromKey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PROFILE_METADATA, kinds = AmethystMetadataKinds, authors = listOf(pubkey), tags = AmethystMetadataTagMapFilter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index 74fee9cd74..7948d3438a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -85,7 +86,8 @@ fun filterBasicAccountInfoFromKeys( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PROFILE_METADATA, kinds = BasicAccountInfoKinds, authors = otherAccounts.toList(), limit = otherAccounts.size * BasicAccountInfoKinds.size, @@ -95,7 +97,8 @@ fun filterBasicAccountInfoFromKeys( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PROFILE_METADATA, kinds = BasicAccountInfoKinds2, authors = otherAccounts.toList(), limit = otherAccounts.size * BasicAccountInfoKinds2.size, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt index 1dbe220f80..8d2b5681c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent @@ -52,7 +53,8 @@ fun filterBookmarksAndReportsFromKey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PROFILE_METADATA, kinds = ReportsAndBookmarksFromKeyKinds, authors = listOf(pubkey), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt index 43051262b8..d046e3cedf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent @@ -56,7 +57,8 @@ fun filterFollowsAndMutesFromKey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, kinds = FollowAndMutesFromKeyKinds, authors = listOf(pubkey), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt index a713795e90..10e98a959e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterLastPostsFromKey( @@ -36,7 +37,8 @@ fun filterLastPostsFromKey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PROFILE_METADATA, authors = listOf(pubkey), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 257f535dd2..ea6b03fbe1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent @@ -30,7 +32,6 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -154,7 +155,8 @@ fun filterNotificationsHistoryToPubkey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, kinds = AllNotificationKinds, tags = mapOf("p" to listOf(pubkey)), limit = limit, @@ -181,7 +183,8 @@ fun filterGroupNotificationsHistoryToPubkey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, kinds = GroupNotificationKinds, tags = mapOf("p" to listOf(pubkey), "h" to groupIds), limit = limit, @@ -202,7 +205,8 @@ fun filterSummaryNotificationsToPubkey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, kinds = SummaryKinds, tags = mapOf("p" to listOf(pubkey)), limit = 2000, @@ -223,7 +227,8 @@ fun filterNotificationsToPubkey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, kinds = NotificationsPerKeyKinds, tags = mapOf("p" to listOf(pubkey)), limit = 500, @@ -233,7 +238,8 @@ fun filterNotificationsToPubkey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), limit = 200, @@ -243,7 +249,8 @@ fun filterNotificationsToPubkey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, kinds = NotificationsPerKeyKinds3, tags = mapOf("p" to listOf(pubkey)), limit = 10, @@ -271,7 +278,8 @@ fun filterGroupNotificationsToPubkey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, kinds = GroupNotificationKinds, tags = mapOf("p" to listOf(pubkey), "h" to groupIds), limit = 200, @@ -292,7 +300,8 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, kinds = SummaryKinds, tags = mapOf("p" to listOf(pubkey)), limit = 20, @@ -302,7 +311,8 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, kinds = NotificationsPerKeyKinds, tags = mapOf("p" to listOf(pubkey)), limit = 20, @@ -312,7 +322,8 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), limit = 10, @@ -322,7 +333,8 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, kinds = NotificationsPerKeyKinds3, tags = mapOf("p" to listOf(pubkey)), limit = 2, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt index fc3a5b8f3a..3fc03aed4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47WalletConnect +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState @@ -87,7 +89,8 @@ class NwcNotificationsEoseManager( RelayBasedFilter( relay = wallet.uri.relayUri, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND), authors = listOf(wallet.uri.pubKeyHex), tags = mapOf("p" to listOf(signer.pubKey)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt index c10fe0477e..daf39c13a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt @@ -23,9 +23,10 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28P import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.utils.mapOfSet @@ -54,7 +55,8 @@ fun filterMissingChannelsById(keys: List): List>): if (it.value.isNotEmpty()) { RelayBasedFilter( relay = it.key, - filter = Filter(ids = it.value.sorted()), + filter = ExplainedFilter(purpose = SubPurpose.SCREEN_CONTENT, ids = it.value.sorted()), ) } else { null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index 45296e8e5f..bd312710ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -91,7 +92,8 @@ fun filterRepliesAndReactionsToAddresses( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = RepliesAndReactionsToAddressesKinds1, tags = mapOf("a" to sortedList), since = since, @@ -102,7 +104,8 @@ fun filterRepliesAndReactionsToAddresses( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = PostsAndChatMessagesToAddresses, tags = mapOf("a" to sortedList), since = since, @@ -113,7 +116,8 @@ fun filterRepliesAndReactionsToAddresses( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = DeletionKindList, tags = mapOf("a" to sortedList), since = since, @@ -124,7 +128,8 @@ fun filterRepliesAndReactionsToAddresses( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = TextNoteKindList, tags = mapOf("q" to sortedList), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 8d951f5de9..bb5815838d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -22,13 +22,14 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -98,7 +99,8 @@ fun filterRepliesAndReactionsToNotes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = RepliesAndReactionsKinds, tags = mapOf("e" to sortedList), since = since, @@ -109,7 +111,8 @@ fun filterRepliesAndReactionsToNotes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = RepliesAndReactionsKinds2, tags = mapOf("e" to sortedList), since = since, @@ -119,7 +122,8 @@ fun filterRepliesAndReactionsToNotes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(TextNoteEvent.KIND, CommentEvent.KIND), tags = mapOf("q" to sortedList), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt index 481be74b49..36a3ad2efc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -38,7 +39,8 @@ fun filterReportsToKeysFromTrusted( return RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PROFILE_METADATA, kinds = ReportKindList, authors = trustedAccounts, tags = mapOf("p" to targets.sorted()), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index 24295084b5..14ec5e3d96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast @@ -28,7 +30,6 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent @@ -111,7 +112,8 @@ fun filterUserMetadataForKey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PROFILE_METADATA, kinds = UserMetadataForKeyKinds, authors = firstTimers.sorted(), ), @@ -123,7 +125,8 @@ fun filterUserMetadataForKey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PROFILE_METADATA, kinds = UserMetadataForKeyKinds, authors = updates.sorted(), since = minimumTime, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt index dc38aef1ca..9d46b41164 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet @@ -53,7 +54,8 @@ fun filterByAuthor( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = MetadataKindList, authors = myUser, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt index 0351c87307..ac59e2cb63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun searchPeopleByName( @@ -33,7 +34,8 @@ fun searchPeopleByName( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(MetadataEvent.KIND), search = searchString, limit = 1000, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index 1a6e0be1d5..12d6cb4888 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent @@ -31,7 +33,6 @@ import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -112,7 +113,8 @@ fun searchPostsByText( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = SearchPostsByTextKinds1, search = searchString, limit = 100, @@ -121,7 +123,8 @@ fun searchPostsByText( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = SearchPostsByTextKinds2, search = searchString, limit = 100, @@ -130,7 +133,8 @@ fun searchPostsByText( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = SearchPostsByTextKinds3, search = searchString, limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt index ab1c3b4def..dcdfb26baf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent @@ -42,7 +43,8 @@ fun filterMyAppRecommendations( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(AppRecommendationEvent.KIND), authors = authors, limit = 100, @@ -61,7 +63,8 @@ fun filterRecentAppDefinitions(relays: Set): List = listOf( RelayBasedFilter( relay = groupId.relayUrl, - filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), + filter = ExplainedFilter(purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), ), // Pins stay in their own filter for the same reason the directory filters split them out. RelayBasedFilter( relay = groupId.relayUrl, - filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), + filter = ExplainedFilter(purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_PIN_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), ), ) @@ -286,8 +287,8 @@ fun buildRelayGroupStateFilters( val scope = mapOf(D_TAG to ids.distinct()) val since = sinceForRelay(relay) listOf( - RelayBasedFilter(relay = relay, filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = since)), - RelayBasedFilter(relay = relay, filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = since)), + RelayBasedFilter(relay = relay, filter = ExplainedFilter(purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = since)), + RelayBasedFilter(relay = relay, filter = ExplainedFilter(purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = since)), ) } @@ -324,7 +325,8 @@ fun buildRelayGroupJoinedChatTailFilter( RelayBasedFilter( relay = groupId.relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -359,7 +361,8 @@ fun buildRelayGroupPreviewFilter( RelayBasedFilter( relay = groupId.relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -379,7 +382,8 @@ fun buildRelayGroupAuxFilter( RelayBasedFilter( relay = groupId.relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_AUX_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -395,7 +399,8 @@ fun buildRelayGroupOpenChatTailFilter( RelayBasedFilter( relay = groupId.relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_OPEN_TAIL_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -418,7 +423,8 @@ fun buildRelayGroupHistoryFilters( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), until = until, @@ -438,7 +444,8 @@ fun buildRelayGroupDirectoryFilter( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_DIRECTORY_KINDS, limit = RELAY_GROUP_DIRECTORY_LIMIT, since = sinceEpoch, @@ -461,7 +468,8 @@ fun buildRelayGroupThreadsHistoryFilters( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_THREAD_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), until = until, @@ -478,7 +486,8 @@ fun buildRelayGroupThreadsFilter( RelayBasedFilter( relay = groupId.relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_THREAD_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt index 812a427e61..3c226fbc4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt @@ -21,13 +21,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -68,7 +69,8 @@ fun filterRelayGroupsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, authors = authorList, kinds = listOf(GroupMetadataEvent.KIND), limit = 200, @@ -79,7 +81,8 @@ fun filterRelayGroupsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(GroupAdminsEvent.KIND, GroupMembersEvent.KIND), tags = mapOf(PTag.TAG_NAME to authorList), limit = 200, @@ -98,7 +101,8 @@ fun filterRelayGroupsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf(DTag.TAG_NAME to rosterGroupIds), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt index f0c140077b..e1cea18d21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent @@ -39,7 +40,8 @@ fun filterRelayGroupsByGeohashes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf(GeoHashTag.TAG_NAME to geotags.map { it.lowercase() }.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt index 908337a8fc..7c860fd57d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent @@ -37,7 +38,8 @@ fun filterRelayGroupsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf(HashtagTag.TAG_NAME to hashtags.map { it.lowercase() }), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt index 41cd15fbbf..d0286410bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt @@ -20,8 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent @@ -51,7 +52,8 @@ fun filterRelayGroupsDirectory( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_DISCOVERY_KINDS, limit = 500, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt index 20d2371956..64d35f7ba6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.commons.defaults.Constants +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.utils.mapOfSet fun filterFollowingEphemeralChats( @@ -52,7 +53,8 @@ fun filterFollowingEphemeralChats( // Metadata comes from any relay relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(EphemeralChatEvent.KIND), tags = mapOf("d" to it.value.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt index bb63652938..3d75e69a24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.geohash.GeohashRelays import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** @@ -53,7 +54,8 @@ fun filterFollowingGeohashChats( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(GeohashChatEvent.KIND), tags = mapOf("g" to cells), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt index c16e287939..c5d827e9be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.commons.defaults.Constants +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.utils.mapOfSet @@ -56,7 +57,8 @@ fun filterFollowingPublicChatsCreationEvent( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(ChannelCreateEvent.KIND), ids = it.value.sorted(), since = since?.get(it.key)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt index 5e898c7640..18623f9d1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.commons.defaults.Constants +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.utils.mapOfSet @@ -57,7 +58,8 @@ fun filterLastMessageFollowingPublicChats( // Metadata comes from any relay relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(ChannelMetadataEvent.KIND), tags = mapOf("e" to it.value.sorted()), since = since?.get(it.key)?.time, @@ -67,7 +69,8 @@ fun filterLastMessageFollowingPublicChats( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(ChannelMessageEvent.KIND), tags = mapOf("e" to it.value.sorted()), since = since?.get(it.key)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt index c000c246d1..f3dc3fac05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -36,7 +37,8 @@ fun filterNip04DMsFromMe( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(PrivateDmEvent.KIND), authors = listOf(user.pubkeyHex), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt index 4b1148e066..9ef0d41e03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -36,7 +37,8 @@ fun filterNip04DMsToMe( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(PrivateDmEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt index a1a0caeb74..bd01433817 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent @@ -64,7 +65,8 @@ class CommunityRulesFilterSubAssembler( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(CommunityRulesEvent.KIND), authors = signers.sorted(), tags = mapOf("a" to listOf(commEvent.addressTag())), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt index e0561e6eec..7a1fe9b11e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -54,7 +55,8 @@ fun filterCommunityPosts( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = community.moderatorKeys(), tags = mapOf("a" to listOf(community.addressTag())), kinds = CommunityPostKinds, @@ -72,7 +74,8 @@ fun filterCommunityPostsFromModerators( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors.sorted(), tags = mapOf("a" to listOf(community.addressTag())), kinds = CommunityPostKinds, @@ -89,7 +92,8 @@ fun filterCommunityPostsFromEverybody( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("a" to listOf(community.addressTag())), kinds = CommunityPostKinds, limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt index c45cc47a10..7b4dfdb34b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -40,7 +41,8 @@ fun filterLongFormAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -55,7 +57,8 @@ fun filterLongFormAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf(LongTextNoteEvent.KIND), limit = 30, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt index ff298fbbf0..f823a69bb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -39,7 +40,8 @@ fun filterLongFormAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(LongTextNoteEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt index 5817a1c822..330672bb08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -40,7 +41,8 @@ fun filterLongFormByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -56,7 +58,8 @@ fun filterLongFormByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = listOf(LongTextNoteEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt index d7b4d6b1d0..b0f6f57c1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByGeohash import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -41,7 +42,8 @@ fun filterLongFormByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(LongTextNoteEvent.KIND), tags = mapOf("g" to geoHashes), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt index 9abbaaefb4..2ebbdb16a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -41,7 +42,8 @@ fun filterLongFormByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(LongTextNoteEvent.KIND), tags = mapOf("t" to hashtags), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt index 2427aea0c6..8732d949b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -39,7 +40,8 @@ fun filterLongFormGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(LongTextNoteEvent.KIND), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt index f1ba91c61b..47983b6b80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -42,7 +43,8 @@ fun filterPublicChatsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -57,7 +59,8 @@ fun filterPublicChatsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt index d97ffa378b..9b6642d92d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -41,7 +42,8 @@ fun filterPublicChatsAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, authors = authorList, kinds = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt index b5d96f3ade..22148c2286 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -42,7 +43,8 @@ fun filterPublicChatsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -58,7 +60,8 @@ fun filterPublicChatsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt index e4fbc745bb..a4261d080b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -42,7 +43,8 @@ fun filterPublicChatsByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf( ChannelCreateEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt index 47033dd904..35447b6ef7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent @@ -43,7 +44,8 @@ fun filterPublicChatsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf( ChannelCreateEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt index a2261c1945..5d2ea4f71c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @@ -42,7 +43,8 @@ fun filterPublicChatsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf( ChannelCreateEvent.KIND, @@ -55,7 +57,8 @@ fun filterPublicChatsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf( ChannelMessageEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt index 4d7e1a8507..6ed7223153 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsAllCommunities import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -41,7 +42,8 @@ fun filterFollowSetsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -56,7 +58,8 @@ fun filterFollowSetsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf(FollowListEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt index 6153bdcb74..9cd220a447 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent @@ -39,7 +40,8 @@ fun filterFollowSetsAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, authors = authorList, kinds = listOf(FollowListEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt index 017e5474ee..6f9ee782ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -40,7 +41,8 @@ fun filterFollowSetsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -56,7 +58,8 @@ fun filterFollowSetsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = listOf(FollowListEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt index ef53490894..e4eca9fa18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent @@ -40,7 +41,8 @@ fun filterFollowSetsByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, kinds = listOf(FollowListEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt index f85d36bc73..12a994c227 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent @@ -41,7 +42,8 @@ fun filterFollowSetsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, kinds = listOf(FollowListEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt index 315e6172f5..788ee527b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent fun filterFollowSetsGlobal( @@ -38,7 +39,8 @@ fun filterFollowSetsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, kinds = listOf(FollowListEvent.KIND), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt index 7ed9ba7962..d82f308332 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent @@ -43,7 +44,8 @@ fun filterLiveActivitiesAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -58,7 +60,8 @@ fun filterLiveActivitiesAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt index 1f576d4306..cf607a6a22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent @@ -42,7 +43,8 @@ fun filterLiveActivitiesAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 300, @@ -53,7 +55,8 @@ fun filterLiveActivitiesAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("p" to authorList), kinds = listOf(LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt index 402d490881..2f5532ab29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent @@ -43,7 +44,8 @@ fun filterLiveActivitiesByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -59,7 +61,8 @@ fun filterLiveActivitiesByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt index 7e5b1d0323..b2c1ed117e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent @@ -43,7 +44,8 @@ fun filterLiveActivitiesByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt index 4ecad2cc7e..82342ce2bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent @@ -44,7 +45,8 @@ fun filterLiveActivitiesByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt index 2a0ac27a22..06b2d03af4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @@ -44,7 +45,8 @@ fun filterLiveActivitiesGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 30, since = since ?: TimeUtils.oneWeekAgo(), @@ -53,7 +55,8 @@ fun filterLiveActivitiesGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(LiveActivitiesChatMessageEvent.KIND), limit = 50, since = since ?: TimeUtils.oneDayAgo(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt index deffb3a7b6..424960a7c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -40,7 +41,8 @@ fun filterCommunitiesAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf("a" to communityList), limit = 300, @@ -51,7 +53,8 @@ fun filterCommunitiesAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("a" to communityList), kinds = listOf(ClassifiedsEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt index 1a9305d6d5..529f1d6cd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -40,7 +41,8 @@ fun filterCommunitiesAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt index e03f9aafba..898c32272d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -40,7 +41,8 @@ fun filterClassifiedsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = listOf(community.pubKeyHex), kinds = listOf(CommunityDefinitionEvent.KIND), tags = mapOf("d" to listOf(community.dTag)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt index e51ca971ba..42d37f298b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -41,7 +42,8 @@ fun filterCommunitiesByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), tags = mapOf("g" to geoHashes), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt index f5b4a505bd..c1f879bd82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -42,7 +43,8 @@ fun filterCommunitiesByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), tags = mapOf("t" to hashtags), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt index 7b9eb13088..51f837f044 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -41,7 +42,8 @@ fun filterCommunitiesGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(CommunityDefinitionEvent.KIND), limit = 200, since = since, @@ -50,7 +52,8 @@ fun filterCommunitiesGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, limit = 100, since = since ?: TimeUtils.oneMonthAgo(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt index 86fa3a7b32..746791944c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -40,7 +41,8 @@ fun filterContentDVMsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -55,7 +57,8 @@ fun filterContentDVMsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf(AppDefinitionEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt index 2b7e0c42b3..cc65567771 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -39,7 +40,8 @@ fun filterContentDVMsAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300")), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt index 5a2de14d4a..409f9ece20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -40,7 +41,8 @@ fun filterContentDVMsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -56,7 +58,8 @@ fun filterContentDVMsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = listOf(AppDefinitionEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt index d83d1a7bd5..69cf77ab24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -40,7 +41,8 @@ fun filterContentDVMsByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300"), "g" to geoHashes), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt index d5825d91f4..4e2029ca7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -41,7 +42,8 @@ fun filterContentDVMsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300"), "t" to hashtags), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt index 9c3b2f960c..f172435535 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent fun filterContentDVMsGlobal( @@ -39,7 +40,8 @@ fun filterContentDVMsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300")), limit = 30, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt index 8f79ce5cf3..3539ea66bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -40,7 +41,8 @@ fun filterClassifiedsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -55,7 +57,8 @@ fun filterClassifiedsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("a" to communityList), kinds = listOf(ClassifiedsEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt index df3b190d69..e763c1f898 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -39,7 +40,8 @@ fun filterClassifiedsAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(ClassifiedsEvent.KIND), limit = authorList.size * 10, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt index 102c9b02f9..530c01efab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -40,7 +41,8 @@ fun filterClassifiedsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -56,7 +58,8 @@ fun filterClassifiedsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, tags = mapOf("a" to listOf(community)), kinds = listOf(ClassifiedsEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt index 570fe9e192..1791c2c032 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -40,7 +41,8 @@ fun filterClassifiedsByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(ClassifiedsEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt index bf227d5e00..93695bec05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -41,7 +42,8 @@ fun filterClassifiedsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(ClassifiedsEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt index 9059eca75a..ecd0ace2c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -40,7 +41,8 @@ fun filterClassifiedsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(ClassifiedsEvent.KIND), limit = 30, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt index 91ea31c9d5..fd431a2a25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent @@ -41,7 +42,8 @@ fun filterBrowseEmojiSetsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors.sorted(), kinds = listOf(EmojiPackEvent.KIND), limit = BROWSE_EMOJI_SETS_FEED_LIMIT, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt index 541cccfd2b..6465dd0277 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent @@ -38,7 +39,8 @@ fun filterBrowseEmojiSetsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(EmojiPackEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = BROWSE_EMOJI_SETS_FEED_LIMIT, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt index 87e465d6e2..a4e2a5dc9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -41,7 +42,8 @@ fun filterBrowseEmojiSetsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(EmojiPackEvent.KIND), limit = BROWSE_EMOJI_SETS_FEED_LIMIT, since = sinceValue, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index 86e8f49fa0..63049951d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent @@ -30,7 +32,6 @@ import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfi import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -77,7 +78,8 @@ fun filterPostsByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = geohashesToFollowMap, kinds = PostsByGeohashKinds, limit = 100, @@ -87,7 +89,8 @@ fun filterPostsByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = geohashesScoreMap, kinds = CommentKinds, limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt index b47755d2a2..3078cd0a2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt @@ -20,8 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent @@ -53,7 +54,8 @@ fun filterRepositoryContent( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("a" to listOf(repository.addressTag())), kinds = RepositoryContentKinds, limit = 500, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAllCommunities.kt index 67dbf674eb..db232b27a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAllCommunities.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -51,7 +52,8 @@ fun filterGitRepositoriesFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -66,7 +68,8 @@ fun filterGitRepositoriesFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("a" to communityList), kinds = GitRepositoriesFromCommunityKinds, limit = communityList.size * 20, @@ -105,7 +108,8 @@ fun filterGitRepositoriesFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -121,7 +125,8 @@ fun filterGitRepositoriesFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, tags = mapOf("a" to listOf(community)), kinds = GitRepositoriesFromCommunityKinds, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAuthors.kt index 1c6f3c3e97..8101155951 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent @@ -39,7 +40,8 @@ fun filterGitRepositoriesByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(GitRepositoryEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByGeohashes.kt index 0ec1f50383..262fcfc87d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByGeohashes.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent @@ -38,7 +39,8 @@ fun filterGitRepositoriesByGeohashes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(GitRepositoryEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByHashtag.kt index d9d7768bed..af76ce5426 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent @@ -36,7 +37,8 @@ fun filterGitRepositoriesByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(GitRepositoryEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesGlobal.kt index c02eb4588c..7d3c31815a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -39,7 +40,8 @@ fun filterGitRepositoriesGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(GitRepositoryEvent.KIND), limit = 200, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt index cd0a703978..07116fb9dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip32Labeling.LabelEvent @@ -62,7 +63,8 @@ fun filterHashtagLabels( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(LabelEvent.KIND), authors = authorList, tags = mapOf("l" to labelValues), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index 4941239783..0e3927e13b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent @@ -30,7 +32,6 @@ import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -86,7 +87,8 @@ fun filterPostsByHashtags( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = hashtagsToFollowMap, kinds = PostsByHashtagsKinds, limit = 400, @@ -96,7 +98,8 @@ fun filterPostsByHashtags( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = hashtagsToFollowMap, kinds = PostsByHashtagKinds2, limit = 100, @@ -106,7 +109,8 @@ fun filterPostsByHashtags( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = hashtagScoreMap, kinds = CommentKinds, limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByAuthors.kt index bc8a8c6e42..25b3b2b447 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -39,7 +40,8 @@ fun filterHighlightsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(HighlightEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByGeohashes.kt index f8c59a1521..f41b282833 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByGeohashes.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -38,7 +39,8 @@ fun filterHighlightsByGeohashes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(HighlightEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByHashtag.kt index a303e6ac69..4106d9a7f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -36,7 +37,8 @@ fun filterHighlightsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(HighlightEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsGlobal.kt index dc37650b6e..0534105d1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -39,7 +40,8 @@ fun filterHighlightsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(HighlightEvent.KIND), limit = 200, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt index 4c19d4939c..5c571806ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -64,7 +65,8 @@ fun filterHomePostsByGeohashes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, kinds = HomePostsByGeohashKinds, tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt index c47f7c94b1..46a970546c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsConversationKinds import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds1 import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds2 import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterHomePostsByGlobal( relays: GlobalTopNavPerRelayFilterSet, @@ -43,7 +44,8 @@ fun filterHomePostsByGlobal( RelayBasedFilter( relay = relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, kinds = HomePostsNewThreadKinds1, limit = 50, since = since ?: newThreadSince, @@ -52,7 +54,8 @@ fun filterHomePostsByGlobal( RelayBasedFilter( relay = relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, kinds = HomePostsNewThreadKinds2, limit = 5, since = since ?: newThreadSince, @@ -61,7 +64,8 @@ fun filterHomePostsByGlobal( RelayBasedFilter( relay = relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, kinds = HomePostsConversationKinds, limit = 50, since = since ?: repliesSince, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt index d3542f77c7..c966de7a9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes @@ -28,7 +30,6 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -71,7 +72,8 @@ fun filterHomePostsByHashtags( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, kinds = HomePostsBuHashtagsKinds, tags = mapOf("t" to hashtags), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByRelay.kt index f9516747dd..9c78192919 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByRelay.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsConversationKinds import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds1 import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds2 import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterHomePostsByRelay( relayFilter: RelayTopNavPerRelayFilterSet, @@ -41,7 +42,8 @@ fun filterHomePostsByRelay( RelayBasedFilter( relay = relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, kinds = HomePostsNewThreadKinds1, limit = 500, since = sinceTime ?: newThreadSince, @@ -50,7 +52,8 @@ fun filterHomePostsByRelay( RelayBasedFilter( relay = relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, kinds = HomePostsNewThreadKinds2, limit = 100, since = sinceTime ?: newThreadSince, @@ -59,7 +62,8 @@ fun filterHomePostsByRelay( RelayBasedFilter( relay = relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, kinds = HomePostsConversationKinds, limit = 500, since = sinceTime ?: repliesSince, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByScopes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByScopes.kt index bb82e03ec8..d915ce741f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByScopes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip22Comments/FilterPostsByScopes.kt @@ -20,8 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -38,7 +39,8 @@ fun filterHomePostsByScopes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, kinds = CommentKinds, tags = mapOf("I" to scopesToLoad.toList()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index ad72b49bca..7baa061791 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -32,7 +34,6 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -112,7 +113,8 @@ fun filterNewHomePostsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, kinds = HomePostsNewThreadKinds1, authors = authorList, limit = min(authorList.size * 10, 500), @@ -122,7 +124,8 @@ fun filterNewHomePostsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, kinds = HomePostsNewThreadKinds2, authors = authorList, limit = min(authorList.size * 10, 5), @@ -143,7 +146,8 @@ fun filterReplyHomePostsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, kinds = HomePostsConversationKinds, authors = authorList, limit = min(authorList.size * 10, 500), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt index 850c9f70a9..9b731dd8db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -39,7 +40,8 @@ fun filterHomePostsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -54,7 +56,8 @@ fun filterHomePostsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, tags = mapOf("a" to communityList), kinds = HomePostsFromCommunityKinds, limit = communityList.size * 20, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt index 4c7f5c3006..8284b0bc23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -76,7 +77,8 @@ fun filterHomePostsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -92,7 +94,8 @@ fun filterHomePostsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, authors = authors, tags = mapOf("a" to listOf(community)), kinds = HomePostsFromCommunityKinds, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt index 029546417a..c4edb1073d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent @@ -77,7 +78,8 @@ private fun contentFetchFilters( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, ids = filter.ids.toList(), limit = filter.ids.size, ), @@ -89,7 +91,8 @@ private fun contentFetchFilters( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, tags = mapOf("a" to filter.addresses.toList()), limit = filter.addresses.size, ), @@ -105,7 +108,8 @@ private fun responseListenFilter( ) = RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.HOME_FEED, kinds = listOf( NIP90ContentDiscoveryResponseEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAllCommunities.kt index 0a3954eaab..16871b2ca2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAllCommunities.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent @@ -54,7 +55,8 @@ fun filterLongsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -69,7 +71,8 @@ fun filterLongsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("a" to communityList), kinds = LongsFromCommunityKinds, limit = communityList.size * 20, @@ -108,7 +111,8 @@ fun filterLongsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -124,7 +128,8 @@ fun filterLongsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, tags = mapOf("a" to listOf(community)), kinds = LongsFromCommunityKinds, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAuthors.kt index 4e727a7d52..73f5ed1eaa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent @@ -40,7 +41,8 @@ fun filterLongsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByGeohashes.kt index d1f169ce8b..0a2abe96a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByGeohashes.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent @@ -39,7 +40,8 @@ fun filterLongsByGeohashes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByHashtag.kt index d268494b74..4f7b25a4a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent @@ -37,7 +38,8 @@ fun filterLongsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsGlobal.kt index 3d37837fe0..817a3d0c83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -40,7 +41,8 @@ fun filterLongsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), limit = 200, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt index 6d59548196..8410b07d11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -27,7 +29,6 @@ import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl // Two single-kind constants so each screen's REQ targets only the kind it actually renders. @@ -49,7 +50,8 @@ fun filterMusicEventsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = kinds, limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt index c5bd165f40..055120a439 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -47,7 +48,8 @@ fun filterMusicEventsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -61,7 +63,8 @@ fun filterMusicEventsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = kinds, tags = mapOf("a" to communityList), limit = communityList.size * 20, @@ -103,7 +106,8 @@ fun filterMusicEventsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -118,7 +122,8 @@ fun filterMusicEventsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = kinds, tags = mapOf("a" to listOf(community)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt index 6cd1449c1f..60381612ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterMusicEventsByGeohashes( @@ -38,7 +39,8 @@ fun filterMusicEventsByGeohashes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = kinds, tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt index 4af44740c5..a214d7ea45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterMusicEventsByHashtag( @@ -36,7 +37,8 @@ fun filterMusicEventsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = kinds, tags = mapOf("t" to hashtags.sorted()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt index edd9f97d1a..638b3a378c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterMusicEventsGlobal( relays: GlobalTopNavPerRelayFilterSet, @@ -43,7 +44,8 @@ fun filterMusicEventsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = kinds, limit = 200, since = sinceForRelay, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByAuthors.kt index 54f5a09760..ef69c79137 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByAuthors.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NAPPLET_PAGE_LIMIT import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent @@ -41,7 +42,8 @@ fun filterNappletsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), limit = NAPPLET_PAGE_LIMIT, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsGlobal.kt index 0b95958904..5cb5cbbdcd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsGlobal.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NAPPLET_PAGE_LIMIT import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent @@ -42,7 +43,8 @@ fun filterNappletsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), limit = NAPPLET_PAGE_LIMIT, since = since?.get(it.key)?.time ?: defaultSince, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt index 43788f33b2..5452966918 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt @@ -24,7 +24,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager @@ -90,7 +92,8 @@ class NestRoomFilterSubAssembler( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf( LiveActivitiesChatMessageEvent.KIND, @@ -106,7 +109,8 @@ class NestRoomFilterSubAssembler( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(AdminCommandEvent.KIND), tags = mapOf("a" to listOf(key.note.idHex), "p" to listOf(key.account.pubKey)), since = since?.get(relay)?.time, @@ -124,7 +128,8 @@ class NestRoomFilterSubAssembler( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(MeetingSpaceEvent.KIND), authors = listOf(key.note.address.pubKeyHex), tags = mapOf("d" to listOf(key.note.address.dTag)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt index 5ebe3d2165..64f0fe82f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt @@ -24,7 +24,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager @@ -81,7 +83,8 @@ class NestRoomLivenessSubAssembler( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(MeetingRoomPresenceEvent.KIND), tags = mapOf("a" to listOf(key.note.idHex)), // limit=1: the badge only needs the freshest diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAllCommunities.kt index c6cbf036c7..724525b124 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAllCommunities.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @@ -42,7 +43,8 @@ fun filterNestsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -57,7 +59,8 @@ fun filterNestsAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAuthors.kt index 5aafab07a3..7193db4568 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @@ -40,7 +41,8 @@ fun filterNestsAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, authors = authorList, kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 300, @@ -51,7 +53,8 @@ fun filterNestsAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, tags = mapOf("p" to authorList), kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByCommunity.kt index 99b0e7d98a..30f784649a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByCommunity.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @@ -41,7 +42,8 @@ fun filterNestsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -57,7 +59,8 @@ fun filterNestsByCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByGeohash.kt index 85d6d4af96..53097b8192 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByGeohash.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @@ -41,7 +42,8 @@ fun filterNestsByGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByHashtag.kt index 26547cd596..86fda37ee5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent @@ -42,7 +43,8 @@ fun filterNestsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt index d82ea93c76..b46b99ba7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @@ -54,7 +55,8 @@ fun filterNestsPresence(relay: NormalizedRelayUrl): RelayBasedFilter = RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(MeetingRoomPresenceEvent.KIND), limit = 500, since = TimeUtils.now() - PRESENCE_LOOKBACK_SECONDS, @@ -75,7 +77,8 @@ fun filterNestsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 300, since = roomsSince ?: TimeUtils.oneWeekAgo(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByAuthors.kt index 7966e7d8a4..4c0b896a73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByAuthors.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NSITE_PAGE_LIMIT import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent @@ -41,7 +42,8 @@ fun filterNsitesByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(RootSiteEvent.KIND, NamedSiteEvent.KIND), limit = NSITE_PAGE_LIMIT, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesGlobal.kt index a648789ab1..add70b0565 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesGlobal.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NSITE_PAGE_LIMIT import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent @@ -42,7 +43,8 @@ fun filterNsitesGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(RootSiteEvent.KIND, NamedSiteEvent.KIND), limit = NSITE_PAGE_LIMIT, since = since?.get(it.key)?.time ?: defaultSince, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAllCommunities.kt index b6a05631e3..2e4418c11c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAllCommunities.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -51,7 +52,8 @@ fun filterPicturesFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -66,7 +68,8 @@ fun filterPicturesFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("a" to communityList), kinds = PicturesFromCommunityKinds, limit = communityList.size * 20, @@ -105,7 +108,8 @@ fun filterPicturesFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -121,7 +125,8 @@ fun filterPicturesFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, tags = mapOf("a" to listOf(community)), kinds = PicturesFromCommunityKinds, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt index 7535759e72..0ff545f665 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip68Picture.PictureEvent @@ -39,7 +40,8 @@ fun filterPicturesByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(PictureEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByGeohashes.kt index 3d45dd83d6..1cee1532c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByGeohashes.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip68Picture.PictureEvent @@ -38,7 +39,8 @@ fun filterPicturesByGeohashes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(PictureEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt index 4a35bc0836..1c028ba724 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip68Picture.PictureEvent @@ -36,7 +37,8 @@ fun filterPicturesByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(PictureEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt index ebba2de347..a677839366 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -39,7 +40,8 @@ fun filterPicturesGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(PictureEvent.KIND), limit = 200, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt index 8cd77217a9..6757d8e46e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata @@ -58,7 +59,8 @@ fun filterMyPodcast( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND), since = sinceTime, @@ -68,7 +70,8 @@ fun filterMyPodcast( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = listOf(AppSpecificDataEvent.KIND), tags = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt index 6c061b11a4..c291fd3a1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent @@ -58,7 +59,8 @@ fun filterOnePodcast( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = OnePodcastKinds, authors = listOf(user.pubkeyHex), limit = 500, @@ -70,7 +72,8 @@ fun filterOnePodcast( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(AppSpecificDataEvent.KIND), authors = listOf(user.pubkeyHex), tags = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt index 48a5213fe0..f1a7d8b593 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent @@ -82,7 +83,8 @@ fun filterPodcastEventsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = kinds, tags = additionalTags, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt index edfd12d10f..7bb561c4cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -42,7 +43,8 @@ fun filterPodcastEventsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -56,7 +58,8 @@ fun filterPodcastEventsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = kinds, tags = mergeFilterTags(mapOf("a" to communityList), additionalTags), limit = communityList.size * 20, @@ -100,7 +103,8 @@ fun filterPodcastEventsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -115,7 +119,8 @@ fun filterPodcastEventsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = kinds, tags = mergeFilterTags(mapOf("a" to listOf(community)), additionalTags), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt index 7693832ccc..3bc67fbfab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterPodcastEventsByGeohashes( @@ -39,7 +40,8 @@ fun filterPodcastEventsByGeohashes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = kinds, tags = mergeFilterTags(mapOf("g" to geotags.sorted()), additionalTags), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt index cbd6961074..3bb2b20cec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterPodcastEventsByHashtag( @@ -37,7 +38,8 @@ fun filterPodcastEventsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = kinds, tags = mergeFilterTags(mapOf("t" to hashtags.sorted()), additionalTags), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt index 95e4778505..be9c9a7508 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterPodcastEventsGlobal( relays: GlobalTopNavPerRelayFilterSet, @@ -41,7 +42,8 @@ fun filterPodcastEventsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = kinds, tags = additionalTags, limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAllCommunities.kt index 28fa8900b2..1dfa5faa35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAllCommunities.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -54,7 +55,8 @@ fun filterPollsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -69,7 +71,8 @@ fun filterPollsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("a" to communityList), kinds = PollsFromCommunityKinds, limit = communityList.size * 20, @@ -108,7 +111,8 @@ fun filterPollsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -124,7 +128,8 @@ fun filterPollsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, tags = mapOf("a" to listOf(community)), kinds = PollsFromCommunityKinds, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt index 45f8136654..da1d4cdca1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -40,7 +41,8 @@ fun filterPollsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByGeohashes.kt index cae2f0800e..2085722e70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByGeohashes.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -39,7 +40,8 @@ fun filterPollsByGeohashes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt index 7460ce7e60..baa8fedac3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -37,7 +38,8 @@ fun filterPollsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt index ad0e8a58b1..c497e78810 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -40,7 +41,8 @@ fun filterPollsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), limit = 200, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt index 7a55ddc027..578c2f4c57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent val UserProfileFollowersKinds = listOf(ContactListEvent.KIND) @@ -42,7 +43,8 @@ fun filterUserProfileFollowers( RelayBasedFilter( relay = it, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = UserProfileFollowersKinds, tags = mapOf("p" to listOf(user.pubkeyHex)), since = since?.get(it)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLastSeen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLastSeen.kt index c37e0acc48..9b21f728fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLastSeen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLastSeen.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterUserProfileLastSeen( @@ -35,7 +36,8 @@ fun filterUserProfileLastSeen( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = it.value.sorted(), since = since?.get(it.key)?.time, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt index 2292153d55..781d47ca5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent @@ -53,7 +54,8 @@ fun filterUserProfileLists( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = UserProfileListKinds, authors = it.value.sorted(), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt index ae247c5dd8..1f330f9108 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -57,7 +58,8 @@ fun filterUserProfileMedia( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = UserProfileMediaKinds, authors = listOf(user.pubkeyHex), limit = 200, @@ -69,7 +71,8 @@ fun filterUserProfileMedia( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(LiveActivitiesClipEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt index 2115dc5708..8c3abadfec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent @@ -49,7 +50,8 @@ fun filterUserProfileMetadata( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.PROFILE_METADATA, kinds = UserProfileMetadataKinds, authors = it.value.sorted(), since = since?.get(it.key)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt index 5ecb3cd102..7c8b263b87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -32,7 +34,6 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -103,7 +104,8 @@ fun filterUserProfilePosts( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = UserProfilePostKinds1, authors = listOf(user.pubkeyHex), limit = 500, @@ -113,7 +115,8 @@ fun filterUserProfilePosts( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = UserProfilePostKinds2, authors = listOf(user.pubkeyHex), limit = 50, @@ -123,7 +126,8 @@ fun filterUserProfilePosts( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = UserProfileAppKinds, authors = listOf(user.pubkeyHex), limit = 50, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt index f9fb0bbc74..ca537f2821 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent @@ -44,7 +45,8 @@ fun filterUserProfileZapsReceived( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = UserProfileZapReceiverKinds, tags = mapOf("p" to listOf(user.pubkeyHex)), limit = 1000, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt index 9174f45a76..f4b6755601 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent @@ -29,7 +31,6 @@ import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -78,7 +79,8 @@ fun filterPostsByRelay( RelayBasedFilter( relay = relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = PostsByRelayKinds, limit = 400, since = sinceTime, @@ -87,7 +89,8 @@ fun filterPostsByRelay( RelayBasedFilter( relay = relayUrl, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = PostsByRelayKinds2, limit = 100, since = sinceTime, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterSubAssembler.kt index 676288ee0a..b0c1de7b6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterSubAssembler.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent class RelayInfoNip66FilterSubAssembler( @@ -41,7 +42,8 @@ class RelayInfoNip66FilterSubAssembler( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(RelayDiscoveryEvent.KIND), tags = mapOf("d" to listOf(relayUrl)), limit = 20, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterPollsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterPollsByAllCommunities.kt index d4e83252a9..4b98621fac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterPollsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterPollsByAllCommunities.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent @@ -54,7 +55,8 @@ fun filterShortsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -69,7 +71,8 @@ fun filterShortsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("a" to communityList), kinds = ShortsFromCommunityKinds, limit = communityList.size * 20, @@ -108,7 +111,8 @@ fun filterShortsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -124,7 +128,8 @@ fun filterShortsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, tags = mapOf("a" to listOf(community)), kinds = ShortsFromCommunityKinds, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByAuthors.kt index d8763f31ca..cc528e6f36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByAuthors.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent @@ -40,7 +41,8 @@ fun filterShortsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByGeohashes.kt index 9f8f79b550..1cf52cd61d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByGeohashes.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent @@ -39,7 +40,8 @@ fun filterShortsByGeohashes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByHashtag.kt index 63763933a7..640bc36417 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByHashtag.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent @@ -37,7 +38,8 @@ fun filterShortsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsGlobal.kt index 8b65060c3f..a5c93f3ad7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsGlobal.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -40,7 +41,8 @@ fun filterShortsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), limit = 200, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByAuthors.kt index ff82d23a47..a664958d18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByAuthors.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -27,7 +29,6 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterSoftwareAppsByAuthors( @@ -40,7 +41,8 @@ fun filterSoftwareAppsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(SoftwareApplicationEvent.KIND, SoftwareReleaseEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByHashtag.kt index a2557276d0..48af996e21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByHashtag.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts @@ -42,7 +43,8 @@ fun filterSoftwareAppsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(SoftwareApplicationEvent.KIND, SoftwareReleaseEvent.KIND), tags = mapOf("t" to expanded), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsGlobal.kt index 056c3a067f..778965d85d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsGlobal.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl @@ -54,7 +55,8 @@ fun filterSoftwareAppsGlobal( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(SoftwareApplicationEvent.KIND, SoftwareReleaseEvent.KIND), // Keep the limit < 100. Relays such as zapstore's score filter // specificity and treat a limit >= 100 (or none) as too vague, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt index 3b640f8eb6..ea9d38c759 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterEventsInThreadForRoot( root: Note, @@ -42,7 +43,8 @@ fun filterEventsInThreadForRoot( RelayBasedFilter( relay = it, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("a" to listOf(addressRoot)), since = since, ), @@ -50,7 +52,8 @@ fun filterEventsInThreadForRoot( RelayBasedFilter( relay = it, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("A" to listOf(addressRoot)), since = since, ), @@ -66,7 +69,8 @@ fun filterEventsInThreadForRoot( RelayBasedFilter( relay = it, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("e" to listOf(eventRoot)), since = since, ), @@ -74,7 +78,8 @@ fun filterEventsInThreadForRoot( RelayBasedFilter( relay = it, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("E" to listOf(eventRoot)), since = since, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrl.kt index 6773c35810..e19d24d17f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrl.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.CommentKinds import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId @@ -40,7 +41,8 @@ fun filterPostsByUrl( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = urlScopeMap, kinds = CommentKinds, limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt index d119a1d31e..a807761425 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterPictureAndVideoGeohash( @@ -40,7 +41,8 @@ fun filterPictureAndVideoGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = PictureAndVideoKinds, tags = mapOf("g" to geoHashes), limit = 200, @@ -50,7 +52,8 @@ fun filterPictureAndVideoGeohash( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = PictureAndVideoLegacyKinds, tags = mapOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt index 79fe885a2f..b2afe876cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts @@ -41,7 +42,8 @@ fun filterPictureAndVideoHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = PictureAndVideoKinds, tags = mapOf("t" to hashtags), limit = 100, @@ -51,7 +53,8 @@ fun filterPictureAndVideoHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = PictureAndVideoLegacyKinds, tags = mapOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt index 5f148816d3..7350844617 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypeMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter fun filterPictureAndVideoGlobal( relays: GlobalTopNavPerRelayFilterSet, @@ -41,7 +42,8 @@ fun filterPictureAndVideoGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = PictureAndVideoKinds, limit = 50, since = since, @@ -50,7 +52,8 @@ fun filterPictureAndVideoGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = PictureAndVideoLegacyKinds, tags = LegacyMimeTypeMap, limit = 50, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt index 917b8b73c0..d2b1ce1a12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip65Follows +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -28,7 +30,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAnd import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlin.math.max @@ -42,7 +43,8 @@ fun filterPictureAndVideoAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, authors = authorList, kinds = PictureAndVideoKinds, limit = if (since == null) max(authorList.size * 20, 200) else null, @@ -52,7 +54,8 @@ fun filterPictureAndVideoAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.FOLLOW_LISTS, authors = authorList, kinds = PictureAndVideoLegacyKinds, tags = LegacyMimeTypeMap, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt index 9098635763..863d208797 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes @@ -28,7 +30,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAnd import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent @@ -45,7 +46,8 @@ fun filterPictureAndVideoAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -60,7 +62,8 @@ fun filterPictureAndVideoAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("a" to communityList), kinds = PictureAndVideoKinds, limit = 200, @@ -71,7 +74,8 @@ fun filterPictureAndVideoAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -86,7 +90,8 @@ fun filterPictureAndVideoAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf( "a" to communityList, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt index 37351de18c..45faca0799 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes @@ -28,7 +30,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAnd import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKTags import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoLegacyKinds import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -44,7 +45,8 @@ fun filterPictureAndVideoCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -60,7 +62,8 @@ fun filterPictureAndVideoCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, tags = mapOf("a" to listOf(community)), kinds = PictureAndVideoKinds, @@ -72,7 +75,8 @@ fun filterPictureAndVideoCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -88,7 +92,8 @@ fun filterPictureAndVideoCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, tags = mapOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssembler.kt index ce9a003f8e..76b31da7c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssembler.kt @@ -22,12 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent @@ -73,7 +74,8 @@ private fun filterOnchainZaps( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(OnchainZapEvent.KIND), tags = mapOf("p" to listOf(pubkey)), limit = PER_FILTER_LIMIT, @@ -83,7 +85,8 @@ private fun filterOnchainZaps( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(OnchainZapEvent.KIND), authors = listOf(pubkey), limit = PER_FILTER_LIMIT, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt index 1c6c7c2cf1..155ceb0c1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent @@ -51,7 +52,8 @@ fun filterWorkoutsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -66,7 +68,8 @@ fun filterWorkoutsFromAllCommunities( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, tags = mapOf("a" to communityList), kinds = WorkoutsFromCommunityKinds, limit = communityList.size * 20, @@ -105,7 +108,8 @@ fun filterWorkoutsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -121,7 +125,8 @@ fun filterWorkoutsFromCommunity( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authors, tags = mapOf("a" to listOf(community)), kinds = WorkoutsFromCommunityKinds, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt index f17dad581a..353e2626c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterWorkoutsByAuthors( @@ -39,7 +40,8 @@ fun filterWorkoutsByAuthors( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, authors = authorList, kinds = listOf(WorkoutRecordEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt index 5665bda320..6e8390ad0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterWorkoutsByGeohashes( @@ -38,7 +39,8 @@ fun filterWorkoutsByGeohashes( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(WorkoutRecordEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt index 53c0de12a2..481662285a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterWorkoutsByHashtag( @@ -36,7 +37,8 @@ fun filterWorkoutsByHashtag( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(WorkoutRecordEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt index bca6d8c207..ee8d5a8bec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.utils.TimeUtils fun filterWorkoutsGlobal( @@ -39,7 +40,8 @@ fun filterWorkoutsGlobal( RelayBasedFilter( relay = it.key, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(WorkoutRecordEvent.KIND), limit = 200, since = since, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 9a210a8c95..32ea1e3ad6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.actions +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState @@ -175,7 +177,9 @@ object ConcordSubscriptionPlanner { val lastRead = lastReadFor(channelId.channelId) val filter = if (lastRead > 0L) { - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, + purposeDetail = "concord community planes", kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = authors.toList(), // -1 so the last-read message (created_at == lastRead) is itself returned: @@ -184,7 +188,9 @@ object ConcordSubscriptionPlanner { limit = catchUpLimit, ) } else { - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, + purposeDetail = "concord community planes", kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = authors.toList(), limit = previewLimit, @@ -263,7 +269,8 @@ object ConcordSubscriptionPlanner { RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.CHATS, // Stored plane wraps (1059) plus ephemeral ones (21059) — the latter carry the // live-only typing heartbeats a relay broadcasts but never stores. kinds = listOf(ConcordStreamEnvelope.KIND_WRAP, ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/subscription/ChessFilterBuilder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/subscription/ChessFilterBuilder.kt index 2e52753a6c..364a41e9c4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/subscription/ChessFilterBuilder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/subscription/ChessFilterBuilder.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.nip64Chess.subscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -102,7 +104,8 @@ object ChessFilterBuilder { now: Long = TimeUtils.now(), ): List { val filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(JESTER_KIND), tags = mapOf("p" to listOf(userPubkey)), limit = 100, @@ -130,7 +133,8 @@ object ChessFilterBuilder { now: Long = TimeUtils.now(), ): List { val filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(JESTER_KIND), tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)), limit = 100, @@ -169,7 +173,9 @@ object ChessFilterBuilder { // Game events: filter by e-tag (startEventId) // This catches all moves/events for these games val gameFilter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, + purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), tags = mapOf("e" to startEventIds.toList()), limit = 500, @@ -181,7 +187,9 @@ object ChessFilterBuilder { // Also filter by p-tag (opponent tagged us) for redundancy val tagFilter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, + purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), tags = mapOf("p" to listOf(userPubkey)), limit = 200, @@ -194,7 +202,9 @@ object ChessFilterBuilder { // Also filter by authors (opponent pubkeys) for redundancy if (opponentPubkeys.isNotEmpty()) { val authorFilter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, + purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), authors = opponentPubkeys.toList(), limit = 200, @@ -218,7 +228,8 @@ object ChessFilterBuilder { now: Long = TimeUtils.now(), ): List { val filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = listOf(JESTER_KIND), limit = 100, ) @@ -245,7 +256,9 @@ object ChessFilterBuilder { * In Jester protocol, all game events reference the startEventId via e-tag. */ fun gameEventsFilter(startEventId: String): Filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, + purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), tags = mapOf("e" to listOf(startEventId)), limit = 500, @@ -259,7 +272,9 @@ object ChessFilterBuilder { */ fun challengesFilter(userPubkey: String? = null): Filter { val now = TimeUtils.now() - return Filter( + return ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, + purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)), since = now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS, @@ -273,7 +288,9 @@ object ChessFilterBuilder { */ fun recentGamesFilter(): Filter { val now = TimeUtils.now() - return Filter( + return ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, + purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, limit = 200, @@ -286,7 +303,9 @@ object ChessFilterBuilder { */ fun userGamesFilter(userPubkey: String): Filter { val now = TimeUtils.now() - return Filter( + return ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, + purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), authors = listOf(userPubkey), since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, @@ -300,7 +319,9 @@ object ChessFilterBuilder { */ fun userTaggedFilter(userPubkey: String): Filter { val now = TimeUtils.now() - return Filter( + return ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, + purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), tags = mapOf("p" to listOf(userPubkey)), since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuMintDirectoryFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuMintDirectoryFilterAssembler.kt index d96bb24b14..d40853b271 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuMintDirectoryFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuMintDirectoryFilterAssembler.kt @@ -24,10 +24,11 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent @@ -83,7 +84,9 @@ private class CashuMintDirectorySubAssembler( .ifEmpty { return null } val mintAnnouncements = - Filter( + ExplainedFilter( + purpose = SubPurpose.WALLET, + purposeDetail = "mint directory", kinds = listOf(CashuMintEvent.KIND), ) @@ -91,7 +94,9 @@ private class CashuMintDirectorySubAssembler( // recommended event's kind as a string. We only want cashu mint // recommendations here. val cashuRecommendations = - Filter( + ExplainedFilter( + purpose = SubPurpose.WALLET, + purposeDetail = "mint directory", kinds = listOf(MintRecommendationEvent.KIND), tags = mapOf("k" to listOf(CashuMintEvent.KIND.toString())), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt index c761981205..f86a8b9fe0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt @@ -24,11 +24,12 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent @@ -106,7 +107,8 @@ private class CashuWalletSubAssembler( if (ownEventRelays.isEmpty() && inboxRelays.isEmpty()) return null val ownedFilter = - Filter( + ExplainedFilter( + purpose = SubPurpose.WALLET, kinds = listOf( CashuWalletEvent.KIND, @@ -124,7 +126,8 @@ private class CashuWalletSubAssembler( ) val inboundNutzapsFilter = - Filter( + ExplainedFilter( + purpose = SubPurpose.WALLET, kinds = listOf(NutzapEvent.KIND), tags = mapOf("p" to listOf(pubkey)), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt index 9eab40c51a..742265289c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.assemblers +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent @@ -44,7 +45,8 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( return RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = ContactCardKindList, authors = trustedAccounts, // kind:30382 addresses the target user in the d-tag @@ -68,7 +70,8 @@ fun filterContactCardsByAuthorInTheRelay( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.SCREEN_CONTENT, kinds = ContactCardKindList, authors = listOf(author), limit = limit, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt index 9502c087ad..527a262b5c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt @@ -20,9 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.nip17Dm +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -41,7 +42,8 @@ fun filterGiftWrapsToPubkey( RelayBasedFilter( relay = relay, filter = - Filter( + ExplainedFilter( + purpose = SubPurpose.DIRECT_MESSAGES, kinds = listOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND), tags = mapOf("p" to listOf(pubkey)), // A gift wrap's outer created_at is randomized up to 2 days before the real From 3eedcd9f89034d1ed7c00ebcc531134479b59f49 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 10:27:19 -0400 Subject: [PATCH 004/227] refactor(relays): split SubPurpose into specific jobs, grouped and lifetime-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass put 208 of 343 filters into SCREEN_CONTENT — a bucket covering the whole Discover tab, every media feed, search, threads, profiles, badges, chess and a mis-filed wallet screen. Useless for explaining anything. MODERATION ended up with zero filters despite kind-1984 subscriptions being live, and ENCRYPTED_GROUPS with zero because Marmot builds its filters in quartz and wraps them later, so the sweep's `filter = Filter(` pattern never saw them. SubPurpose is now 27 specific jobs on two axes: - `group` rolls them up (ACCOUNT / MESSAGES / FEEDS / CURRENT_SCREEN) so a notification can say something short while a relay screen or bug report keeps the fine value. Adding a fine value stays cheap. - `isBackground` marks what may outlive the foreground. SCREEN_CONTENT is gone, replaced by DISCOVER_FEED, MEDIA_FEED, TAG_FEED, COMMUNITY_FEED, TOPIC_FEED, THREAD, USER_PROFILE, SEARCH, ENGAGEMENT, REFERENCED_EVENTS, ADD_ONS, GAMES and RELAY_INFO. CHATS splits into PUBLIC_CHATS, COMMUNITY_CHATS, ENCRYPTED_GROUPS and LIVE_ROOMS. Marmot is tagged through ExplainedFilter.of() at the wrap site, since quartz cannot see commons. Every one of the 27 values is applied by at least one filter — checked, not assumed. Nothing in a relay-bound path is left untagged. Measured on device, cold start then HOME: foreground @45s 13 purposes HOME_FEED 337 relays · NOTIFICATIONS 329 · RELAY_LISTS 337 · MODERATION 326 · ENGAGEMENT 20 background @90s 9 purposes all isBackground=true Both `isBackground = false` purposes in flight (HOME_FEED, ENGAGEMENT) were gone after backgrounding, which is the invariant worth having: a CURRENT_SCREEN purpose alive in the background is a leak, and that is now assertable. The same run corrected the flag's documentation. RELAY_LISTS and FOLLOW_LISTS are marked background-capable yet absent at @90s — because those loaders had nothing to fetch, not because they were torn down. So `isBackground` is a ceiling, not a promise: absence proves nothing, presence of a `false` one proves a bug. The KDoc now says that instead of implying the stronger claim. Worth a second look before this ships: WALLET holds 21 filters across 12 relays while backgrounded — more relays than DIRECT_MESSAGES (5) or NOTIFICATIONS (8). That may be correct for nutzap watching, or it may be more sockets than the feature warrants. The tagging is what makes the question askable. Co-Authored-By: Claude Opus 5 (1M context) --- .../drafts/FilterDraftsAndReportsFromKey.kt | 2 +- .../marmot/MarmotGroupEventsEoseManager.kt | 6 +- .../FilterAccountInfoAndListsFromKey.kt | 6 +- .../FilterBasicAccountInfoFromKeys.kt | 4 +- .../FilterBookmarksAndReportsFromKey.kt | 2 +- .../metadata/FilterFollowsAndMutesFromKey.kt | 2 +- .../metadata/FilterLastPostsFromKey.kt | 2 +- .../NwcNotificationsEoseManager.kt | 2 +- .../FilterChannelMetadataCreationById.kt | 2 +- .../FilterChannelMetadataUpdatesById.kt | 2 +- .../FilterLiveStreamUpdatesByAddress.kt | 2 +- .../loaders/FilterMissingAddressables.kt | 4 +- .../event/loaders/FilterMissingEvents.kt | 2 +- .../FilterRepliesAndReactionsToAddresses.kt | 8 +- .../FilterRepliesAndReactionsToNotes.kt | 6 +- .../user/watchers/FilterReportsToKey.kt | 2 +- .../subassemblies/FilterByAuthor.kt | 2 +- .../subassemblies/SearchPeopleByName.kt | 2 +- .../subassemblies/SearchPostsByText.kt | 6 +- .../datasource/FilterAppRecommendations.kt | 4 +- .../badges/datasource/FilterBadges.kt | 4 +- .../datasource/FilterReceivedBadgeAwards.kt | 2 +- .../subassemblies/FilterCalendarsByAuthors.kt | 2 +- .../FilterCalendarsByGeohashes.kt | 2 +- .../subassemblies/FilterCalendarsByHashtag.kt | 2 +- .../subassemblies/FilterCalendarsGlobal.kt | 2 +- .../ConcordChannelHistoryFilterAssembler.kt | 2 +- .../FilterGoalForLiveActivity.kt | 4 +- .../FilterMessagesToEphemeralChat.kt | 2 +- .../FilterMessagesToGeohashChat.kt | 2 +- .../FilterMessagesToLiveStream.kt | 2 +- .../FilterMessagesToPublicChat.kt | 2 +- .../FilterMyMessagesToEphemeralChat.kt | 2 +- .../FilterMyMessagesToLiveActivities.kt | 2 +- .../FilterMyMessagesToPublicChat.kt | 2 +- .../subassemblies/FilterRelayGroupState.kt | 6 +- .../RelayGroupCardWarmupFilterAssembler.kt | 2 +- .../datasource/RelayGroupFilterBuilders.kt | 24 +-- .../FilterRelayGroupsByAuthors.kt | 6 +- .../FilterRelayGroupsByGeohashes.kt | 2 +- .../FilterRelayGroupsByHashtag.kt | 2 +- .../FilterRelayGroupsDirectory.kt | 2 +- .../FilterFollowingEphemeralChats.kt | 2 +- .../datasource/FilterFollowingGeohashChats.kt | 2 +- .../datasource/FilterFollowingPublicChats.kt | 2 +- .../FilterLastMessageFollowingPublicChats.kt | 4 +- .../rooms/datasource/FilterNip04DMsFromMe.kt | 2 +- .../rooms/datasource/FilterNip04DMsToMe.kt | 2 +- .../CommunityRulesFilterSubAssembler.kt | 2 +- .../datasource/FilterCommunityPosts.kt | 6 +- .../FilterLongFormByAllCommunities.kt | 4 +- .../subassemblies/FilterLongFormByAuthors.kt | 2 +- .../FilterLongFormByCommunity.kt | 4 +- .../subassemblies/FilterLongFormByGeohash.kt | 2 +- .../subassemblies/FilterLongFormByHashtag.kt | 2 +- .../subassemblies/FilterLongFormGlobal.kt | 2 +- .../FilterPublicChatsByAllCommunities.kt | 4 +- .../FilterPublicChatsByAuthors.kt | 2 +- .../FilterPublicChatsByCommunity.kt | 4 +- .../FilterPublicChatsByGeohash.kt | 2 +- .../FilterPublicChatsByHashtag.kt | 2 +- .../subassemblies/FilterPublicChatsGlobal.kt | 4 +- .../FilterLiveActivitiesByAllCommunities.kt | 4 +- .../FilterLiveActivitiesByAuthors.kt | 4 +- .../FilterLiveActivitiesByCommunity.kt | 4 +- .../FilterLiveActivitiesByGeohash.kt | 2 +- .../FilterLiveActivitiesByHashtag.kt | 2 +- .../FilterLiveActivitiesGlobal.kt | 4 +- .../FilterCommunitiesByAllCommunities.kt | 4 +- .../FilterCommunitiesByAuthors.kt | 2 +- .../FilterCommunitiesByCommunity.kt | 2 +- .../FilterCommunitiesByGeohash.kt | 2 +- .../FilterCommunitiesByHashtag.kt | 2 +- .../subassemblies/FilterCommunitiesGlobal.kt | 4 +- .../FilterContentDVMsByAllCommunities.kt | 4 +- .../FilterContentDVMsByAuthors.kt | 2 +- .../FilterContentDVMsByCommunity.kt | 4 +- .../FilterContentDVMsByGeohash.kt | 2 +- .../FilterContentDVMsByHashtag.kt | 2 +- .../subassemblies/FilterContentDVMsGlobal.kt | 2 +- .../FilterClassifiedsByAllCommunities.kt | 4 +- .../FilterClassifiedsByAuthors.kt | 2 +- .../FilterClassifiedsByCommunity.kt | 4 +- .../FilterClassifiedsByGeohash.kt | 2 +- .../FilterClassifiedsByHashtag.kt | 2 +- .../subassemblies/FilterClassifiedsGlobal.kt | 2 +- .../FilterBrowseEmojiSetsByAuthors.kt | 2 +- .../FilterBrowseEmojiSetsByHashtag.kt | 2 +- .../FilterBrowseEmojiSetsGlobal.kt | 2 +- .../datasource/FilterPostsByGeohash.kt | 4 +- .../datasource/FilterRepositoryContent.kt | 2 +- .../FilterGitRepositoriesByAllCommunities.kt | 8 +- .../FilterGitRepositoriesByAuthors.kt | 2 +- .../FilterGitRepositoriesByGeohashes.kt | 2 +- .../FilterGitRepositoriesByHashtag.kt | 2 +- .../FilterGitRepositoriesGlobal.kt | 2 +- .../hashtag/datasource/FilterHashtagLabels.kt | 2 +- .../datasource/FilterPostsByHashtags.kt | 6 +- .../FilterHighlightsByAuthors.kt | 2 +- .../FilterHighlightsByGeohashes.kt | 2 +- .../FilterHighlightsByHashtag.kt | 2 +- .../subassemblies/FilterHighlightsGlobal.kt | 2 +- .../nip65Follows/FilterHomePostsByAuthors.kt | 6 +- .../FilterLongsByAllCommunities.kt | 8 +- .../subassemblies/FilterLongsByAuthors.kt | 2 +- .../subassemblies/FilterLongsByGeohashes.kt | 2 +- .../subassemblies/FilterLongsByHashtag.kt | 2 +- .../subassemblies/FilterLongsGlobal.kt | 2 +- .../FilterMusicEventsByAuthors.kt | 2 +- .../FilterMusicEventsByCommunities.kt | 8 +- .../FilterMusicEventsByGeohashes.kt | 2 +- .../FilterMusicEventsByHashtag.kt | 2 +- .../subassemblies/FilterMusicEventsGlobal.kt | 2 +- .../subassemblies/FilterNappletsByAuthors.kt | 2 +- .../subassemblies/FilterNappletsGlobal.kt | 2 +- .../datasource/NestRoomFilterAssembler.kt | 6 +- .../NestRoomLivenessProbeAssembler.kt | 2 +- .../FilterNestsByAllCommunities.kt | 4 +- .../subassemblies/FilterNestsByAuthors.kt | 4 +- .../subassemblies/FilterNestsByCommunity.kt | 4 +- .../subassemblies/FilterNestsByGeohash.kt | 2 +- .../subassemblies/FilterNestsByHashtag.kt | 2 +- .../subassemblies/FilterNestsGlobal.kt | 4 +- .../subassemblies/FilterNsitesByAuthors.kt | 2 +- .../subassemblies/FilterNsitesGlobal.kt | 2 +- .../FilterPicturesByAllCommunities.kt | 8 +- .../subassemblies/FilterPicturesByAuthors.kt | 2 +- .../FilterPicturesByGeohashes.kt | 2 +- .../subassemblies/FilterPicturesByHashtag.kt | 2 +- .../subassemblies/FilterPicturesGlobal.kt | 2 +- .../podcasts/datasource/FilterMyPodcast.kt | 4 +- .../podcasts/datasource/FilterOnePodcast.kt | 4 +- .../FilterPodcastEventsByAuthors.kt | 2 +- .../FilterPodcastEventsByCommunities.kt | 8 +- .../FilterPodcastEventsByGeohashes.kt | 2 +- .../FilterPodcastEventsByHashtag.kt | 2 +- .../FilterPodcastEventsGlobal.kt | 2 +- .../FilterPollsByAllCommunities.kt | 8 +- .../subassemblies/FilterPollsByAuthors.kt | 2 +- .../subassemblies/FilterPollsByGeohashes.kt | 2 +- .../subassemblies/FilterPollsByHashtag.kt | 2 +- .../subassemblies/FilterPollsGlobal.kt | 2 +- .../datasource/FilterUserProfileFollowers.kt | 2 +- .../datasource/FilterUserProfileLastSeen.kt | 2 +- .../datasource/FilterUserProfileLists.kt | 2 +- .../datasource/FilterUserProfileMedia.kt | 4 +- .../datasource/FilterUserProfileMetadata.kt | 2 +- .../datasource/FilterUserProfilePosts.kt | 6 +- .../FilterUserProfileZapReceived.kt | 2 +- .../relay/datasource/FilterPostsByRelay.kt | 4 +- .../RelayInfoNip66FilterSubAssembler.kt | 2 +- .../FilterPollsByAllCommunities.kt | 8 +- .../subassemblies/FilterShortsByAuthors.kt | 2 +- .../subassemblies/FilterShortsByGeohashes.kt | 2 +- .../subassemblies/FilterShortsByHashtag.kt | 2 +- .../subassemblies/FilterShortsGlobal.kt | 2 +- .../FilterSoftwareAppsByAuthors.kt | 2 +- .../FilterSoftwareAppsByHashtag.kt | 2 +- .../subassemblies/FilterSoftwareAppsGlobal.kt | 2 +- .../FilterEventsInThreadForRoot.kt | 8 +- .../url/datasource/FilterPostsByUrl.kt | 2 +- .../FilterPictureAndVideoByGeohash.kt | 4 +- .../FilterPictureAndVideoByHashtag.kt | 4 +- .../nip01Core/FilterPictureAndVideoGlobal.kt | 4 +- .../FilterPictureAndVideoByAuthors.kt | 4 +- .../FilterPictureAndVideoByAllCommunities.kt | 8 +- .../FilterPictureAndVideoByCommunity.kt | 8 +- .../datasource/OnchainZapsFilterAssembler.kt | 4 +- .../FilterWorkoutsByAllCommunities.kt | 8 +- .../subassemblies/FilterWorkoutsByAuthors.kt | 2 +- .../FilterWorkoutsByGeohashes.kt | 2 +- .../subassemblies/FilterWorkoutsByHashtag.kt | 2 +- .../subassemblies/FilterWorkoutsGlobal.kt | 2 +- .../actions/ConcordSubscriptionPlanner.kt | 6 +- .../subscription/ChessFilterBuilder.kt | 22 +-- .../assemblers/ContactCardFilters.kt | 4 +- .../assemblers/ReactionsFilterAssembler.kt | 2 +- .../relayClient/subscriptions/SubPurpose.kt | 148 +++++++++++++++--- 178 files changed, 417 insertions(+), 319 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt index 4f7ed60e8f..80b768321d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt @@ -44,7 +44,7 @@ fun filterDraftsFromKey( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MODERATION, kinds = DraftKinds, authors = listOf(pubkey), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt index 4dc904d02e..fe34cca31b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver @@ -89,7 +91,7 @@ class MarmotGroupEventsEoseManager( "(metadataRelays=${groupRelays?.size ?: 0}, usingFallback=${groupRelays.isNullOrEmpty()}): ${relaysForGroup.map { it.url }}" } for (relay in relaysForGroup) { - result.add(RelayBasedFilter(relay = relay, filter = filter)) + result.add(RelayBasedFilter(relay = relay, filter = ExplainedFilter.of(filter, SubPurpose.ENCRYPTED_GROUPS, "MLS group messages"))) } } @@ -97,7 +99,7 @@ class MarmotGroupEventsEoseManager( if (fallbackRelays.isNotEmpty()) { val ownKeyPackageFilter = manager.subscriptionManager.ownKeyPackageFilter() for (relay in fallbackRelays) { - result.add(RelayBasedFilter(relay = relay, filter = ownKeyPackageFilter)) + result.add(RelayBasedFilter(relay = relay, filter = ExplainedFilter.of(ownKeyPackageFilter, SubPurpose.ENCRYPTED_GROUPS, "own MLS key package"))) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 783b3ce15c..35d9288085 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -119,7 +119,7 @@ fun filterAccountInfoAndListsFromKey( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PROFILE_METADATA, + purpose = SubPurpose.ACCOUNT_DATA, kinds = AccountInfoAndListsFromKeyKinds, authors = listOf(pubkey), limit = 20, @@ -130,7 +130,7 @@ fun filterAccountInfoAndListsFromKey( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PROFILE_METADATA, + purpose = SubPurpose.ACCOUNT_DATA, kinds = AccountInfoAndListsFromKeyKinds2, authors = listOf(pubkey), limit = 80, @@ -148,7 +148,7 @@ fun filterAccountInfoAndListsFromKey( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PROFILE_METADATA, + purpose = SubPurpose.ACCOUNT_DATA, kinds = AmethystMetadataKinds, authors = listOf(pubkey), tags = AmethystMetadataTagMapFilter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index 7948d3438a..b9985891fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -87,7 +87,7 @@ fun filterBasicAccountInfoFromKeys( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PROFILE_METADATA, + purpose = SubPurpose.ACCOUNT_DATA, kinds = BasicAccountInfoKinds, authors = otherAccounts.toList(), limit = otherAccounts.size * BasicAccountInfoKinds.size, @@ -98,7 +98,7 @@ fun filterBasicAccountInfoFromKeys( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PROFILE_METADATA, + purpose = SubPurpose.ACCOUNT_DATA, kinds = BasicAccountInfoKinds2, authors = otherAccounts.toList(), limit = otherAccounts.size * BasicAccountInfoKinds2.size, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt index 8d2b5681c0..edde979296 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -54,7 +54,7 @@ fun filterBookmarksAndReportsFromKey( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PROFILE_METADATA, + purpose = SubPurpose.ACCOUNT_DATA, kinds = ReportsAndBookmarksFromKeyKinds, authors = listOf(pubkey), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt index d046e3cedf..ce53c3f448 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt @@ -58,7 +58,7 @@ fun filterFollowsAndMutesFromKey( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.FOLLOW_LISTS, + purpose = SubPurpose.ACCOUNT_DATA, kinds = FollowAndMutesFromKeyKinds, authors = listOf(pubkey), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt index 10e98a959e..e0a534ae4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt @@ -38,7 +38,7 @@ fun filterLastPostsFromKey( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PROFILE_METADATA, + purpose = SubPurpose.ACCOUNT_DATA, authors = listOf(pubkey), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt index 3fc03aed4a..62594bacc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt @@ -90,7 +90,7 @@ class NwcNotificationsEoseManager( relay = wallet.uri.relayUri, filter = ExplainedFilter( - purpose = SubPurpose.NOTIFICATIONS, + purpose = SubPurpose.WALLET, kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND), authors = listOf(wallet.uri.pubKeyHex), tags = mapOf("p" to listOf(signer.pubKey)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt index daf39c13a2..c02515c72a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt @@ -56,7 +56,7 @@ fun filterMissingChannelsById(keys: List): List>): if (it.value.isNotEmpty()) { RelayBasedFilter( relay = it.key, - filter = ExplainedFilter(purpose = SubPurpose.SCREEN_CONTENT, ids = it.value.sorted()), + filter = ExplainedFilter(purpose = SubPurpose.REFERENCED_EVENTS, ids = it.value.sorted()), ) } else { null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index bd312710ab..7a172477d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -93,7 +93,7 @@ fun filterRepliesAndReactionsToAddresses( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ENGAGEMENT, kinds = RepliesAndReactionsToAddressesKinds1, tags = mapOf("a" to sortedList), since = since, @@ -105,7 +105,7 @@ fun filterRepliesAndReactionsToAddresses( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ENGAGEMENT, kinds = PostsAndChatMessagesToAddresses, tags = mapOf("a" to sortedList), since = since, @@ -117,7 +117,7 @@ fun filterRepliesAndReactionsToAddresses( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ENGAGEMENT, kinds = DeletionKindList, tags = mapOf("a" to sortedList), since = since, @@ -129,7 +129,7 @@ fun filterRepliesAndReactionsToAddresses( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ENGAGEMENT, kinds = TextNoteKindList, tags = mapOf("q" to sortedList), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index bb5815838d..494ee84209 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -100,7 +100,7 @@ fun filterRepliesAndReactionsToNotes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ENGAGEMENT, kinds = RepliesAndReactionsKinds, tags = mapOf("e" to sortedList), since = since, @@ -112,7 +112,7 @@ fun filterRepliesAndReactionsToNotes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ENGAGEMENT, kinds = RepliesAndReactionsKinds2, tags = mapOf("e" to sortedList), since = since, @@ -123,7 +123,7 @@ fun filterRepliesAndReactionsToNotes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ENGAGEMENT, kinds = listOf(TextNoteEvent.KIND, CommentEvent.KIND), tags = mapOf("q" to sortedList), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt index 36a3ad2efc..0e5d73a549 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt @@ -40,7 +40,7 @@ fun filterReportsToKeysFromTrusted( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PROFILE_METADATA, + purpose = SubPurpose.MODERATION, kinds = ReportKindList, authors = trustedAccounts, tags = mapOf("p" to targets.sorted()), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt index 9d46b41164..70973ab169 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt @@ -55,7 +55,7 @@ fun filterByAuthor( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.SEARCH, kinds = MetadataKindList, authors = myUser, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt index ac59e2cb63..53e4dae18f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPeopleByName.kt @@ -35,7 +35,7 @@ fun searchPeopleByName( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.SEARCH, kinds = listOf(MetadataEvent.KIND), search = searchString, limit = 1000, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index 12d6cb4888..69014e6708 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -114,7 +114,7 @@ fun searchPostsByText( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.SEARCH, kinds = SearchPostsByTextKinds1, search = searchString, limit = 100, @@ -124,7 +124,7 @@ fun searchPostsByText( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.SEARCH, kinds = SearchPostsByTextKinds2, search = searchString, limit = 100, @@ -134,7 +134,7 @@ fun searchPostsByText( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.SEARCH, kinds = SearchPostsByTextKinds3, search = searchString, limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt index dcdfb26baf..62b8d3fec7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/FilterAppRecommendations.kt @@ -44,7 +44,7 @@ fun filterMyAppRecommendations( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ADD_ONS, kinds = listOf(AppRecommendationEvent.KIND), authors = authors, limit = 100, @@ -64,7 +64,7 @@ fun filterRecentAppDefinitions(relays: Set): List = listOf( RelayBasedFilter( relay = groupId.relayUrl, - filter = ExplainedFilter(purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), + filter = ExplainedFilter(purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), ), // Pins stay in their own filter for the same reason the directory filters split them out. RelayBasedFilter( relay = groupId.relayUrl, - filter = ExplainedFilter(purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_PIN_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), + filter = ExplainedFilter(purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_PIN_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), ), ) @@ -287,8 +287,8 @@ fun buildRelayGroupStateFilters( val scope = mapOf(D_TAG to ids.distinct()) val since = sinceForRelay(relay) listOf( - RelayBasedFilter(relay = relay, filter = ExplainedFilter(purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = since)), - RelayBasedFilter(relay = relay, filter = ExplainedFilter(purpose = SubPurpose.CHATS, kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = since)), + RelayBasedFilter(relay = relay, filter = ExplainedFilter(purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = since)), + RelayBasedFilter(relay = relay, filter = ExplainedFilter(purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = since)), ) } @@ -326,7 +326,7 @@ fun buildRelayGroupJoinedChatTailFilter( relay = groupId.relayUrl, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -362,7 +362,7 @@ fun buildRelayGroupPreviewFilter( relay = groupId.relayUrl, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -383,7 +383,7 @@ fun buildRelayGroupAuxFilter( relay = groupId.relayUrl, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_AUX_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -400,7 +400,7 @@ fun buildRelayGroupOpenChatTailFilter( relay = groupId.relayUrl, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_OPEN_TAIL_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -424,7 +424,7 @@ fun buildRelayGroupHistoryFilters( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), until = until, @@ -445,7 +445,7 @@ fun buildRelayGroupDirectoryFilter( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_DIRECTORY_KINDS, limit = RELAY_GROUP_DIRECTORY_LIMIT, since = sinceEpoch, @@ -469,7 +469,7 @@ fun buildRelayGroupThreadsHistoryFilters( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_THREAD_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), until = until, @@ -487,7 +487,7 @@ fun buildRelayGroupThreadsFilter( relay = groupId.relayUrl, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_THREAD_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt index 3c226fbc4f..70fd3d0684 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt @@ -70,7 +70,7 @@ fun filterRelayGroupsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, authors = authorList, kinds = listOf(GroupMetadataEvent.KIND), limit = 200, @@ -82,7 +82,7 @@ fun filterRelayGroupsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf(GroupAdminsEvent.KIND, GroupMembersEvent.KIND), tags = mapOf(PTag.TAG_NAME to authorList), limit = 200, @@ -102,7 +102,7 @@ fun filterRelayGroupsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf(DTag.TAG_NAME to rosterGroupIds), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt index e1cea18d21..a1d83e48fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt @@ -41,7 +41,7 @@ fun filterRelayGroupsByGeohashes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf(GeoHashTag.TAG_NAME to geotags.map { it.lowercase() }.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt index 7c860fd57d..5c5b113e9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt @@ -39,7 +39,7 @@ fun filterRelayGroupsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf(HashtagTag.TAG_NAME to hashtags.map { it.lowercase() }), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt index d0286410bb..d6deb84bf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt @@ -53,7 +53,7 @@ fun filterRelayGroupsDirectory( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_DISCOVERY_KINDS, limit = 500, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt index 64d35f7ba6..a5897a49f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt @@ -54,7 +54,7 @@ fun filterFollowingEphemeralChats( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf(EphemeralChatEvent.KIND), tags = mapOf("d" to it.value.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt index 3d75e69a24..1ac81ad02c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt @@ -55,7 +55,7 @@ fun filterFollowingGeohashChats( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf(GeohashChatEvent.KIND), tags = mapOf("g" to cells), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt index c5d827e9be..000fab8036 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt @@ -58,7 +58,7 @@ fun filterFollowingPublicChatsCreationEvent( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf(ChannelCreateEvent.KIND), ids = it.value.sorted(), since = since?.get(it.key)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt index 18623f9d1f..600e75815e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt @@ -59,7 +59,7 @@ fun filterLastMessageFollowingPublicChats( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf(ChannelMetadataEvent.KIND), tags = mapOf("e" to it.value.sorted()), since = since?.get(it.key)?.time, @@ -70,7 +70,7 @@ fun filterLastMessageFollowingPublicChats( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf(ChannelMessageEvent.KIND), tags = mapOf("e" to it.value.sorted()), since = since?.get(it.key)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt index f3dc3fac05..29edf0025d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt @@ -38,7 +38,7 @@ fun filterNip04DMsFromMe( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.DIRECT_MESSAGES, kinds = listOf(PrivateDmEvent.KIND), authors = listOf(user.pubkeyHex), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt index 9ef0d41e03..dbe23014ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt @@ -38,7 +38,7 @@ fun filterNip04DMsToMe( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.DIRECT_MESSAGES, kinds = listOf(PrivateDmEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt index bd01433817..5649dbd192 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt @@ -66,7 +66,7 @@ class CommunityRulesFilterSubAssembler( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, kinds = listOf(CommunityRulesEvent.KIND), authors = signers.sorted(), tags = mapOf("a" to listOf(commEvent.addressTag())), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt index 7a1fe9b11e..143da7c09a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt @@ -56,7 +56,7 @@ fun filterCommunityPosts( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, authors = community.moderatorKeys(), tags = mapOf("a" to listOf(community.addressTag())), kinds = CommunityPostKinds, @@ -75,7 +75,7 @@ fun filterCommunityPostsFromModerators( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, authors = authors.sorted(), tags = mapOf("a" to listOf(community.addressTag())), kinds = CommunityPostKinds, @@ -93,7 +93,7 @@ fun filterCommunityPostsFromEverybody( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, tags = mapOf("a" to listOf(community.addressTag())), kinds = CommunityPostKinds, limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt index 7b4dfdb34b..1d773ea040 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt @@ -42,7 +42,7 @@ fun filterLongFormAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -58,7 +58,7 @@ fun filterLongFormAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf(LongTextNoteEvent.KIND), limit = 30, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt index f823a69bb6..e87828fa52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt @@ -41,7 +41,7 @@ fun filterLongFormAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = authorList, kinds = listOf(LongTextNoteEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt index 330672bb08..ec867450d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt @@ -42,7 +42,7 @@ fun filterLongFormByCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -59,7 +59,7 @@ fun filterLongFormByCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = listOf(LongTextNoteEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt index b0f6f57c1c..e753b72ce0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt @@ -43,7 +43,7 @@ fun filterLongFormByGeohash( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LongTextNoteEvent.KIND), tags = mapOf("g" to geoHashes), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt index 2ebbdb16a4..5a11af780c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt @@ -43,7 +43,7 @@ fun filterLongFormByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LongTextNoteEvent.KIND), tags = mapOf("t" to hashtags), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt index 8732d949b2..c68babe190 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt @@ -41,7 +41,7 @@ fun filterLongFormGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LongTextNoteEvent.KIND), limit = 100, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt index 47983b6b80..38244e6737 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt @@ -44,7 +44,7 @@ fun filterPublicChatsAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -60,7 +60,7 @@ fun filterPublicChatsAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt index 9b6642d92d..b4da28138c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt @@ -43,7 +43,7 @@ fun filterPublicChatsAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, authors = authorList, kinds = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt index 22148c2286..1973662fab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt @@ -44,7 +44,7 @@ fun filterPublicChatsByCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -61,7 +61,7 @@ fun filterPublicChatsByCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt index a4261d080b..bc60f7d6f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt @@ -44,7 +44,7 @@ fun filterPublicChatsByGeohash( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf( ChannelCreateEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt index 35447b6ef7..f3c2ed65a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt @@ -45,7 +45,7 @@ fun filterPublicChatsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf( ChannelCreateEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt index 5d2ea4f71c..3342839f42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt @@ -44,7 +44,7 @@ fun filterPublicChatsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf( ChannelCreateEvent.KIND, @@ -58,7 +58,7 @@ fun filterPublicChatsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.PUBLIC_CHATS, kinds = listOf( ChannelMessageEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt index d82f308332..74fd1d9bf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt @@ -45,7 +45,7 @@ fun filterLiveActivitiesAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -61,7 +61,7 @@ fun filterLiveActivitiesAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt index cf607a6a22..a51b12f82e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt @@ -44,7 +44,7 @@ fun filterLiveActivitiesAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = authorList, kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 300, @@ -56,7 +56,7 @@ fun filterLiveActivitiesAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, tags = mapOf("p" to authorList), kinds = listOf(LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt index 2f5532ab29..449cdbdb96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt @@ -45,7 +45,7 @@ fun filterLiveActivitiesByCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -62,7 +62,7 @@ fun filterLiveActivitiesByCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt index b2c1ed117e..5eea6e7c53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt @@ -45,7 +45,7 @@ fun filterLiveActivitiesByGeohash( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt index 82342ce2bd..28b5de9f64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt @@ -46,7 +46,7 @@ fun filterLiveActivitiesByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt index 06b2d03af4..f55370b6ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt @@ -46,7 +46,7 @@ fun filterLiveActivitiesGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 30, since = since ?: TimeUtils.oneWeekAgo(), @@ -56,7 +56,7 @@ fun filterLiveActivitiesGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(LiveActivitiesChatMessageEvent.KIND), limit = 50, since = since ?: TimeUtils.oneDayAgo(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt index 424960a7c9..89bc8c6e52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt @@ -42,7 +42,7 @@ fun filterCommunitiesAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf("a" to communityList), limit = 300, @@ -54,7 +54,7 @@ fun filterCommunitiesAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, tags = mapOf("a" to communityList), kinds = listOf(ClassifiedsEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt index 529f1d6cd4..27b1047080 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt @@ -42,7 +42,7 @@ fun filterCommunitiesAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = authorList, kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt index 898c32272d..6f928b6ff7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt @@ -42,7 +42,7 @@ fun filterClassifiedsByCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = listOf(community.pubKeyHex), kinds = listOf(CommunityDefinitionEvent.KIND), tags = mapOf("d" to listOf(community.dTag)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt index 42d37f298b..304469da3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt @@ -43,7 +43,7 @@ fun filterCommunitiesByGeohash( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), tags = mapOf("g" to geoHashes), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt index c1f879bd82..620abd126f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt @@ -44,7 +44,7 @@ fun filterCommunitiesByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND), tags = mapOf("t" to hashtags), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt index 51f837f044..ad580b93c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt @@ -43,7 +43,7 @@ fun filterCommunitiesGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(CommunityDefinitionEvent.KIND), limit = 200, since = since, @@ -53,7 +53,7 @@ fun filterCommunitiesGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, limit = 100, since = since ?: TimeUtils.oneMonthAgo(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt index 746791944c..708eff563e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt @@ -42,7 +42,7 @@ fun filterContentDVMsAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -58,7 +58,7 @@ fun filterContentDVMsAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf(AppDefinitionEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt index cc65567771..9820641f47 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt @@ -41,7 +41,7 @@ fun filterContentDVMsAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = authorList, kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300")), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt index 409f9ece20..c83c695711 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt @@ -42,7 +42,7 @@ fun filterContentDVMsByCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -59,7 +59,7 @@ fun filterContentDVMsByCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = listOf(AppDefinitionEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt index 69cf77ab24..1b53060611 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt @@ -42,7 +42,7 @@ fun filterContentDVMsByGeohash( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300"), "g" to geoHashes), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt index 4e2029ca7f..d6437759ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt @@ -43,7 +43,7 @@ fun filterContentDVMsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300"), "t" to hashtags), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt index f172435535..618c3c1ecf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt @@ -41,7 +41,7 @@ fun filterContentDVMsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(AppDefinitionEvent.KIND), tags = mapOf("k" to listOf("5300")), limit = 30, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt index 3539ea66bd..31e717ed13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt @@ -42,7 +42,7 @@ fun filterClassifiedsAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -58,7 +58,7 @@ fun filterClassifiedsAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, tags = mapOf("a" to communityList), kinds = listOf(ClassifiedsEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt index e763c1f898..307f970e53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt @@ -41,7 +41,7 @@ fun filterClassifiedsAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = authorList, kinds = listOf(ClassifiedsEvent.KIND), limit = authorList.size * 10, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt index 530c01efab..f7027966ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt @@ -42,7 +42,7 @@ fun filterClassifiedsByCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -59,7 +59,7 @@ fun filterClassifiedsByCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, authors = authors, tags = mapOf("a" to listOf(community)), kinds = listOf(ClassifiedsEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt index 1791c2c032..366449b616 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt @@ -42,7 +42,7 @@ fun filterClassifiedsByGeohash( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(ClassifiedsEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt index 93695bec05..709af9ca9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt @@ -43,7 +43,7 @@ fun filterClassifiedsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(ClassifiedsEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt index ecd0ace2c8..f2ed19d8cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt @@ -42,7 +42,7 @@ fun filterClassifiedsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.DISCOVER_FEED, kinds = listOf(ClassifiedsEvent.KIND), limit = 30, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt index fd431a2a25..605cc42df7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt @@ -43,7 +43,7 @@ fun filterBrowseEmojiSetsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ADD_ONS, authors = authors.sorted(), kinds = listOf(EmojiPackEvent.KIND), limit = BROWSE_EMOJI_SETS_FEED_LIMIT, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt index 6465dd0277..9eabf847df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt @@ -40,7 +40,7 @@ fun filterBrowseEmojiSetsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, kinds = listOf(EmojiPackEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = BROWSE_EMOJI_SETS_FEED_LIMIT, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt index a4e2a5dc9d..9cd2bb4184 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt @@ -43,7 +43,7 @@ fun filterBrowseEmojiSetsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ADD_ONS, kinds = listOf(EmojiPackEvent.KIND), limit = BROWSE_EMOJI_SETS_FEED_LIMIT, since = sinceValue, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index 63049951d1..0fabbeae26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -79,7 +79,7 @@ fun filterPostsByGeohash( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, tags = geohashesToFollowMap, kinds = PostsByGeohashKinds, limit = 100, @@ -90,7 +90,7 @@ fun filterPostsByGeohash( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, tags = geohashesScoreMap, kinds = CommentKinds, limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt index 3078cd0a2d..924f9dbf96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt @@ -55,7 +55,7 @@ fun filterRepositoryContent( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TOPIC_FEED, tags = mapOf("a" to listOf(repository.addressTag())), kinds = RepositoryContentKinds, limit = 500, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAllCommunities.kt index db232b27a1..3cb32b21af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAllCommunities.kt @@ -53,7 +53,7 @@ fun filterGitRepositoriesFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -69,7 +69,7 @@ fun filterGitRepositoriesFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, tags = mapOf("a" to communityList), kinds = GitRepositoriesFromCommunityKinds, limit = communityList.size * 20, @@ -109,7 +109,7 @@ fun filterGitRepositoriesFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -126,7 +126,7 @@ fun filterGitRepositoriesFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, authors = authors, tags = mapOf("a" to listOf(community)), kinds = GitRepositoriesFromCommunityKinds, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAuthors.kt index 8101155951..4316cac70e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAuthors.kt @@ -41,7 +41,7 @@ fun filterGitRepositoriesByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TOPIC_FEED, authors = authorList, kinds = listOf(GitRepositoryEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByGeohashes.kt index 262fcfc87d..0762319d4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByGeohashes.kt @@ -40,7 +40,7 @@ fun filterGitRepositoriesByGeohashes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, kinds = listOf(GitRepositoryEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByHashtag.kt index af76ce5426..4dd1939660 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByHashtag.kt @@ -38,7 +38,7 @@ fun filterGitRepositoriesByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, kinds = listOf(GitRepositoryEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesGlobal.kt index 7d3c31815a..d3f40af9d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesGlobal.kt @@ -41,7 +41,7 @@ fun filterGitRepositoriesGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TOPIC_FEED, kinds = listOf(GitRepositoryEvent.KIND), limit = 200, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt index 07116fb9dd..670eff235a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt @@ -64,7 +64,7 @@ fun filterHashtagLabels( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, kinds = listOf(LabelEvent.KIND), authors = authorList, tags = mapOf("l" to labelValues), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index 0e3927e13b..fcf9ab032f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -88,7 +88,7 @@ fun filterPostsByHashtags( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, tags = hashtagsToFollowMap, kinds = PostsByHashtagsKinds, limit = 400, @@ -99,7 +99,7 @@ fun filterPostsByHashtags( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, tags = hashtagsToFollowMap, kinds = PostsByHashtagKinds2, limit = 100, @@ -110,7 +110,7 @@ fun filterPostsByHashtags( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, tags = hashtagScoreMap, kinds = CommentKinds, limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByAuthors.kt index 25b3b2b447..500ccfabef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByAuthors.kt @@ -41,7 +41,7 @@ fun filterHighlightsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TOPIC_FEED, authors = authorList, kinds = listOf(HighlightEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByGeohashes.kt index f41b282833..625683fb71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByGeohashes.kt @@ -40,7 +40,7 @@ fun filterHighlightsByGeohashes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, kinds = listOf(HighlightEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByHashtag.kt index 4106d9a7f3..4a075c18c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByHashtag.kt @@ -38,7 +38,7 @@ fun filterHighlightsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, kinds = listOf(HighlightEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsGlobal.kt index 0534105d1e..b4ba6fb4be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsGlobal.kt @@ -41,7 +41,7 @@ fun filterHighlightsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TOPIC_FEED, kinds = listOf(HighlightEvent.KIND), limit = 200, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index 7baa061791..be5512ae2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -114,7 +114,7 @@ fun filterNewHomePostsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.FOLLOW_LISTS, + purpose = SubPurpose.RELAY_LISTS, kinds = HomePostsNewThreadKinds1, authors = authorList, limit = min(authorList.size * 10, 500), @@ -125,7 +125,7 @@ fun filterNewHomePostsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.FOLLOW_LISTS, + purpose = SubPurpose.RELAY_LISTS, kinds = HomePostsNewThreadKinds2, authors = authorList, limit = min(authorList.size * 10, 5), @@ -147,7 +147,7 @@ fun filterReplyHomePostsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.FOLLOW_LISTS, + purpose = SubPurpose.RELAY_LISTS, kinds = HomePostsConversationKinds, authors = authorList, limit = min(authorList.size * 10, 500), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAllCommunities.kt index 16871b2ca2..7e5c287cbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAllCommunities.kt @@ -56,7 +56,7 @@ fun filterLongsFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -72,7 +72,7 @@ fun filterLongsFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, tags = mapOf("a" to communityList), kinds = LongsFromCommunityKinds, limit = communityList.size * 20, @@ -112,7 +112,7 @@ fun filterLongsFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -129,7 +129,7 @@ fun filterLongsFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authors, tags = mapOf("a" to listOf(community)), kinds = LongsFromCommunityKinds, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAuthors.kt index 73f5ed1eaa..7abcb602d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAuthors.kt @@ -42,7 +42,7 @@ fun filterLongsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authorList, kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByGeohashes.kt index 0a2abe96a9..e9ad6f93eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByGeohashes.kt @@ -41,7 +41,7 @@ fun filterLongsByGeohashes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByHashtag.kt index 4f7b25a4a9..3af9195851 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByHashtag.kt @@ -39,7 +39,7 @@ fun filterLongsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsGlobal.kt index 817a3d0c83..34577f364a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsGlobal.kt @@ -42,7 +42,7 @@ fun filterLongsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), limit = 200, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt index 8410b07d11..6820936028 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt @@ -51,7 +51,7 @@ fun filterMusicEventsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authorList, kinds = kinds, limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt index 055120a439..0e315e84e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt @@ -49,7 +49,7 @@ fun filterMusicEventsFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -64,7 +64,7 @@ fun filterMusicEventsFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = kinds, tags = mapOf("a" to communityList), limit = communityList.size * 20, @@ -107,7 +107,7 @@ fun filterMusicEventsFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authorList, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -123,7 +123,7 @@ fun filterMusicEventsFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authorList, kinds = kinds, tags = mapOf("a" to listOf(community)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt index 60381612ba..2a1d14a505 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt @@ -40,7 +40,7 @@ fun filterMusicEventsByGeohashes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = kinds, tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt index a214d7ea45..15748ceafe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt @@ -38,7 +38,7 @@ fun filterMusicEventsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = kinds, tags = mapOf("t" to hashtags.sorted()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt index 638b3a378c..05164fffa3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt @@ -45,7 +45,7 @@ fun filterMusicEventsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = kinds, limit = 200, since = sinceForRelay, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByAuthors.kt index ef69c79137..6c415a452a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByAuthors.kt @@ -43,7 +43,7 @@ fun filterNappletsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ADD_ONS, authors = authorList, kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), limit = NAPPLET_PAGE_LIMIT, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsGlobal.kt index 5cb5cbbdcd..929a24de6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsGlobal.kt @@ -44,7 +44,7 @@ fun filterNappletsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ADD_ONS, kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), limit = NAPPLET_PAGE_LIMIT, since = since?.get(it.key)?.time ?: defaultSince, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt index 5452966918..f3dd500d6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt @@ -93,7 +93,7 @@ class NestRoomFilterSubAssembler( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, kinds = listOf( LiveActivitiesChatMessageEvent.KIND, @@ -110,7 +110,7 @@ class NestRoomFilterSubAssembler( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, kinds = listOf(AdminCommandEvent.KIND), tags = mapOf("a" to listOf(key.note.idHex), "p" to listOf(key.account.pubKey)), since = since?.get(relay)?.time, @@ -129,7 +129,7 @@ class NestRoomFilterSubAssembler( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, kinds = listOf(MeetingSpaceEvent.KIND), authors = listOf(key.note.address.pubKeyHex), tags = mapOf("d" to listOf(key.note.address.dTag)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt index 64f0fe82f0..3ae8d18843 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt @@ -84,7 +84,7 @@ class NestRoomLivenessSubAssembler( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, kinds = listOf(MeetingRoomPresenceEvent.KIND), tags = mapOf("a" to listOf(key.note.idHex)), // limit=1: the badge only needs the freshest diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAllCommunities.kt index 724525b124..89d166c8bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAllCommunities.kt @@ -44,7 +44,7 @@ fun filterNestsAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -60,7 +60,7 @@ fun filterNestsAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, tags = mapOf("k" to listOf("5300"), "a" to communityList), kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAuthors.kt index 7193db4568..7a29bef733 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAuthors.kt @@ -42,7 +42,7 @@ fun filterNestsAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, authors = authorList, kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 300, @@ -54,7 +54,7 @@ fun filterNestsAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, tags = mapOf("p" to authorList), kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByCommunity.kt index 30f784649a..3cb5722213 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByCommunity.kt @@ -43,7 +43,7 @@ fun filterNestsByCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -60,7 +60,7 @@ fun filterNestsByCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByGeohash.kt index 53097b8192..a044bca29d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByGeohash.kt @@ -43,7 +43,7 @@ fun filterNestsByGeohash( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByHashtag.kt index 86fda37ee5..b9a558630e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByHashtag.kt @@ -44,7 +44,7 @@ fun filterNestsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt index b46b99ba7d..966966fe7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt @@ -56,7 +56,7 @@ fun filterNestsPresence(relay: NormalizedRelayUrl): RelayBasedFilter = relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, kinds = listOf(MeetingRoomPresenceEvent.KIND), limit = 500, since = TimeUtils.now() - PRESENCE_LOOKBACK_SECONDS, @@ -78,7 +78,7 @@ fun filterNestsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.LIVE_ROOMS, kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 300, since = roomsSince ?: TimeUtils.oneWeekAgo(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByAuthors.kt index 4c0b896a73..9c4154105d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByAuthors.kt @@ -43,7 +43,7 @@ fun filterNsitesByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ADD_ONS, authors = authorList, kinds = listOf(RootSiteEvent.KIND, NamedSiteEvent.KIND), limit = NSITE_PAGE_LIMIT, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesGlobal.kt index add70b0565..f39af90993 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesGlobal.kt @@ -44,7 +44,7 @@ fun filterNsitesGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ADD_ONS, kinds = listOf(RootSiteEvent.KIND, NamedSiteEvent.KIND), limit = NSITE_PAGE_LIMIT, since = since?.get(it.key)?.time ?: defaultSince, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAllCommunities.kt index 2e4418c11c..f1992e40fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAllCommunities.kt @@ -53,7 +53,7 @@ fun filterPicturesFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -69,7 +69,7 @@ fun filterPicturesFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, tags = mapOf("a" to communityList), kinds = PicturesFromCommunityKinds, limit = communityList.size * 20, @@ -109,7 +109,7 @@ fun filterPicturesFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -126,7 +126,7 @@ fun filterPicturesFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authors, tags = mapOf("a" to listOf(community)), kinds = PicturesFromCommunityKinds, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt index 0ff545f665..f9e769998e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt @@ -41,7 +41,7 @@ fun filterPicturesByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authorList, kinds = listOf(PictureEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByGeohashes.kt index 1cee1532c5..23a3762bf4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByGeohashes.kt @@ -40,7 +40,7 @@ fun filterPicturesByGeohashes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = listOf(PictureEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt index 1c028ba724..4913579efd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt @@ -38,7 +38,7 @@ fun filterPicturesByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = listOf(PictureEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt index a677839366..9d898918eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt @@ -41,7 +41,7 @@ fun filterPicturesGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = listOf(PictureEvent.KIND), limit = 200, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt index 6757d8e46e..482a1dfbc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt @@ -60,7 +60,7 @@ fun filterMyPodcast( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authors, kinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND), since = sinceTime, @@ -71,7 +71,7 @@ fun filterMyPodcast( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authors, kinds = listOf(AppSpecificDataEvent.KIND), tags = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt index c291fd3a1a..074e00b411 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt @@ -60,7 +60,7 @@ fun filterOnePodcast( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = OnePodcastKinds, authors = listOf(user.pubkeyHex), limit = 500, @@ -73,7 +73,7 @@ fun filterOnePodcast( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = listOf(AppSpecificDataEvent.KIND), authors = listOf(user.pubkeyHex), tags = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt index f1a7d8b593..75a6410dbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt @@ -84,7 +84,7 @@ fun filterPodcastEventsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authorList, kinds = kinds, tags = additionalTags, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt index 7bb561c4cd..4aad2b6400 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt @@ -44,7 +44,7 @@ fun filterPodcastEventsFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -59,7 +59,7 @@ fun filterPodcastEventsFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = kinds, tags = mergeFilterTags(mapOf("a" to communityList), additionalTags), limit = communityList.size * 20, @@ -104,7 +104,7 @@ fun filterPodcastEventsFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authorList, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -120,7 +120,7 @@ fun filterPodcastEventsFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authorList, kinds = kinds, tags = mergeFilterTags(mapOf("a" to listOf(community)), additionalTags), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt index 3bc67fbfab..f0e511d112 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt @@ -41,7 +41,7 @@ fun filterPodcastEventsByGeohashes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = kinds, tags = mergeFilterTags(mapOf("g" to geotags.sorted()), additionalTags), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt index 3bb2b20cec..d6b189ed23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt @@ -39,7 +39,7 @@ fun filterPodcastEventsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = kinds, tags = mergeFilterTags(mapOf("t" to hashtags.sorted()), additionalTags), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt index be9c9a7508..093f5c3b45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt @@ -43,7 +43,7 @@ fun filterPodcastEventsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = kinds, tags = additionalTags, limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAllCommunities.kt index 1dfa5faa35..3c7f65da1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAllCommunities.kt @@ -56,7 +56,7 @@ fun filterPollsFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -72,7 +72,7 @@ fun filterPollsFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, tags = mapOf("a" to communityList), kinds = PollsFromCommunityKinds, limit = communityList.size * 20, @@ -112,7 +112,7 @@ fun filterPollsFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -129,7 +129,7 @@ fun filterPollsFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, authors = authors, tags = mapOf("a" to listOf(community)), kinds = PollsFromCommunityKinds, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt index da1d4cdca1..72ac8ebd89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt @@ -42,7 +42,7 @@ fun filterPollsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TOPIC_FEED, authors = authorList, kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByGeohashes.kt index 2085722e70..fecc93a0c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByGeohashes.kt @@ -41,7 +41,7 @@ fun filterPollsByGeohashes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt index baa8fedac3..dffbab4c0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt @@ -39,7 +39,7 @@ fun filterPollsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt index c497e78810..0216068391 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt @@ -42,7 +42,7 @@ fun filterPollsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TOPIC_FEED, kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), limit = 200, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt index 578c2f4c57..35c9ae52e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt @@ -44,7 +44,7 @@ fun filterUserProfileFollowers( relay = it, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.USER_PROFILE, kinds = UserProfileFollowersKinds, tags = mapOf("p" to listOf(user.pubkeyHex)), since = since?.get(it)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLastSeen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLastSeen.kt index 9b21f728fe..a4a362359f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLastSeen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLastSeen.kt @@ -37,7 +37,7 @@ fun filterUserProfileLastSeen( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.USER_PROFILE, authors = it.value.sorted(), since = since?.get(it.key)?.time, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt index 781d47ca5d..3f59327dc6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt @@ -55,7 +55,7 @@ fun filterUserProfileLists( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.USER_PROFILE, kinds = UserProfileListKinds, authors = it.value.sorted(), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt index 1f330f9108..9fdd6c2abe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt @@ -59,7 +59,7 @@ fun filterUserProfileMedia( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.USER_PROFILE, kinds = UserProfileMediaKinds, authors = listOf(user.pubkeyHex), limit = 200, @@ -72,7 +72,7 @@ fun filterUserProfileMedia( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.USER_PROFILE, kinds = listOf(LiveActivitiesClipEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt index 8c3abadfec..966a12ce15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMetadata.kt @@ -51,7 +51,7 @@ fun filterUserProfileMetadata( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.PROFILE_METADATA, + purpose = SubPurpose.USER_PROFILE, kinds = UserProfileMetadataKinds, authors = it.value.sorted(), since = since?.get(it.key)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt index 7c8b263b87..cffc751c84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -105,7 +105,7 @@ fun filterUserProfilePosts( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.USER_PROFILE, kinds = UserProfilePostKinds1, authors = listOf(user.pubkeyHex), limit = 500, @@ -116,7 +116,7 @@ fun filterUserProfilePosts( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.USER_PROFILE, kinds = UserProfilePostKinds2, authors = listOf(user.pubkeyHex), limit = 50, @@ -127,7 +127,7 @@ fun filterUserProfilePosts( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.USER_PROFILE, kinds = UserProfileAppKinds, authors = listOf(user.pubkeyHex), limit = 50, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt index ca537f2821..f913c42159 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt @@ -46,7 +46,7 @@ fun filterUserProfileZapsReceived( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.USER_PROFILE, kinds = UserProfileZapReceiverKinds, tags = mapOf("p" to listOf(user.pubkeyHex)), limit = 1000, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt index f4b6755601..3c9a3ce21f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt @@ -80,7 +80,7 @@ fun filterPostsByRelay( relay = relayUrl, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.RELAY_INFO, kinds = PostsByRelayKinds, limit = 400, since = sinceTime, @@ -90,7 +90,7 @@ fun filterPostsByRelay( relay = relayUrl, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.RELAY_INFO, kinds = PostsByRelayKinds2, limit = 100, since = sinceTime, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterSubAssembler.kt index b0c1de7b6e..ad1cc723eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/datasource/RelayInfoNip66FilterSubAssembler.kt @@ -43,7 +43,7 @@ class RelayInfoNip66FilterSubAssembler( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.RELAY_INFO, kinds = listOf(RelayDiscoveryEvent.KIND), tags = mapOf("d" to listOf(relayUrl)), limit = 20, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterPollsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterPollsByAllCommunities.kt index 4b98621fac..15a3c51460 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterPollsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterPollsByAllCommunities.kt @@ -56,7 +56,7 @@ fun filterShortsFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -72,7 +72,7 @@ fun filterShortsFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, tags = mapOf("a" to communityList), kinds = ShortsFromCommunityKinds, limit = communityList.size * 20, @@ -112,7 +112,7 @@ fun filterShortsFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -129,7 +129,7 @@ fun filterShortsFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authors, tags = mapOf("a" to listOf(community)), kinds = ShortsFromCommunityKinds, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByAuthors.kt index cc528e6f36..fcade6e8c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByAuthors.kt @@ -42,7 +42,7 @@ fun filterShortsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authorList, kinds = listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByGeohashes.kt index 1cf52cd61d..117ca69717 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByGeohashes.kt @@ -41,7 +41,7 @@ fun filterShortsByGeohashes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByHashtag.kt index 640bc36417..a0df247758 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByHashtag.kt @@ -39,7 +39,7 @@ fun filterShortsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsGlobal.kt index a5c93f3ad7..d9975d402b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsGlobal.kt @@ -42,7 +42,7 @@ fun filterShortsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), limit = 200, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByAuthors.kt index a664958d18..d24de2b9bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByAuthors.kt @@ -42,7 +42,7 @@ fun filterSoftwareAppsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ADD_ONS, authors = authorList, kinds = listOf(SoftwareApplicationEvent.KIND, SoftwareReleaseEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByHashtag.kt index 48af996e21..3c32ed466a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByHashtag.kt @@ -44,7 +44,7 @@ fun filterSoftwareAppsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, kinds = listOf(SoftwareApplicationEvent.KIND, SoftwareReleaseEvent.KIND), tags = mapOf("t" to expanded), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsGlobal.kt index 778965d85d..62298cbf96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsGlobal.kt @@ -56,7 +56,7 @@ fun filterSoftwareAppsGlobal( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ADD_ONS, kinds = listOf(SoftwareApplicationEvent.KIND, SoftwareReleaseEvent.KIND), // Keep the limit < 100. Relays such as zapstore's score filter // specificity and treat a limit >= 100 (or none) as too vague, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt index ea9d38c759..8ae53c3d4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterEventsInThreadForRoot.kt @@ -44,7 +44,7 @@ fun filterEventsInThreadForRoot( relay = it, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.THREAD, tags = mapOf("a" to listOf(addressRoot)), since = since, ), @@ -53,7 +53,7 @@ fun filterEventsInThreadForRoot( relay = it, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.THREAD, tags = mapOf("A" to listOf(addressRoot)), since = since, ), @@ -70,7 +70,7 @@ fun filterEventsInThreadForRoot( relay = it, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.THREAD, tags = mapOf("e" to listOf(eventRoot)), since = since, ), @@ -79,7 +79,7 @@ fun filterEventsInThreadForRoot( relay = it, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.THREAD, tags = mapOf("E" to listOf(eventRoot)), since = since, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrl.kt index e19d24d17f..e18789b4aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/url/datasource/FilterPostsByUrl.kt @@ -42,7 +42,7 @@ fun filterPostsByUrl( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.OTHER, tags = urlScopeMap, kinds = CommentKinds, limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt index a807761425..dc785068bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt @@ -42,7 +42,7 @@ fun filterPictureAndVideoGeohash( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = PictureAndVideoKinds, tags = mapOf("g" to geoHashes), limit = 200, @@ -53,7 +53,7 @@ fun filterPictureAndVideoGeohash( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = PictureAndVideoLegacyKinds, tags = mapOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt index b2afe876cc..b0d29d160d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt @@ -43,7 +43,7 @@ fun filterPictureAndVideoHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = PictureAndVideoKinds, tags = mapOf("t" to hashtags), limit = 100, @@ -54,7 +54,7 @@ fun filterPictureAndVideoHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = PictureAndVideoLegacyKinds, tags = mapOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt index 7350844617..8ffb150967 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt @@ -43,7 +43,7 @@ fun filterPictureAndVideoGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = PictureAndVideoKinds, limit = 50, since = since, @@ -53,7 +53,7 @@ fun filterPictureAndVideoGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = PictureAndVideoLegacyKinds, tags = LegacyMimeTypeMap, limit = 50, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt index d2b1ce1a12..bb5d27a75d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt @@ -44,7 +44,7 @@ fun filterPictureAndVideoAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.FOLLOW_LISTS, + purpose = SubPurpose.RELAY_LISTS, authors = authorList, kinds = PictureAndVideoKinds, limit = if (since == null) max(authorList.size * 20, 200) else null, @@ -55,7 +55,7 @@ fun filterPictureAndVideoAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.FOLLOW_LISTS, + purpose = SubPurpose.RELAY_LISTS, authors = authorList, kinds = PictureAndVideoLegacyKinds, tags = LegacyMimeTypeMap, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt index 863d208797..1d9a517d3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt @@ -47,7 +47,7 @@ fun filterPictureAndVideoAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -63,7 +63,7 @@ fun filterPictureAndVideoAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, tags = mapOf("a" to communityList), kinds = PictureAndVideoKinds, limit = 200, @@ -75,7 +75,7 @@ fun filterPictureAndVideoAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -91,7 +91,7 @@ fun filterPictureAndVideoAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, tags = mapOf( "a" to communityList, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt index 45faca0799..2bdec3af51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt @@ -46,7 +46,7 @@ fun filterPictureAndVideoCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -63,7 +63,7 @@ fun filterPictureAndVideoCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authors, tags = mapOf("a" to listOf(community)), kinds = PictureAndVideoKinds, @@ -76,7 +76,7 @@ fun filterPictureAndVideoCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -93,7 +93,7 @@ fun filterPictureAndVideoCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.MEDIA_FEED, authors = authors, tags = mapOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssembler.kt index 76b31da7c3..f13764f410 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssembler.kt @@ -75,7 +75,7 @@ private fun filterOnchainZaps( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.WALLET, kinds = listOf(OnchainZapEvent.KIND), tags = mapOf("p" to listOf(pubkey)), limit = PER_FILTER_LIMIT, @@ -86,7 +86,7 @@ private fun filterOnchainZaps( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.WALLET, kinds = listOf(OnchainZapEvent.KIND), authors = listOf(pubkey), limit = PER_FILTER_LIMIT, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt index 155ceb0c1e..31e074a6aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt @@ -53,7 +53,7 @@ fun filterWorkoutsFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = mapOf( @@ -69,7 +69,7 @@ fun filterWorkoutsFromAllCommunities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, tags = mapOf("a" to communityList), kinds = WorkoutsFromCommunityKinds, limit = communityList.size * 20, @@ -109,7 +109,7 @@ fun filterWorkoutsFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, authors = authors, kinds = CommunityPostApprovalEvent.KIND_LIST, tags = @@ -126,7 +126,7 @@ fun filterWorkoutsFromCommunity( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.COMMUNITY_FEED, authors = authors, tags = mapOf("a" to listOf(community)), kinds = WorkoutsFromCommunityKinds, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt index 353e2626c7..13382e375f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt @@ -41,7 +41,7 @@ fun filterWorkoutsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TOPIC_FEED, authors = authorList, kinds = listOf(WorkoutRecordEvent.KIND), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt index 6e8390ad0a..6d0f83cd41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt @@ -40,7 +40,7 @@ fun filterWorkoutsByGeohashes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, kinds = listOf(WorkoutRecordEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt index 481662285a..117e8137f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt @@ -38,7 +38,7 @@ fun filterWorkoutsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TAG_FEED, kinds = listOf(WorkoutRecordEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt index ee8d5a8bec..d798092b08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt @@ -41,7 +41,7 @@ fun filterWorkoutsGlobal( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.TOPIC_FEED, kinds = listOf(WorkoutRecordEvent.KIND), limit = 200, since = since, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 32ea1e3ad6..6e8dff7700 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -178,7 +178,7 @@ object ConcordSubscriptionPlanner { val filter = if (lastRead > 0L) { ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.COMMUNITY_CHATS, purposeDetail = "concord community planes", kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = authors.toList(), @@ -189,7 +189,7 @@ object ConcordSubscriptionPlanner { ) } else { ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.COMMUNITY_CHATS, purposeDetail = "concord community planes", kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = authors.toList(), @@ -270,7 +270,7 @@ object ConcordSubscriptionPlanner { relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.CHATS, + purpose = SubPurpose.COMMUNITY_CHATS, // Stored plane wraps (1059) plus ephemeral ones (21059) — the latter carry the // live-only typing heartbeats a relay broadcasts but never stores. kinds = listOf(ConcordStreamEnvelope.KIND_WRAP, ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/subscription/ChessFilterBuilder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/subscription/ChessFilterBuilder.kt index 364a41e9c4..7d0e0880a8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/subscription/ChessFilterBuilder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/subscription/ChessFilterBuilder.kt @@ -105,7 +105,7 @@ object ChessFilterBuilder { ): List { val filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.GAMES, kinds = listOf(JESTER_KIND), tags = mapOf("p" to listOf(userPubkey)), limit = 100, @@ -134,7 +134,7 @@ object ChessFilterBuilder { ): List { val filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.GAMES, kinds = listOf(JESTER_KIND), tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)), limit = 100, @@ -174,7 +174,7 @@ object ChessFilterBuilder { // This catches all moves/events for these games val gameFilter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.GAMES, purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), tags = mapOf("e" to startEventIds.toList()), @@ -188,7 +188,7 @@ object ChessFilterBuilder { // Also filter by p-tag (opponent tagged us) for redundancy val tagFilter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.GAMES, purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), tags = mapOf("p" to listOf(userPubkey)), @@ -203,7 +203,7 @@ object ChessFilterBuilder { if (opponentPubkeys.isNotEmpty()) { val authorFilter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.GAMES, purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), authors = opponentPubkeys.toList(), @@ -229,7 +229,7 @@ object ChessFilterBuilder { ): List { val filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.GAMES, kinds = listOf(JESTER_KIND), limit = 100, ) @@ -257,7 +257,7 @@ object ChessFilterBuilder { */ fun gameEventsFilter(startEventId: String): Filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.GAMES, purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), tags = mapOf("e" to listOf(startEventId)), @@ -273,7 +273,7 @@ object ChessFilterBuilder { fun challengesFilter(userPubkey: String? = null): Filter { val now = TimeUtils.now() return ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.GAMES, purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)), @@ -289,7 +289,7 @@ object ChessFilterBuilder { fun recentGamesFilter(): Filter { val now = TimeUtils.now() return ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.GAMES, purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, @@ -304,7 +304,7 @@ object ChessFilterBuilder { fun userGamesFilter(userPubkey: String): Filter { val now = TimeUtils.now() return ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.GAMES, purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), authors = listOf(userPubkey), @@ -320,7 +320,7 @@ object ChessFilterBuilder { fun userTaggedFilter(userPubkey: String): Filter { val now = TimeUtils.now() return ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.GAMES, purposeDetail = "live chess games on screen", kinds = listOf(JESTER_KIND), tags = mapOf("p" to listOf(userPubkey)), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt index 742265289c..4bdfb7623e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt @@ -46,7 +46,7 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.PROFILE_METADATA, kinds = ContactCardKindList, authors = trustedAccounts, // kind:30382 addresses the target user in the d-tag @@ -71,7 +71,7 @@ fun filterContactCardsByAuthorInTheRelay( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.PROFILE_METADATA, kinds = ContactCardKindList, authors = listOf(author), limit = limit, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt index 0b2f8717f9..d897f238e8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt @@ -74,7 +74,7 @@ class ReactionsFilterAssembler( // Create filter for reactions (Kind 7) targeting these notes via e-tags val filter = ExplainedFilter( - purpose = SubPurpose.SCREEN_CONTENT, + purpose = SubPurpose.ENGAGEMENT, purposeDetail = "reactions on notes currently on screen", kinds = listOf(ReactionEvent.KIND), tags = mapOf("e" to noteIdList), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt index c5c52bbd06..c9014a3965 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt @@ -23,46 +23,142 @@ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions /** * Why a subscription exists — the client-side answer to "what is this relay doing for me?". * - * Attached to an [ExplainedFilter] and **never sent to a relay** (see [ExplainedFilter]). It exists - * so the app can account for its own connections: the always-on notification reports a bare - * "connected to N relays", which is emergent rather than curated — nothing today can tell you - * whether those N are carrying your DMs or just re-dialling a stale outbox hint. + * Attached to an [ExplainedFilter] and **never sent to a relay**. It exists so the app can account + * for its own connections: the always-on notification reports a bare "connected to N relays", which + * is emergent rather than curated — nothing today can tell you whether those N are carrying your DMs + * or re-dialling a stale outbox hint. * - * An enum rather than a free-form string so the UI can group and localize by a stable key instead of - * matching on text. Use [other] with a label when a subscription genuinely doesn't fit a category — - * that keeps one-off debug filters describable without diluting the buckets a user sees. + * ## Two levels on purpose + * + * The fine value names the actual job; [group] rolls it up. A relay screen or a bug report wants the + * fine value ("hashtag feed" vs "thread you are reading"); a notification a user glances at wants the + * group. Adding a fine value is cheap because the UI can keep rendering groups. + * + * ## Lifetime is the other axis + * + * [isBackground] marks the purposes *allowed* to outlive the foreground — the ones the always-on + * notification is actually about. Read it as a ceiling, not a promise: a background-capable purpose + * with nothing to fetch is simply absent, so absence proves nothing. The direction that does hold is + * the other one — **a purpose with `isBackground = false` appearing while backgrounded is a leak**, + * and that is now assertable. + * + * Measured on device (cold start, then HOME): foreground carried 13 purposes; backgrounded carried 9, + * every one of them `isBackground = true`. `HOME_FEED` (337 relays) and `ENGAGEMENT` (20) — the only + * two `false` entries in flight — were both gone, while the transient account loaders + * ([RELAY_LISTS], [FOLLOW_LISTS]) were idle rather than torn down. */ -enum class SubPurpose { - /** Mentions, reactions, reposts, zaps addressed to me (`#p` = me). Inbox relays. */ - NOTIFICATIONS, +enum class SubPurpose( + val group: SubPurposeGroup, + /** True when this subscription is permitted to stay active while the app is backgrounded. */ + val isBackground: Boolean = false, +) { + // ---- the account itself: always on ------------------------------------- + + /** My own profile, settings and app-specific data. */ + ACCOUNT_DATA(SubPurposeGroup.ACCOUNT, isBackground = true), + + /** Profiles (kind 0) of everyone we render. */ + PROFILE_METADATA(SubPurposeGroup.ACCOUNT, isBackground = true), + + /** NIP-65 relay lists, DM relay lists, blocked/trusted relay sets — outbox discovery. */ + RELAY_LISTS(SubPurposeGroup.ACCOUNT, isBackground = true), + + /** Kind-3 follows and the web-of-trust sets derived from them. */ + FOLLOW_LISTS(SubPurposeGroup.ACCOUNT, isBackground = true), + + /** Reports (kind 1984), mute lists, spam and ban state. */ + MODERATION(SubPurposeGroup.ACCOUNT, isBackground = true), + + /** Cashu wallet, nutzaps, mints and NWC. */ + WALLET(SubPurposeGroup.ACCOUNT, isBackground = true), + + // ---- things addressed to me: always on --------------------------------- + + /** Mentions, reactions, reposts and zaps addressed to me (`#p` = me). Inbox relays. */ + NOTIFICATIONS(SubPurposeGroup.MESSAGES, isBackground = true), /** NIP-17 gift wraps and NIP-04 legacy DMs. DM relays. */ - DIRECT_MESSAGES, + DIRECT_MESSAGES(SubPurposeGroup.MESSAGES, isBackground = true), + + /** NIP-28/29 public channels and relay groups. */ + PUBLIC_CHATS(SubPurposeGroup.MESSAGES, isBackground = true), + + /** Concord and Buzz community planes. */ + COMMUNITY_CHATS(SubPurposeGroup.MESSAGES, isBackground = true), + + /** Marmot / MLS encrypted group messaging. */ + ENCRYPTED_GROUPS(SubPurposeGroup.MESSAGES, isBackground = true), + + /** Live audio rooms (NIP-53 nests) and their presence. */ + LIVE_ROOMS(SubPurposeGroup.MESSAGES), + + // ---- feeds: on while their tab is alive -------------------------------- /** Posts from the people I follow. Outbox relays. */ - HOME_FEED, + HOME_FEED(SubPurposeGroup.FEEDS), - /** Profiles (kind 0) — mine and everyone I render. */ - PROFILE_METADATA, + /** The Discover tab: live streams, DVM feeds, marketplaces, community suggestions. */ + DISCOVER_FEED(SubPurposeGroup.FEEDS), - /** Relay lists (NIP-65), DM relay lists, blocked/trusted relay sets. */ - RELAY_LISTS, + /** Video, shorts, pictures, music, podcasts and long-form tabs. */ + MEDIA_FEED(SubPurposeGroup.FEEDS), - /** Follow lists and follow-list-derived sets (kind 3, web of trust). */ - FOLLOW_LISTS, + /** A hashtag or geohash feed. */ + TAG_FEED(SubPurposeGroup.FEEDS), - /** Group, channel and community chat (NIP-28/29, Concord, Buzz). */ - CHATS, + /** NIP-72 community feeds. */ + COMMUNITY_FEED(SubPurposeGroup.FEEDS), - /** Reports and moderation state (kind 1984, mute lists). */ - MODERATION, + /** Calendars, polls, workouts, git repos, highlights and other typed feeds. */ + TOPIC_FEED(SubPurposeGroup.FEEDS), - /** Cashu wallet, nutzaps and mint directories. */ - WALLET, + // ---- whatever is on screen right now ----------------------------------- - /** A thread, profile page, hashtag or anything else opened on demand. Short-lived. */ - SCREEN_CONTENT, + /** The conversation currently open. */ + THREAD(SubPurposeGroup.CURRENT_SCREEN), + + /** A profile page currently open. */ + USER_PROFILE(SubPurposeGroup.CURRENT_SCREEN), + + /** Search results. */ + SEARCH(SubPurposeGroup.CURRENT_SCREEN), + + /** Replies, reactions and zaps on the notes currently rendered. */ + ENGAGEMENT(SubPurposeGroup.CURRENT_SCREEN), + + /** + * Events something on screen points at but we do not hold — a quoted note, the parent of a + * reply, an addressable a card renders. Distinct from [ENGAGEMENT], which is inbound reactions + * *to* what we already have; this is outbound resolution of references. + */ + REFERENCED_EVENTS(SubPurposeGroup.CURRENT_SCREEN), + + /** Badges, emoji packs, napplets, nSites, software apps and other add-ons. */ + ADD_ONS(SubPurposeGroup.CURRENT_SCREEN), + + /** Games — chess and friends. */ + GAMES(SubPurposeGroup.CURRENT_SCREEN), + + /** Relay information screens probing a relay directly. */ + RELAY_INFO(SubPurposeGroup.CURRENT_SCREEN), /** Anything not worth its own bucket; carry detail in [ExplainedFilter.purposeDetail]. */ + OTHER(SubPurposeGroup.OTHER), +} + +/** Coarse roll-up of [SubPurpose], for surfaces that should not list two dozen categories. */ +enum class SubPurposeGroup { + /** Keeping the account itself current: profile, follows, relays, moderation, wallet. */ + ACCOUNT, + + /** Anything addressed to me or to a room I am in. */ + MESSAGES, + + /** Timelines the user browses. */ + FEEDS, + + /** Bound to a screen that is open right now. */ + CURRENT_SCREEN, + OTHER, } From 3192e14c5a4aac23a33066f88530edc7faf242e0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 10:39:06 -0400 Subject: [PATCH 005/227] feat(notifications): explain the relay count, but only when asked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames SubPurpose.isBackground to runsInBackground, and puts the per-job breakdown behind the notification's expand affordance. The collapsed line is byte-for-byte what it was — "Connected to 188 relays" — because that is all most people want from an ongoing notification, and this one sits in the shade permanently. The breakdown goes in a BigTextStyle, so it costs nothing until someone deliberately expands it to ask why their phone is talking to that many relays. It is skipped entirely when nothing is attributed yet, so an empty section can never render. Verified by reading the notification back out of the system rather than trusting the code path (`dumpsys notification --noredact`): android.text Connected to 188 relays <- unchanged android.bigText Connected to 188 relays Relay lists · 173 relays Notifications · 169 relays Moderation · 159 relays Your follows · 154 relays Public chats · 21 relays Profiles · 12 relays Wallet · 12 relays Communities · 9 relays Direct messages · 6 relays Encrypted groups · 6 relays Your account · 3 relays Browsing · 173 relays Only the twelve jobs worth naming to a user get a line. Feeds and whatever screen is open have no label and collapse into "Browsing" — they tear themselves down once the app is backgrounded, which is exactly when this notification matters, so itemising them would add noise precisely when nobody is interested. The counts overlap on purpose and sum well past the relay count: a typical relay carries four jobs at once, so there is no partition to show. The copy answers "how many relays carry my DMs", not "how is the pool split" — which is why the earlier sketched wording ("4 for notifications, 3 for DMs") was dropped; it implied a partition that does not exist. Strings are one reused plural plus thirteen labels, so the count agrees with its noun in languages that decline it. Co-Authored-By: Claude Opus 5 (1M context) --- .../notifications/NotificationRelayService.kt | 17 ++- .../notifications/RelayPurposeSummary.kt | 101 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 19 ++++ .../relayClient/subscriptions/SubPurpose.kt | 30 +++--- 4 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RelayPurposeSummary.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index c2fa2eab2d..28cd04414c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -348,11 +348,26 @@ class NotificationRelayService : Service() { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) + // Expanded only. The collapsed line stays the bare count it has always been — that is all + // most people want from an ongoing notification — and the per-job breakdown appears solely + // when someone deliberately expands it to ask why the phone is talking to N relays. + // Skipped entirely when nothing is attributed yet, so we never render an empty section. + val breakdown = RelayPurposeSummary.lines(this).takeIf { it.isNotEmpty() } + return NotificationCompat .Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.always_on_notif_title)) .setContentText(contentText) - .setSmallIcon(R.drawable.amethyst_service) + .apply { + breakdown?.let { + setStyle( + NotificationCompat + .BigTextStyle() + .setBigContentTitle(getString(R.string.always_on_notif_title)) + .bigText(contentText + "\n\n" + it.joinToString("\n")), + ) + } + }.setSmallIcon(R.drawable.amethyst_service) .setContentIntent(pendingIntent) .setOngoing(true) .setSilent(true) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RelayPurposeSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RelayPurposeSummary.kt new file mode 100644 index 0000000000..0569e3fc07 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RelayPurposeSummary.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import android.content.Context +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposes +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * The per-job breakdown behind the always-on notification's relay count. + * + * **Only ever shown expanded.** The collapsed line stays exactly what it was — a count — because + * that is all most people ever want from an ongoing notification. This is for the moment someone + * taps to ask *why* their phone is talking to 40 relays. + * + * A relay usually serves several jobs at once (measured: a typical relay carries four), so these + * counts deliberately **overlap and sum to more than the relay count**. They answer "how many relays + * carry my DMs", not "how is the pool partitioned" — there is no partition. + * + * Purposes with no label — feeds and whatever screen is open — collapse into one "browsing" line. + * Those disappear on their own once the app is backgrounded, which is exactly when this notification + * matters most, so spelling them out would add noise precisely when the user is least interested. + */ +object RelayPurposeSummary { + /** The jobs worth naming to a user. Everything else is browsing, and transient. */ + private fun labelOf(purpose: SubPurpose): Int? = + when (purpose) { + SubPurpose.NOTIFICATIONS -> R.string.relay_purpose_notifications + SubPurpose.DIRECT_MESSAGES -> R.string.relay_purpose_direct_messages + SubPurpose.PUBLIC_CHATS -> R.string.relay_purpose_public_chats + SubPurpose.COMMUNITY_CHATS -> R.string.relay_purpose_community_chats + SubPurpose.ENCRYPTED_GROUPS -> R.string.relay_purpose_encrypted_groups + SubPurpose.LIVE_ROOMS -> R.string.relay_purpose_live_rooms + SubPurpose.ACCOUNT_DATA -> R.string.relay_purpose_account_data + SubPurpose.PROFILE_METADATA -> R.string.relay_purpose_profiles + SubPurpose.RELAY_LISTS -> R.string.relay_purpose_relay_lists + SubPurpose.FOLLOW_LISTS -> R.string.relay_purpose_follows + SubPurpose.MODERATION -> R.string.relay_purpose_moderation + SubPurpose.WALLET -> R.string.relay_purpose_wallet + else -> null + } + + /** + * Lines for the expanded notification, busiest first. Empty when nothing is attributed yet — + * the caller must then fall back to the bare count rather than render an empty section. + */ + fun lines(ctx: Context): List { + val client = Amethyst.instance.client + val named = mutableMapOf>() + val browsing = mutableSetOf() + + client.connectedRelaysFlow().value.forEach { relay -> + client + .activeRequests(relay) + .values + .flatten() + .purposes() + .forEach { purpose -> + if (labelOf(purpose) != null) { + named.getOrPut(purpose) { mutableSetOf() }.add(relay) + } else { + browsing.add(relay) + } + } + } + + val lines = + named.entries + .sortedWith(compareByDescending>> { it.value.size }.thenBy { it.key.name }) + .map { (purpose, relays) -> + pluralStringRes(ctx, R.plurals.relay_purpose_line, relays.size, ctx.getString(labelOf(purpose)!!), relays.size) + }.toMutableList() + + if (browsing.isNotEmpty()) { + lines.add(pluralStringRes(ctx, R.plurals.relay_purpose_line, browsing.size, ctx.getString(R.string.relay_purpose_browsing), browsing.size)) + } + return lines + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d5cf87e6a4..a65b3ba0c6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2069,6 +2069,25 @@ Connected to %1$d relay Connected to %1$d relays + + + %1$s \u00b7 %2$d relay + %1$s \u00b7 %2$d relays + + Notifications + Direct messages + Public chats + Communities + Encrypted groups + Live rooms + Your account + Profiles + Relay lists + Your follows + Moderation + Wallet + Browsing Connecting to inbox relays\u2026 Always-on notification service Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt index c9014a3965..cc865fdbbd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt @@ -36,58 +36,58 @@ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions * * ## Lifetime is the other axis * - * [isBackground] marks the purposes *allowed* to outlive the foreground — the ones the always-on + * [runsInBackground] marks the purposes *allowed* to outlive the foreground — the ones the always-on * notification is actually about. Read it as a ceiling, not a promise: a background-capable purpose * with nothing to fetch is simply absent, so absence proves nothing. The direction that does hold is - * the other one — **a purpose with `isBackground = false` appearing while backgrounded is a leak**, + * the other one — **a purpose with `runsInBackground = false` appearing while backgrounded is a leak**, * and that is now assertable. * * Measured on device (cold start, then HOME): foreground carried 13 purposes; backgrounded carried 9, - * every one of them `isBackground = true`. `HOME_FEED` (337 relays) and `ENGAGEMENT` (20) — the only + * every one of them `runsInBackground = true`. `HOME_FEED` (337 relays) and `ENGAGEMENT` (20) — the only * two `false` entries in flight — were both gone, while the transient account loaders * ([RELAY_LISTS], [FOLLOW_LISTS]) were idle rather than torn down. */ enum class SubPurpose( val group: SubPurposeGroup, /** True when this subscription is permitted to stay active while the app is backgrounded. */ - val isBackground: Boolean = false, + val runsInBackground: Boolean = false, ) { // ---- the account itself: always on ------------------------------------- /** My own profile, settings and app-specific data. */ - ACCOUNT_DATA(SubPurposeGroup.ACCOUNT, isBackground = true), + ACCOUNT_DATA(SubPurposeGroup.ACCOUNT, runsInBackground = true), /** Profiles (kind 0) of everyone we render. */ - PROFILE_METADATA(SubPurposeGroup.ACCOUNT, isBackground = true), + PROFILE_METADATA(SubPurposeGroup.ACCOUNT, runsInBackground = true), /** NIP-65 relay lists, DM relay lists, blocked/trusted relay sets — outbox discovery. */ - RELAY_LISTS(SubPurposeGroup.ACCOUNT, isBackground = true), + RELAY_LISTS(SubPurposeGroup.ACCOUNT, runsInBackground = true), /** Kind-3 follows and the web-of-trust sets derived from them. */ - FOLLOW_LISTS(SubPurposeGroup.ACCOUNT, isBackground = true), + FOLLOW_LISTS(SubPurposeGroup.ACCOUNT, runsInBackground = true), /** Reports (kind 1984), mute lists, spam and ban state. */ - MODERATION(SubPurposeGroup.ACCOUNT, isBackground = true), + MODERATION(SubPurposeGroup.ACCOUNT, runsInBackground = true), /** Cashu wallet, nutzaps, mints and NWC. */ - WALLET(SubPurposeGroup.ACCOUNT, isBackground = true), + WALLET(SubPurposeGroup.ACCOUNT, runsInBackground = true), // ---- things addressed to me: always on --------------------------------- /** Mentions, reactions, reposts and zaps addressed to me (`#p` = me). Inbox relays. */ - NOTIFICATIONS(SubPurposeGroup.MESSAGES, isBackground = true), + NOTIFICATIONS(SubPurposeGroup.MESSAGES, runsInBackground = true), /** NIP-17 gift wraps and NIP-04 legacy DMs. DM relays. */ - DIRECT_MESSAGES(SubPurposeGroup.MESSAGES, isBackground = true), + DIRECT_MESSAGES(SubPurposeGroup.MESSAGES, runsInBackground = true), /** NIP-28/29 public channels and relay groups. */ - PUBLIC_CHATS(SubPurposeGroup.MESSAGES, isBackground = true), + PUBLIC_CHATS(SubPurposeGroup.MESSAGES, runsInBackground = true), /** Concord and Buzz community planes. */ - COMMUNITY_CHATS(SubPurposeGroup.MESSAGES, isBackground = true), + COMMUNITY_CHATS(SubPurposeGroup.MESSAGES, runsInBackground = true), /** Marmot / MLS encrypted group messaging. */ - ENCRYPTED_GROUPS(SubPurposeGroup.MESSAGES, isBackground = true), + ENCRYPTED_GROUPS(SubPurposeGroup.MESSAGES, runsInBackground = true), /** Live audio rooms (NIP-53 nests) and their presence. */ LIVE_ROOMS(SubPurposeGroup.MESSAGES), From 397246b2cad4a24cb5525111a7aaa569b2c434b4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 10:57:05 -0400 Subject: [PATCH 006/227] feat(relays): show what each relay is doing, in the app's own words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the purpose chips to the Connected Relays rows, and retires the invented vocabulary the notification was using. "Relay lists" and "Moderation" read as fuzzy because they were words I made up that match nothing the user can navigate to. Every label now points at a string the app already shows somewhere else — nav routes (Home, Discover, Messages, Notifications, Chess), event-kind names (Reports, Profile, Follow List, Outbox Relays, Drafts, Reactions) and feature names (Communities, Nests, Marmot Group, Wallet). Twelve invented strings deleted; nine remain, only for jobs the app had never had to name (Media, Hashtags, Topics, Conversation, Search, Quoted posts, Add-ons, Relay info, Other). Those twelve also needed translating and now do not. Checking those two labels found three real mis-buckets from the sweep's rule ordering, all now fixed: FilterHomePostsByAuthors RELAY_LISTS -> HOME_FEED (nip65Follows in the FilterPictureAndVideoByAuthors RELAY_LISTS -> MEDIA_FEED path matched first) FilterDraftsAndReportsFromKey MODERATION -> ACCOUNT_DATA (kinds = DraftKinds; "report" in the filename) So "Relay lists · 173 relays" was mostly the home and media feed fan-out, and "Moderation" was counting drafts. RELAY_LISTS now covers only the outbox finder and MODERATION only reports-about-you, which is what the words claim. SubPurposeLabels is the single place a purpose becomes words, shared by both surfaces so they cannot drift. Which jobs earn a notification line is now derived from the taxonomy — the ACCOUNT and MESSAGES groups — rather than a hand-kept list that happened to contain the same twelve. The two surfaces stay deliberately different: the notification names only the dozen jobs that survive backgrounding, while the relay screen someone opened on purpose shows every job, feeds and current-screen work included. Verified on device, notification re-read from the system: Notifications · 168 · Reports · 164 · Follow List · 134 · Relay Chats · 19 Profile · 14 · Wallet · 11 · Communities · 8 · Messages · 6 Marmot Group · 5 · Drafts · 3 · Browsing · 165 The chips themselves are compile-verified and read from the same already-device-verified data, but the rendered row has NOT been seen — reaching that screen needs drawer navigation that was not worth scripting. Worth a look before merge. Co-Authored-By: Claude Opus 5 (1M context) --- .../notifications/RelayPurposeSummary.kt | 23 +---- .../drafts/FilterDraftsAndReportsFromKey.kt | 2 +- .../nip65Follows/FilterHomePostsByAuthors.kt | 6 +- .../common/BasicRelaySetupInfoClickableRow.kt | 2 + .../loggedIn/relays/common/RelayPurposeRow.kt | 84 +++++++++++++++++++ .../relays/common/SubPurposeLabels.kt | 83 ++++++++++++++++++ .../FilterPictureAndVideoByAuthors.kt | 4 +- amethyst/src/main/res/values/strings.xml | 21 ++--- 8 files changed, 187 insertions(+), 38 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayPurposeRow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RelayPurposeSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RelayPurposeSummary.kt index 0569e3fc07..5b71abddbf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RelayPurposeSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RelayPurposeSummary.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposes import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** @@ -44,24 +45,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl * matters most, so spelling them out would add noise precisely when the user is least interested. */ object RelayPurposeSummary { - /** The jobs worth naming to a user. Everything else is browsing, and transient. */ - private fun labelOf(purpose: SubPurpose): Int? = - when (purpose) { - SubPurpose.NOTIFICATIONS -> R.string.relay_purpose_notifications - SubPurpose.DIRECT_MESSAGES -> R.string.relay_purpose_direct_messages - SubPurpose.PUBLIC_CHATS -> R.string.relay_purpose_public_chats - SubPurpose.COMMUNITY_CHATS -> R.string.relay_purpose_community_chats - SubPurpose.ENCRYPTED_GROUPS -> R.string.relay_purpose_encrypted_groups - SubPurpose.LIVE_ROOMS -> R.string.relay_purpose_live_rooms - SubPurpose.ACCOUNT_DATA -> R.string.relay_purpose_account_data - SubPurpose.PROFILE_METADATA -> R.string.relay_purpose_profiles - SubPurpose.RELAY_LISTS -> R.string.relay_purpose_relay_lists - SubPurpose.FOLLOW_LISTS -> R.string.relay_purpose_follows - SubPurpose.MODERATION -> R.string.relay_purpose_moderation - SubPurpose.WALLET -> R.string.relay_purpose_wallet - else -> null - } - /** * Lines for the expanded notification, busiest first. Empty when nothing is attributed yet — * the caller must then fall back to the bare count rather than render an empty section. @@ -78,7 +61,7 @@ object RelayPurposeSummary { .flatten() .purposes() .forEach { purpose -> - if (labelOf(purpose) != null) { + if (SubPurposeLabels.isWorthNamingInNotification(purpose)) { named.getOrPut(purpose) { mutableSetOf() }.add(relay) } else { browsing.add(relay) @@ -90,7 +73,7 @@ object RelayPurposeSummary { named.entries .sortedWith(compareByDescending>> { it.value.size }.thenBy { it.key.name }) .map { (purpose, relays) -> - pluralStringRes(ctx, R.plurals.relay_purpose_line, relays.size, ctx.getString(labelOf(purpose)!!), relays.size) + pluralStringRes(ctx, R.plurals.relay_purpose_line, relays.size, ctx.getString(SubPurposeLabels.labelOf(purpose)), relays.size) }.toMutableList() if (browsing.isNotEmpty()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt index 80b768321d..8ee1043610 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt @@ -44,7 +44,7 @@ fun filterDraftsFromKey( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.MODERATION, + purpose = SubPurpose.ACCOUNT_DATA, kinds = DraftKinds, authors = listOf(pubkey), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index be5512ae2d..46ec797c00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -114,7 +114,7 @@ fun filterNewHomePostsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.RELAY_LISTS, + purpose = SubPurpose.HOME_FEED, kinds = HomePostsNewThreadKinds1, authors = authorList, limit = min(authorList.size * 10, 500), @@ -125,7 +125,7 @@ fun filterNewHomePostsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.RELAY_LISTS, + purpose = SubPurpose.HOME_FEED, kinds = HomePostsNewThreadKinds2, authors = authorList, limit = min(authorList.size * 10, 5), @@ -147,7 +147,7 @@ fun filterReplyHomePostsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.RELAY_LISTS, + purpose = SubPurpose.HOME_FEED, kinds = HomePostsConversationKinds, authors = authorList, limit = min(authorList.size * 10, 500), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index b18d85ea04..e522caea3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -143,6 +143,8 @@ fun BasicRelaySetupInfoClickableRow( UsedBy(item, accountViewModel, nav) + RelayPurposeRow(item.purposes) + RelayEventCountRow( countResult = countResult, modifier = ReactionRowHeightChatMaxWidth, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayPurposeRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayPurposeRow.kt new file mode 100644 index 0000000000..f157743ce2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayPurposeRow.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * What this relay is currently doing for us, as small chips under the relay's name. + * + * This is the detailed counterpart to the always-on notification: the notification names only the + * dozen jobs that keep running with the app closed, while here — a screen someone opened on purpose + * to inspect relays — every job is shown, including the feed and current-screen work. + * + * Renders nothing when the set is empty. Empty means "no tagged filter in flight on this relay right + * now", which is the honest reading; it must not be drawn as "idle", because a relay can be + * connected with its subscriptions still being assembled. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RelayPurposeRow( + purposes: Set, + modifier: Modifier = Modifier, +) { + if (purposes.isEmpty()) return + + // Stable order so the chips do not reshuffle between recompositions as filters come and go. + val sorted = remember(purposes) { purposes.sortedWith(compareBy({ it.group.ordinal }, { it.ordinal })) } + + FlowRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + sorted.forEach { purpose -> + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = stringRes(SubPurposeLabels.labelOf(purpose)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(PaddingValues(horizontal = 5.dp, vertical = 1.dp)), + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt new file mode 100644 index 0000000000..693b3c31f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common + +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurposeGroup + +/** + * The one place a [SubPurpose] becomes words, shared by the relay screens and the always-on + * notification so both call the same job the same thing. + * + * **Reuses the app's existing vocabulary wherever it exists** — nav routes (`Home`, `Discover`, + * `Messages`, `Notifications`, `Chess`), event-kind names (`Reports`, `Profile`, `Follow List`, + * `Outbox Relays`, `Drafts`, `Reactions`), and feature names (`Communities`, `Nests`, + * `Marmot Group`, `Wallet`). A parallel vocabulary invented here would read as fuzzy exactly because + * the words match nothing the user can navigate to, and it would need its own translations. + * New strings exist only for the handful of jobs the app never had to name before. + */ +object SubPurposeLabels { + fun labelOf(purpose: SubPurpose): Int = + when (purpose) { + // account — always on + SubPurpose.ACCOUNT_DATA -> R.string.kind_drafts + SubPurpose.PROFILE_METADATA -> R.string.kind_profile + SubPurpose.RELAY_LISTS -> R.string.kind_outbox_relays + SubPurpose.FOLLOW_LISTS -> R.string.kind_follow_list + SubPurpose.MODERATION -> R.string.kind_reports + SubPurpose.WALLET -> R.string.wallet + // messages — always on + SubPurpose.NOTIFICATIONS -> R.string.route_notifications + SubPurpose.DIRECT_MESSAGES -> R.string.route_messages + SubPurpose.PUBLIC_CHATS -> R.string.relay_chat_title + SubPurpose.COMMUNITY_CHATS -> R.string.communities + SubPurpose.ENCRYPTED_GROUPS -> R.string.marmot_group + SubPurpose.LIVE_ROOMS -> R.string.nests + // feeds + SubPurpose.HOME_FEED -> R.string.route_home + SubPurpose.DISCOVER_FEED -> R.string.route_discover + SubPurpose.MEDIA_FEED -> R.string.relay_purpose_media + SubPurpose.TAG_FEED -> R.string.relay_purpose_tags + SubPurpose.COMMUNITY_FEED -> R.string.communities + SubPurpose.TOPIC_FEED -> R.string.relay_purpose_topics + // current screen + SubPurpose.THREAD -> R.string.relay_purpose_thread + SubPurpose.USER_PROFILE -> R.string.kind_profile + SubPurpose.SEARCH -> R.string.relay_purpose_search + SubPurpose.ENGAGEMENT -> R.string.kind_reactions + SubPurpose.REFERENCED_EVENTS -> R.string.relay_purpose_referenced + SubPurpose.ADD_ONS -> R.string.relay_purpose_add_ons + SubPurpose.GAMES -> R.string.route_chess + SubPurpose.RELAY_INFO -> R.string.relay_purpose_relay_info + SubPurpose.OTHER -> R.string.relay_purpose_other + } + + /** + * Whether this job earns its own line in the always-on notification. + * + * Derived from the taxonomy rather than hand-listed: the [SubPurposeGroup.ACCOUNT] and + * [SubPurposeGroup.MESSAGES] groups are exactly the jobs that keep running with the app closed, + * which is what that notification is about. Feeds and current-screen work tear themselves down on + * backgrounding, so itemising them would add noise precisely when nobody is looking. + */ + fun isWorthNamingInNotification(purpose: SubPurpose): Boolean = purpose.group == SubPurposeGroup.ACCOUNT || purpose.group == SubPurposeGroup.MESSAGES +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt index bb5d27a75d..1175551bc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt @@ -44,7 +44,7 @@ fun filterPictureAndVideoAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.RELAY_LISTS, + purpose = SubPurpose.MEDIA_FEED, authors = authorList, kinds = PictureAndVideoKinds, limit = if (since == null) max(authorList.size * 20, 200) else null, @@ -55,7 +55,7 @@ fun filterPictureAndVideoAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.RELAY_LISTS, + purpose = SubPurpose.MEDIA_FEED, authors = authorList, kinds = PictureAndVideoLegacyKinds, tags = LegacyMimeTypeMap, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a65b3ba0c6..06eda5cabe 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2075,19 +2075,16 @@ %1$s \u00b7 %2$d relay %1$s \u00b7 %2$d relays - Notifications - Direct messages - Public chats - Communities - Encrypted groups - Live rooms - Your account - Profiles - Relay lists - Your follows - Moderation - Wallet Browsing + Media + Hashtags + Topics + Conversation + Search + Quoted posts + Add-ons + Relay info + Other Connecting to inbox relays\u2026 Always-on notification service Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message. From e47a2376aae6ddda12417f0d18a6528a28ac2c9b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 11:13:10 -0400 Subject: [PATCH 007/227] feat(relays): carry entity and account on tagged filters Adds entityId and accountPubKey to ExplainedFilter so a chip can answer "which community made me connect here, and for whose account" rather than a bare "Communities". Both ride copy() and are covered by the existing never-reaches-the-wire test. Ids, not names: names change, are often not loaded when the filter is built, and would pin a stale copy into a subscription that outlives them. The UI resolves against LocalCache at render time. A pubkey, not an Account reference: filters are held by the relay pool for the session and outlive the objects that built them, so a reference would keep a logged-out account alive. Several accounts are normally active and do not share relay sets, so without this one account's communities read as another's. purposeEntities() returns the (purpose, entity, account) rows a relay is serving, which is what a per-relay or per-subscription screen needs. Co-Authored-By: Claude Opus 5 (1M context) --- .../subscriptions/ExplainedFilter.kt | 43 ++++++++++++++++++- .../subscriptions/ExplainedFilterTest.kt | 7 +++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt index fd13dc8cdd..436fac0fd2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt @@ -65,6 +65,23 @@ class ExplainedFilter( val purpose: SubPurpose, /** Free-form extra context for [SubPurpose.OTHER] or for narrowing a bucket while debugging. */ val purposeDetail: String? = null, + /** + * The thing this filter serves — a community, group, channel or mint id. Deliberately an **id, + * not a name**: names change, are not always loaded when the filter is built, and would pin a + * stale copy into a long-lived subscription. The UI resolves it against `LocalCache` at render + * time, so it always shows the current name and shows nothing gracefully when unknown. + */ + val entityId: HexKey? = null, + /** + * Which logged-in account asked for this. Several accounts are commonly active at once and they + * do not share relay sets, so "why is this relay connected" is only answerable per account — + * without it, one account's communities look like another's. + * + * A pubkey rather than an account reference: filters outlive the objects that created them and + * are held by the relay pool for the session, so holding an `Account` here would keep a logged-out + * account alive. + */ + val accountPubKey: HexKey? = null, ) : Filter(ids, authors, kinds, tags, tagsAll, since, until, limit, search) { override fun copy( ids: List?, @@ -76,7 +93,7 @@ class ExplainedFilter( until: Long?, limit: Int?, search: String?, - ) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail) + ) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityId, accountPubKey) companion object { /** Tags [filter] with a [purpose], preserving every protocol field. */ @@ -84,6 +101,8 @@ class ExplainedFilter( filter: Filter, purpose: SubPurpose, detail: String? = null, + entityId: HexKey? = null, + accountPubKey: HexKey? = null, ) = ExplainedFilter( filter.ids, filter.authors, @@ -96,6 +115,8 @@ class ExplainedFilter( filter.search, purpose, detail, + entityId, + accountPubKey, ) } } @@ -105,3 +126,23 @@ fun Filter.purposeOrNull(): SubPurpose? = (this as? ExplainedFilter)?.purpose /** The purposes behind a relay's in-flight filters, deduplicated — what this relay is doing for us. */ fun Collection.purposes(): Set = mapNotNullTo(mutableSetOf()) { it.purposeOrNull() } + +/** + * One row per (purpose, entity, account) a relay is serving, deduplicated. + * + * The entity/account pair is what makes a chip answerable — "Communities" alone cannot tell you + * *which* community made you connect here, and with several accounts logged in it cannot tell you + * whose. Entries with no entity collapse to a single row for that purpose. + */ +fun Collection.purposeEntities(): Set = + mapNotNullTo(mutableSetOf()) { filter -> + (filter as? ExplainedFilter)?.let { PurposeEntity(it.purpose, it.entityId, it.accountPubKey, it.purposeDetail) } + } + +/** A single "this relay is doing X, for Y, on behalf of account Z" fact. Ids only; names resolve in the UI. */ +data class PurposeEntity( + val purpose: SubPurpose, + val entityId: HexKey? = null, + val accountPubKey: HexKey? = null, + val detail: String? = null, +) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt index ec96ce47a0..fb348a933e 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt @@ -56,6 +56,8 @@ class ExplainedFilterTest { limit = 20, purpose = SubPurpose.NOTIFICATIONS, purposeDetail = "inbox relays for the active account", + entityId = "cafe0000000000000000000000000000000000000000000000000000000000ff", + accountPubKey = pubkey, ) // ---- the wire must not learn why we asked ------------------------------- @@ -77,6 +79,8 @@ class ExplainedFilterTest { assertFalse("purpose leaked to the wire: $json", json.contains("NOTIFICATIONS", ignoreCase = true)) assertFalse("purpose leaked to the wire: $json", json.contains("purpose", ignoreCase = true)) assertFalse("detail leaked to the wire: $json", json.contains("inbox relays", ignoreCase = true)) + assertFalse("entityId leaked to the wire: $json", json.contains("cafe0000", ignoreCase = true)) + assertFalse("accountPubKey leaked as a field: $json", json.contains("accountPubKey", ignoreCase = true)) } /** The filter is serialized as part of a REQ, so check the real command too, not just the filter. */ @@ -102,6 +106,9 @@ class ExplainedFilterTest { assertTrue("copy() must stay an ExplainedFilter", advanced is ExplainedFilter) assertEquals(SubPurpose.NOTIFICATIONS, advanced.purposeOrNull()) assertEquals("inbox relays for the active account", (advanced as ExplainedFilter).purposeDetail) + // entityId/accountPubKey ride the same path and would vanish just as silently + assertEquals("cafe0000000000000000000000000000000000000000000000000000000000ff", advanced.entityId) + assertEquals(pubkey, advanced.accountPubKey) assertEquals(1_785_379_272L, advanced.since) // and the protocol fields came along untouched assertEquals(listOf(pubkey), advanced.authors) From a9ced24eb9b2b0a786361d1a12e2241b60f0a141 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 11:25:40 -0400 Subject: [PATCH 008/227] feat(relays): add an Active Subscriptions screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-relay purpose chips with a screen whose only job is to answer "why do I have this many subscriptions right now". The chips were the wrong shape. Pivoting on relay hides the thing worth finding: the notifications straggler probe holds ~670 filters across 168 relays, and on a relay-shaped list that is one unremarkable chip repeated on 168 rows. Pivoted on purpose it is a single line that dwarfs everything under it, which is exactly how it was spotted in the first place. Account is the outer grouping — several accounts are normally logged in, they do not share relay sets, and a mixed total cannot be acted on. Purposes sort by filter count, expand to per-entity rows, and carry an explainer describing the actual strategy rather than the intent. Those explainers are written from the code they describe: MODERATION says it asks each relay your follows publish to because UserReportsSubAssembler walks declaredFollowsPerOutboxRelay, and NOTIFICATIONS mentions the follows-wide probe because AccountNotificationsEoseFromRandomRelaysManager subscribes to every follows relay with no sampling. Names resolve at render time from LocalCache and fall back to a short id — a name captured when the filter was built would usually be missing (profiles arrive later) and would go stale on rename. Untagged filters are counted and shown rather than hidden. A total that claims to be fully attributed when it is not would defeat the point of the screen. Reached from the Connected Relays list, which is where the question occurs to people. Notification filters now carry accountPubKey so the largest purpose groups correctly; the remaining assemblers still report under "Not attributed to an account" until they are threaded through. Counts use two separate plurals composed at the call site rather than one string with two %d, so "filter" and "relay" decline independently. NOT visually verified: reaching the screen needs drawer navigation that was not worth scripting. Compile-clean, tests green, and it reads the same activeRequests data already verified on device. Co-Authored-By: Claude Opus 5 (1M context) --- .../FilterNotificationsToPubkey.kt | 11 ++ .../amethyst/ui/navigation/AppNavigation.kt | 3 + .../amethyst/ui/navigation/routes/Routes.kt | 3 + .../relays/common/BasicRelaySetupInfo.kt | 8 - .../common/BasicRelaySetupInfoClickableRow.kt | 2 - .../loggedIn/relays/common/RelayPurposeRow.kt | 84 -------- .../relays/common/SubPurposeLabels.kt | 27 +++ .../connected/ConnectedRelayListView.kt | 31 +++ .../connected/ConnectedRelayListViewModel.kt | 5 - .../ActiveSubscriptionsScreen.kt | 185 ++++++++++++++++++ .../ActiveSubscriptionsViewModel.kt | 177 +++++++++++++++++ amethyst/src/main/res/values/strings.xml | 34 ++++ 12 files changed, 471 insertions(+), 99 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayPurposeRow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index ea6b03fbe1..0b211a0634 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -157,6 +157,7 @@ fun filterNotificationsHistoryToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = AllNotificationKinds, tags = mapOf("p" to listOf(pubkey)), limit = limit, @@ -185,6 +186,7 @@ fun filterGroupNotificationsHistoryToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = GroupNotificationKinds, tags = mapOf("p" to listOf(pubkey), "h" to groupIds), limit = limit, @@ -207,6 +209,7 @@ fun filterSummaryNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = SummaryKinds, tags = mapOf("p" to listOf(pubkey)), limit = 2000, @@ -229,6 +232,7 @@ fun filterNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = NotificationsPerKeyKinds, tags = mapOf("p" to listOf(pubkey)), limit = 500, @@ -240,6 +244,7 @@ fun filterNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), limit = 200, @@ -251,6 +256,7 @@ fun filterNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = NotificationsPerKeyKinds3, tags = mapOf("p" to listOf(pubkey)), limit = 10, @@ -280,6 +286,7 @@ fun filterGroupNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = GroupNotificationKinds, tags = mapOf("p" to listOf(pubkey), "h" to groupIds), limit = 200, @@ -302,6 +309,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = SummaryKinds, tags = mapOf("p" to listOf(pubkey)), limit = 20, @@ -313,6 +321,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = NotificationsPerKeyKinds, tags = mapOf("p" to listOf(pubkey)), limit = 20, @@ -324,6 +333,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), limit = 10, @@ -335,6 +345,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = NotificationsPerKeyKinds3, tags = mapOf("p" to listOf(pubkey)), limit = 2, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index d520612fcb..41ca6d47ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -251,6 +251,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScre import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip43.RelayMembersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions.ActiveSubscriptionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen @@ -590,6 +591,8 @@ fun BuildNavigation( composableFromEndArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } + + composableFromEndArgs { ActiveSubscriptionsScreen() } composableFromEnd { EventSyncScreen(accountViewModel, nav) } composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 4392da65e9..57656e8fb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -459,6 +459,9 @@ sealed class Route { @Serializable object EditRelays : Route() + /** Diagnostic: explains why the app currently holds the subscriptions it holds. */ + @Serializable object ActiveSubscriptions : Route() + @Serializable object EventSync : Route() @Serializable object RequestToVanish : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt index 25d057fb4b..d4820a1b79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -34,13 +33,6 @@ data class BasicRelaySetupInfo( val paidRelay: Boolean = false, val forcesTor: Boolean = false, val users: List = emptyList(), - /** - * What this relay is currently doing for us, derived from the purposes tagged onto its in-flight - * filters. Empty when nothing on this relay has been tagged yet — the assemblers are being - * migrated to [com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter] - * incrementally, so an empty set means "not yet attributed", never "idle". - */ - val purposes: Set = emptySet(), ) fun relaySetupInfoBuilder( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index e522caea3e..b18d85ea04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -143,8 +143,6 @@ fun BasicRelaySetupInfoClickableRow( UsedBy(item, accountViewModel, nav) - RelayPurposeRow(item.purposes) - RelayEventCountRow( countResult = countResult, modifier = ReactionRowHeightChatMaxWidth, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayPurposeRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayPurposeRow.kt deleted file mode 100644 index f157743ce2..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayPurposeRow.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.ui.stringRes - -/** - * What this relay is currently doing for us, as small chips under the relay's name. - * - * This is the detailed counterpart to the always-on notification: the notification names only the - * dozen jobs that keep running with the app closed, while here — a screen someone opened on purpose - * to inspect relays — every job is shown, including the feed and current-screen work. - * - * Renders nothing when the set is empty. Empty means "no tagged filter in flight on this relay right - * now", which is the honest reading; it must not be drawn as "idle", because a relay can be - * connected with its subscriptions still being assembled. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun RelayPurposeRow( - purposes: Set, - modifier: Modifier = Modifier, -) { - if (purposes.isEmpty()) return - - // Stable order so the chips do not reshuffle between recompositions as filters come and go. - val sorted = remember(purposes) { purposes.sortedWith(compareBy({ it.group.ordinal }, { it.ordinal })) } - - FlowRow( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - sorted.forEach { purpose -> - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - Text( - text = stringRes(SubPurposeLabels.labelOf(purpose)), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(PaddingValues(horizontal = 5.dp, vertical = 1.dp)), - ) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt index 693b3c31f2..0926c5268d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt @@ -80,4 +80,31 @@ object SubPurposeLabels { * backgrounding, so itemising them would add noise precisely when nobody is looking. */ fun isWorthNamingInNotification(purpose: SubPurpose): Boolean = purpose.group == SubPurposeGroup.ACCOUNT || purpose.group == SubPurposeGroup.MESSAGES + + /** + * How this subscription actually works — the strategy, not the intent. + * + * Only the jobs whose relay footprint surprises people have one; the rest are self-evident from + * their label. Written from the code they describe: `MODERATION` says it asks each relay your + * follows publish to because `UserReportsSubAssembler` walks `declaredFollowsPerOutboxRelay`, + * and `NOTIFICATIONS` mentions the follows-wide probe because + * `AccountNotificationsEoseFromRandomRelaysManager` subscribes to every follows relay. + */ + fun explainerOf(purpose: SubPurpose): Int? = + when (purpose) { + SubPurpose.NOTIFICATIONS -> R.string.relay_explain_notifications + SubPurpose.DIRECT_MESSAGES -> R.string.relay_explain_direct_messages + SubPurpose.PUBLIC_CHATS -> R.string.relay_explain_public_chats + SubPurpose.COMMUNITY_CHATS -> R.string.relay_explain_community_chats + SubPurpose.ENCRYPTED_GROUPS -> R.string.relay_explain_encrypted_groups + SubPurpose.LIVE_ROOMS -> R.string.relay_explain_live_rooms + SubPurpose.ACCOUNT_DATA -> R.string.relay_explain_account_data + SubPurpose.PROFILE_METADATA -> R.string.relay_explain_profiles + SubPurpose.RELAY_LISTS -> R.string.relay_explain_relay_lists + SubPurpose.FOLLOW_LISTS -> R.string.relay_explain_follows + SubPurpose.MODERATION -> R.string.relay_explain_moderation + SubPurpose.WALLET -> R.string.relay_explain_wallet + SubPurpose.HOME_FEED -> R.string.relay_explain_home + else -> null + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt index 6cba78aed4..8023dafcf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt @@ -20,20 +20,31 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.HorzHalfVertPadding @@ -62,6 +73,26 @@ fun LazyListScope.renderConnectedItems( accountViewModel: AccountViewModel, nav: INav, ) { + // The list answers "which relays am I on"; this answers the follow-up question it always + // provokes — "and why are there this many". + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.ActiveSubscriptions) } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = stringRes(R.string.active_subs_title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + HorizontalDivider() + } + itemsIndexed(feedState, key = { _, item -> "Connected" + item.relay.url }) { _, item -> BasicRelaySetupInfoDialog( item, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt index 6babfdc831..4f6de20024 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposes import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel @@ -54,12 +53,8 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { } } - // Every in-flight filter that has been tagged says why this relay is connected. - val purposes = reqs.values.flatten().purposes() - BasicRelaySetupInfo( relay = it, - purposes = purposes, relayStat = Amethyst.instance.relayStats.get(it), forcesTor = Amethyst.instance.torEvaluatorFlow.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt new file mode 100644 index 0000000000..7381020bd6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Explains why the app is holding the number of subscriptions it currently holds. + * + * Pivoted on purpose rather than relay, because the relay-shaped view hides exactly the thing worth + * finding: a probe holding hundreds of filters across hundreds of relays looks like one ordinary + * entry repeated on every row, while here it is a single line that dwarfs everything under it. + * + * Account is the outer grouping — several are normally logged in, they do not share relay sets, and + * a mixed total cannot be acted on. + */ +@Composable +fun ActiveSubscriptionsScreen(viewModel: ActiveSubscriptionsViewModel = viewModel()) { + LaunchedEffect(Unit) { viewModel.startPolling() } + val state by viewModel.state.collectAsStateWithLifecycle() + + LazyColumn(Modifier.fillMaxWidth()) { + item { + Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text( + countsLine(state.totalFilters, state.totalRelays), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + if (state.untaggedFilters > 0) { + // Stated rather than hidden: an untagged filter is a subscription this screen + // cannot explain, and pretending the total is fully attributed would be a lie. + Text( + pluralStringResource(R.plurals.active_subs_untagged, state.untaggedFilters, state.untaggedFilters), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider() + } + + items(state.accounts, key = { it.accountPubKey ?: "unattributed" }) { account -> + AccountSection(account) + HorizontalDivider() + } + } +} + +@Composable +private fun AccountSection(account: SubscriptionAccountRow) { + val name = account.accountPubKey?.let { displayNameOf(it) } ?: stringRes(R.string.active_subs_unattributed) + + Column(Modifier.padding(vertical = 4.dp)) { + Text( + text = name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + account.purposes.forEach { PurposeSection(it) } + } +} + +@Composable +private fun PurposeSection(purposeRow: SubscriptionPurposeRow) { + var expanded by rememberSaveable(purposeRow.purpose) { mutableStateOf(false) } + + Column( + Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 16.dp, vertical = 6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = stringRes(SubPurposeLabels.labelOf(purposeRow.purpose)), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = countsLine(purposeRow.filterCount, purposeRow.relays.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (expanded) { + SubPurposeLabels.explainerOf(purposeRow.purpose)?.let { + Text( + text = stringRes(it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp, bottom = 2.dp), + ) + } + purposeRow.entities.forEach { entity -> + val label = + entity.entityId?.let { displayNameOf(it) } + ?: entity.detail + ?: stringRes(R.string.active_subs_no_entity) + Text( + text = stringRes(R.string.active_subs_pair, label, pluralStringResource(R.plurals.active_subs_relays, entity.relays.size, entity.relays.size)), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 12.dp, top = 2.dp), + ) + } + } + } +} + +/** "N filters · M relays", each noun pluralised on its own count. */ +@Composable +private fun countsLine( + filters: Int, + relays: Int, +): String = + stringRes( + R.string.active_subs_pair, + pluralStringResource(R.plurals.active_subs_filters, filters, filters), + pluralStringResource(R.plurals.active_subs_relays, relays, relays), + ) + +/** + * Resolves an id to whatever name is loaded right now, falling back to a short id. + * + * Deliberately at render time rather than baked into the filter: names arrive after the subscription + * that needed them, so a name captured at filter-construction would usually be missing and would go + * stale when the user renames. + */ +@Composable +private fun displayNameOf(id: HexKey): String { + val cached = + remember(id) { + LocalCache.getUserIfExists(id)?.toBestDisplayName() + ?: LocalCache.getNoteIfExists(id)?.event?.let { LocalCache.getUserIfExists(it.pubKey)?.toBestDisplayName() } + } + return cached ?: id.take(8) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt new file mode 100644 index 0000000000..24f04cf36f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * "Why do I have this many subscriptions right now?" + * + * Pivots on **purpose**, not on relay. The relay-shaped view cannot answer the question: a probe + * holding 670 filters across 168 relays looks like one unremarkable chip repeated on 168 rows, + * whereas here it is a single line that reads `Notifications · 670 filters · 168 relays` and is + * immediately obviously the largest thing running. + * + * Grouped by account first because several accounts are normally logged in and they do not share + * relay sets — a total that mixes them cannot be acted on. + * + * Everything is a **snapshot**, polled rather than observed: `activeRequests` is a plain map read + * off the relay pool with no change feed, and subscriptions churn constantly (every EOSE advances a + * `since`). Polling on a visible screen is honest and cheap; a push feed would mean instrumenting + * the pool for a diagnostic screen. + */ +@Immutable +data class SubscriptionEntityRow( + /** Null when the filter named no entity — "the rest of this purpose", not a real entity. */ + val entityId: HexKey?, + val detail: String?, + val relays: List, + val filterCount: Int, +) + +@Immutable +data class SubscriptionPurposeRow( + val purpose: SubPurpose, + val filterCount: Int, + val relays: List, + val entities: List, +) + +@Immutable +data class SubscriptionAccountRow( + /** Null groups everything not yet attributed to an account. Shown last, never hidden. */ + val accountPubKey: HexKey?, + val filterCount: Int, + val relays: List, + val purposes: List, +) + +@Immutable +data class ActiveSubscriptionsState( + val accounts: List = emptyList(), + val totalFilters: Int = 0, + val totalRelays: Int = 0, + /** Filters in flight that carry no purpose — assemblers not yet migrated. Honesty, not a bug. */ + val untaggedFilters: Int = 0, +) + +class ActiveSubscriptionsViewModel : ViewModel() { + private val _state = MutableStateFlow(ActiveSubscriptionsState()) + val state: StateFlow = _state.asStateFlow() + + /** Polls while the screen is on. [REFRESH_MS] is slow enough to be free, fast enough to feel live. */ + fun startPolling() { + viewModelScope.launch(Dispatchers.Default) { + while (isActive) { + _state.value = snapshot() + kotlinx.coroutines.delay(REFRESH_MS) + } + } + } + + private suspend fun snapshot(): ActiveSubscriptionsState = + withContext(Dispatchers.Default) { + val client = Amethyst.instance.client + + // account -> purpose -> entity -> relays / count + val byAccount = mutableMapOf>>>() + val detailOf = mutableMapOf, String?>() + var total = 0 + var untagged = 0 + val allRelays = mutableSetOf() + + client.connectedRelaysFlow().value.forEach { relay -> + client.activeRequests(relay).values.flatten().forEach { filter -> + total++ + val explained = filter as? ExplainedFilter + if (explained == null) { + untagged++ + return@forEach + } + allRelays.add(relay) + byAccount + .getOrPut(explained.accountPubKey) { mutableMapOf() } + .getOrPut(explained.purpose) { mutableMapOf() } + .getOrPut(explained.entityId) { mutableListOf() } + .add(relay) + detailOf[explained.purpose to explained.entityId] = explained.purposeDetail + } + } + + val accounts = + byAccount + .map { (account, purposes) -> + val purposeRows = + purposes + .map { (purpose, entities) -> + val entityRows = + entities + .map { (entityId, relays) -> + SubscriptionEntityRow( + entityId = entityId, + detail = detailOf[purpose to entityId], + relays = relays.distinct().sortedBy { it.url }, + filterCount = relays.size, + ) + }.sortedByDescending { it.filterCount } + SubscriptionPurposeRow( + purpose = purpose, + filterCount = entityRows.sumOf { it.filterCount }, + relays = entityRows.flatMap { it.relays }.distinct(), + entities = entityRows, + ) + }.sortedByDescending { it.filterCount } + SubscriptionAccountRow( + accountPubKey = account, + filterCount = purposeRows.sumOf { it.filterCount }, + relays = purposeRows.flatMap { it.relays }.distinct(), + purposes = purposeRows, + ) + } + // unattributed group last, so it reads as a remainder rather than a headline + .sortedWith(compareBy { it.accountPubKey == null }.thenByDescending { it.filterCount }) + + ActiveSubscriptionsState( + accounts = accounts, + totalFilters = total, + totalRelays = allRelays.size, + untaggedFilters = untagged, + ) + } + + companion object { + const val REFRESH_MS = 2_000L + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 06eda5cabe..6f979366fc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2085,6 +2085,40 @@ Add-ons Relay info Other + + Your inbox relays, plus a probe on every relay your follows post to, in case a mention was delivered somewhere else. + Your DM inbox relays, where gift-wrapped messages are delivered. + The home relay of each chat you have open or joined. + The relays each community publishes its planes to. + Group messages and key packages, on each group\'s relays. + The room\'s relays, while it is open. + Your own profile, settings and drafts, on your home relays. + Profiles of the people currently on screen. + Finds which relays each person publishes to, so their posts can be fetched from the right place. + Follow lists, used to build your feed and your web of trust. + Reports written by people you follow, asked of each relay those people post to. + Your mints, wallet state and incoming nutzaps. + Active Subscriptions + + + %1$d filter + %1$d filters + + + %1$d relay + %1$d relays + + + %1$d filter is not attributed yet + %1$d filters are not attributed yet + + %1$s \u00b7 %2$s + Not attributed to an account + All + Posts by people you follow, read from the relays each of them publishes to. Connecting to inbox relays\u2026 Always-on notification service Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message. From 1e20e104972f10c4c15b1570b15dcf194d1b7bfe Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 11:33:48 -0400 Subject: [PATCH 009/227] fix(notifications): sample the straggler relays instead of watching all of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe that catches mentions delivered to the wrong relay was subscribing to every relay the user's follows post to — ~330 relays, ~670 filters held permanently, by a wide margin the largest thing the client ran. Despite the class name nothing about it was random and nothing bounded it; `updateFilter` returned `followsPerRelay.keys - notificationRelays` in full, and a grep for take/shuffled/random/subList across the manager found nothing. It now watches a rotating window of 5 relays that slides every 5 minutes, so the whole space is still swept, just never all at once. That matches what the job actually needs: a background sweep for misdelivered mentions, while the inbox relays carry the real traffic. The window is a contiguous slice of a URL-sorted list, not a fresh random draw. Re-picking at random on each invalidation would re-REQ a different set every few seconds — the manager already invalidates on follows changes (debounced 5s) and notification-feed updates (sampled 5s) — which would cost more than the subscriptions it replaced. Deterministic order means the window only moves when the rotation timer says so. The rotation job is registered alongside the existing two and cancelled with them in endSub. Measured on device, cold start, same account: before NOTIFICATIONS 670 filters 168 relays after NOTIFICATIONS 58 filters 13 relays (8 inbox + 5 sampled) The Active Subscriptions explainer was updated in the same commit: it described the old behaviour, and an explainer that lies is worse than none. HOME_FEED is now the largest at 2,485 filters across 355 relays. That is the outbox model working as designed rather than an anomaly, but it is the next thing worth looking at. Co-Authored-By: Claude Opus 5 (1M context) --- ...otificationsEoseFromRandomRelaysManager.kt | 55 +++++++++++++++++-- amethyst/src/main/res/values/strings.xml | 2 +- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt index 7cfc8cd385..1de1ebd3d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt @@ -30,9 +30,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscriptio import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch class AccountNotificationsEoseFromRandomRelaysManager( @@ -42,9 +44,21 @@ class AccountNotificationsEoseFromRandomRelaysManager( override fun user(key: AccountQueryState) = key.account.userProfile() /** - * Downloads most notifications from the user's own inbox relays. - * But also connects to all the follows relays to check for new notifications that are not in the user's - * own inbox. + * Most notifications arrive on the user's own inbox relays. This is the straggler probe for the + * rest: other clients sometimes deliver a mention to the author's own relays instead of the + * recipient's inbox, so those only ever turn up if we go and look. + * + * It looks at a **rotating window of [RELAYS_PER_PASS] relays**, not at every relay the follows + * post to. The whole set was ~330 relays on a normal account, and subscribing to all of them + * held roughly 670 filters permanently — by a wide margin the largest thing the client ran, for + * a job that only needs to sweep the space eventually, not watch all of it at once. The window + * slides every [PASS_DURATION_MS], so every relay is still visited, just never all at the same + * time. + * + * The window is a contiguous slice of a URL-sorted list rather than a random draw: a fresh + * random pick on each invalidation would re-REQ a different set every few seconds, which costs + * more than the subscriptions it replaced. Deterministic order means the window only moves when + * the rotation timer says so. */ override fun updateFilter( key: AccountQueryState, @@ -55,12 +69,26 @@ class AccountNotificationsEoseFromRandomRelaysManager( // newest either way — the floor only ever hid notifications older than a week, and since the // boundary above needs a full page to arm, a quiet inbox could never page past it. val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() - return (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap { + val candidates = + (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value) + .sortedBy { it.url } + if (candidates.isEmpty()) return emptyList() + + val start = (passIndex * RELAYS_PER_PASS).mod(candidates.size) + val window = List(minOf(RELAYS_PER_PASS, candidates.size)) { candidates[(start + it).mod(candidates.size)] } + + return window.flatMap { val since = since?.get(it)?.time ?: defaultSince filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since) } } + /** + * Which slice of the sorted relay list the current pass is on. Bumped by the rotation job below; + * `mod` at the read site keeps it valid however far it runs. + */ + @Volatile private var passIndex = 0 + val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) @@ -80,6 +108,14 @@ class AccountNotificationsEoseFromRandomRelaysManager( invalidateFilters() } }, + // Slides the window. Cancelled with the others in endSub, so it stops with the account. + key.account.scope.launch(Dispatchers.IO) { + while (isActive) { + delay(PASS_DURATION_MS) + passIndex++ + invalidateFilters() + } + }, ) return super.newSub(key) @@ -92,4 +128,15 @@ class AccountNotificationsEoseFromRandomRelaysManager( super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } + + companion object { + /** + * Relays watched per pass. Small on purpose: this is a background sweep for misdelivered + * mentions, and the inbox relays carry the real traffic. + */ + const val RELAYS_PER_PASS = 5 + + /** How long a window stays put before sliding. Long enough that re-REQ churn stays negligible. */ + const val PASS_DURATION_MS = 5 * 60 * 1000L + } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6f979366fc..d55d790cea 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2088,7 +2088,7 @@ - Your inbox relays, plus a probe on every relay your follows post to, in case a mention was delivered somewhere else. + Your inbox relays, plus a small rotating sample of the relays your follows post to, in case a mention was delivered somewhere else. Your DM inbox relays, where gift-wrapped messages are delivered. The home relay of each chat you have open or joined. The relays each community publishes its planes to. From b6808959cd82715166bb22725711fb1f4a72db5b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 20:59:48 -0400 Subject: [PATCH 010/227] refactor(relays): split the public-chat purpose into the five protocols it held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUBLIC_CHATS was a catch-all carrying NIP-28 chats, NIP-29 relay groups, ephemeral chats, geohash chats and live-stream chat under one row labelled with the NIP-29 name — so the subscription screen could not say where any of them came from, and the label was wrong for four fifths of what it counted. Each is now its own purpose with its own label and explainer. Geohash cells carry their id too, so location chats list one row per cell and render as a place name instead of a single opaque group. Co-Authored-By: Claude Opus 5 (1M context) --- .../FilterGoalForLiveActivity.kt | 4 +- .../FilterMessagesToEphemeralChat.kt | 2 +- .../FilterMessagesToGeohashChat.kt | 5 ++- .../FilterMessagesToLiveStream.kt | 2 +- .../FilterMyMessagesToEphemeralChat.kt | 2 +- .../FilterMyMessagesToLiveActivities.kt | 2 +- .../subassemblies/FilterRelayGroupState.kt | 6 +-- .../RelayGroupCardWarmupFilterAssembler.kt | 7 +-- .../datasource/RelayGroupFilterBuilders.kt | 31 +++++++------ .../FilterRelayGroupsByAuthors.kt | 6 +-- .../FilterRelayGroupsByGeohashes.kt | 2 +- .../FilterRelayGroupsByHashtag.kt | 2 +- .../FilterRelayGroupsDirectory.kt | 2 +- .../FilterFollowingEphemeralChats.kt | 2 +- .../datasource/FilterFollowingGeohashChats.kt | 5 ++- .../relays/common/SubPurposeLabels.kt | 38 +++++++++++----- amethyst/src/main/res/values/strings.xml | 43 +++++++++++++++++-- .../relayClient/subscriptions/SubPurpose.kt | 42 +++++++++++++++++- 18 files changed, 153 insertions(+), 50 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt index 212ce98b67..7c55b9db06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt @@ -49,7 +49,7 @@ fun filterGoalForLiveActivities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.LIVE_CHAT, kinds = listOf(GoalEvent.KIND), ids = listOf(goalId), limit = 1, @@ -59,7 +59,7 @@ fun filterGoalForLiveActivities( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.LIVE_CHAT, kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("e" to listOf(goalId)), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt index 6ba64df397..a3797580b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToEphemeralChat.kt @@ -36,7 +36,7 @@ fun filterMessagesToEphemeralChat( relay = it, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.EPHEMERAL_CHATS, kinds = listOf(EphemeralChatEvent.KIND), tags = if (channel.roomId.id.isBlank()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToGeohashChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToGeohashChat.kt index 28bf7a214f..837b6da8e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToGeohashChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToGeohashChat.kt @@ -43,7 +43,10 @@ fun filterMessagesToGeohashChat( relay = it, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.GEOHASH_CHATS, + // The cell IS the chat's identity, so the screen can list one row per location + // instead of collapsing every joined cell into one unnamed entry. + entityIds = listOf(channel.geohash), kinds = listOf(GeohashChatEvent.KIND), tags = mapOf("g" to listOf(channel.geohash)), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index bebfa71971..6a5410c356 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -41,7 +41,7 @@ fun filterMessagesToLiveActivities( relay = it, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.LIVE_CHAT, kinds = listOf( LiveActivitiesChatMessageEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt index e3511f3cc3..650e55c344 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToEphemeralChat.kt @@ -38,7 +38,7 @@ fun filterMyMessagesToEphemeralChat( relay = it, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.EPHEMERAL_CHATS, kinds = listOf(EphemeralChatEvent.KIND), tags = if (channel.roomId.id.isBlank()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt index c9d718ad05..2258d81255 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMyMessagesToLiveActivities.kt @@ -38,7 +38,7 @@ fun filterMyMessagesToLiveActivities( relay = it, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.LIVE_CHAT, kinds = listOf(LiveActivitiesChatMessageEvent.KIND), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), authors = listOf(pubKey), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt index 15f1a535d1..f56aa7f54f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt @@ -49,8 +49,8 @@ fun filterRelayGroupState( relays.flatMap { val floor = since?.get(it)?.time listOf( - RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = floor)), - RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = floor)), + RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = floor)), + RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = floor)), ) } @@ -63,7 +63,7 @@ fun filterRelayGroupState( if (pinnedIds.isEmpty()) { emptyList() } else { - relays.map { RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.PUBLIC_CHATS, ids = pinnedIds)) } + relays.map { RelayBasedFilter(relay = it, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, ids = pinnedIds)) } } return directory + pins diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupCardWarmupFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupCardWarmupFilterAssembler.kt index aa5bfd453f..8e3d16982a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupCardWarmupFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupCardWarmupFilterAssembler.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.filterRelayGroupState @@ -35,13 +36,13 @@ import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag /** One on-screen group card's request to warm a single group. */ class RelayGroupCardWarmupQueryState( - val account: Account, + override val account: Account, val channel: RelayGroupChannel, /** When true, prefetch only recent content — the caller's screen already streams metadata. */ val contentOnly: Boolean = false, /** How many recent content events to prefetch ahead of a tap. */ val contentLimit: Int = RELAY_GROUP_WARMUP_LIMIT, -) +) : AccountScopedQuery /** * Default number of recent events to pull ahead of a tap — enough to fill the first screen AND drive @@ -96,7 +97,7 @@ class RelayGroupCardWarmupSubAssembler( relay = groupId.relayUrl, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, kinds = RELAY_GROUP_CARD_WARMUP_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), limit = key.contentLimit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt index 4533686692..d432e55357 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt @@ -128,12 +128,12 @@ fun buildRelayGroupLiveStateFilter(groupId: GroupId): List = listOf( RelayBasedFilter( relay = groupId.relayUrl, - filter = ExplainedFilter(purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), + filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, entityIds = listOf(groupId.id), kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), ), // Pins stay in their own filter for the same reason the directory filters split them out. RelayBasedFilter( relay = groupId.relayUrl, - filter = ExplainedFilter(purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_PIN_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), + filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, entityIds = listOf(groupId.id), kinds = RELAY_GROUP_PIN_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), ), ) @@ -287,8 +287,8 @@ fun buildRelayGroupStateFilters( val scope = mapOf(D_TAG to ids.distinct()) val since = sinceForRelay(relay) listOf( - RelayBasedFilter(relay = relay, filter = ExplainedFilter(purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = since)), - RelayBasedFilter(relay = relay, filter = ExplainedFilter(purpose = SubPurpose.PUBLIC_CHATS, kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = since)), + RelayBasedFilter(relay = relay, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, entityIds = ids.distinct(), kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = since)), + RelayBasedFilter(relay = relay, filter = ExplainedFilter(purpose = SubPurpose.RELAY_GROUPS, entityIds = ids.distinct(), kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = since)), ) } @@ -326,7 +326,8 @@ fun buildRelayGroupJoinedChatTailFilter( relay = groupId.relayUrl, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -362,7 +363,8 @@ fun buildRelayGroupPreviewFilter( relay = groupId.relayUrl, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -383,7 +385,8 @@ fun buildRelayGroupAuxFilter( relay = groupId.relayUrl, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_AUX_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -400,7 +403,8 @@ fun buildRelayGroupOpenChatTailFilter( relay = groupId.relayUrl, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_OPEN_TAIL_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, @@ -424,7 +428,8 @@ fun buildRelayGroupHistoryFilters( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_ALL_TIMELINE_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), until = until, @@ -445,7 +450,7 @@ fun buildRelayGroupDirectoryFilter( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, kinds = RELAY_GROUP_DIRECTORY_KINDS, limit = RELAY_GROUP_DIRECTORY_LIMIT, since = sinceEpoch, @@ -469,7 +474,8 @@ fun buildRelayGroupThreadsHistoryFilters( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_THREAD_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), until = until, @@ -487,7 +493,8 @@ fun buildRelayGroupThreadsFilter( relay = groupId.relayUrl, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, + entityIds = listOf(groupId.id), kinds = RELAY_GROUP_THREAD_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt index 70fd3d0684..1e0e1291f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt @@ -70,7 +70,7 @@ fun filterRelayGroupsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, authors = authorList, kinds = listOf(GroupMetadataEvent.KIND), limit = 200, @@ -82,7 +82,7 @@ fun filterRelayGroupsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, kinds = listOf(GroupAdminsEvent.KIND, GroupMembersEvent.KIND), tags = mapOf(PTag.TAG_NAME to authorList), limit = 200, @@ -102,7 +102,7 @@ fun filterRelayGroupsByAuthors( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf(DTag.TAG_NAME to rosterGroupIds), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt index a1d83e48fd..7b9c8b5922 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt @@ -41,7 +41,7 @@ fun filterRelayGroupsByGeohashes( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf(GeoHashTag.TAG_NAME to geotags.map { it.lowercase() }.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt index 5c5b113e9a..98847f9bdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt @@ -39,7 +39,7 @@ fun filterRelayGroupsByHashtag( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf(HashtagTag.TAG_NAME to hashtags.map { it.lowercase() }), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt index d6deb84bf5..06595c754b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt @@ -53,7 +53,7 @@ fun filterRelayGroupsDirectory( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.RELAY_GROUPS, kinds = RELAY_GROUP_DISCOVERY_KINDS, limit = 500, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt index a5897a49f3..6be3d19400 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt @@ -54,7 +54,7 @@ fun filterFollowingEphemeralChats( relay = it.key, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.EPHEMERAL_CHATS, kinds = listOf(EphemeralChatEvent.KIND), tags = mapOf("d" to it.value.sorted()), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt index 1ac81ad02c..29f3466e53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingGeohashChats.kt @@ -55,7 +55,10 @@ fun filterFollowingGeohashChats( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PUBLIC_CHATS, + purpose = SubPurpose.GEOHASH_CHATS, + // One filter per relay carries every cell that relay serves, so all of them ride + // along and the screen fans them into a row each. + entityIds = cells.sorted(), kinds = listOf(GeohashChatEvent.KIND), tags = mapOf("g" to cells), limit = 100, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt index 0926c5268d..b4254ebbc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt @@ -39,31 +39,38 @@ object SubPurposeLabels { fun labelOf(purpose: SubPurpose): Int = when (purpose) { // account — always on - SubPurpose.ACCOUNT_DATA -> R.string.kind_drafts - SubPurpose.PROFILE_METADATA -> R.string.kind_profile - SubPurpose.RELAY_LISTS -> R.string.kind_outbox_relays + SubPurpose.ACCOUNT_DATA -> R.string.relay_purpose_your_account + SubPurpose.PROFILE_METADATA -> R.string.relay_purpose_observing_profiles + SubPurpose.RELAY_LISTS -> R.string.relay_purpose_relay_list_finder SubPurpose.FOLLOW_LISTS -> R.string.kind_follow_list - SubPurpose.MODERATION -> R.string.kind_reports - SubPurpose.WALLET -> R.string.wallet + SubPurpose.MODERATION -> R.string.relay_purpose_reports_from_follows + SubPurpose.WALLET -> R.string.relay_purpose_your_wallet + SubPurpose.NUTZAP_INBOX -> R.string.relay_purpose_nutzap_inbox + SubPurpose.MINT_DIRECTORY -> R.string.relay_purpose_mint_directory + SubPurpose.NWC -> R.string.relay_purpose_nwc // messages — always on SubPurpose.NOTIFICATIONS -> R.string.route_notifications - SubPurpose.DIRECT_MESSAGES -> R.string.route_messages - SubPurpose.PUBLIC_CHATS -> R.string.relay_chat_title - SubPurpose.COMMUNITY_CHATS -> R.string.communities + SubPurpose.DIRECT_MESSAGES -> R.string.relay_purpose_dm_inbox + SubPurpose.PUBLIC_CHATS -> R.string.public_chat + SubPurpose.RELAY_GROUPS -> R.string.relay_purpose_relay_groups + SubPurpose.EPHEMERAL_CHATS -> R.string.relay_purpose_ephemeral_chats + SubPurpose.GEOHASH_CHATS -> R.string.relay_purpose_geohash_chats + SubPurpose.LIVE_CHAT -> R.string.relay_purpose_live_chat + SubPurpose.COMMUNITY_CHATS -> R.string.relay_purpose_community_chats SubPurpose.ENCRYPTED_GROUPS -> R.string.marmot_group SubPurpose.LIVE_ROOMS -> R.string.nests // feeds - SubPurpose.HOME_FEED -> R.string.route_home + SubPurpose.HOME_FEED -> R.string.relay_purpose_home_feed SubPurpose.DISCOVER_FEED -> R.string.route_discover SubPurpose.MEDIA_FEED -> R.string.relay_purpose_media SubPurpose.TAG_FEED -> R.string.relay_purpose_tags - SubPurpose.COMMUNITY_FEED -> R.string.communities + SubPurpose.COMMUNITY_FEED -> R.string.relay_purpose_community_feeds SubPurpose.TOPIC_FEED -> R.string.relay_purpose_topics // current screen SubPurpose.THREAD -> R.string.relay_purpose_thread SubPurpose.USER_PROFILE -> R.string.kind_profile SubPurpose.SEARCH -> R.string.relay_purpose_search - SubPurpose.ENGAGEMENT -> R.string.kind_reactions + SubPurpose.ENGAGEMENT -> R.string.relay_purpose_engagement SubPurpose.REFERENCED_EVENTS -> R.string.relay_purpose_referenced SubPurpose.ADD_ONS -> R.string.relay_purpose_add_ons SubPurpose.GAMES -> R.string.route_chess @@ -95,6 +102,10 @@ object SubPurposeLabels { SubPurpose.NOTIFICATIONS -> R.string.relay_explain_notifications SubPurpose.DIRECT_MESSAGES -> R.string.relay_explain_direct_messages SubPurpose.PUBLIC_CHATS -> R.string.relay_explain_public_chats + SubPurpose.RELAY_GROUPS -> R.string.relay_explain_relay_groups + SubPurpose.EPHEMERAL_CHATS -> R.string.relay_explain_ephemeral_chats + SubPurpose.GEOHASH_CHATS -> R.string.relay_explain_geohash_chats + SubPurpose.LIVE_CHAT -> R.string.relay_explain_live_chat SubPurpose.COMMUNITY_CHATS -> R.string.relay_explain_community_chats SubPurpose.ENCRYPTED_GROUPS -> R.string.relay_explain_encrypted_groups SubPurpose.LIVE_ROOMS -> R.string.relay_explain_live_rooms @@ -104,7 +115,12 @@ object SubPurposeLabels { SubPurpose.FOLLOW_LISTS -> R.string.relay_explain_follows SubPurpose.MODERATION -> R.string.relay_explain_moderation SubPurpose.WALLET -> R.string.relay_explain_wallet + SubPurpose.NUTZAP_INBOX -> R.string.relay_explain_nutzap_inbox + SubPurpose.MINT_DIRECTORY -> R.string.relay_explain_mint_directory + SubPurpose.NWC -> R.string.relay_explain_nwc SubPurpose.HOME_FEED -> R.string.relay_explain_home + SubPurpose.ENGAGEMENT -> R.string.relay_explain_engagement + SubPurpose.REFERENCED_EVENTS -> R.string.relay_explain_referenced else -> null } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d55d790cea..6dcd4e12e3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2081,10 +2081,39 @@ Topics Conversation Search - Quoted posts + Finding Missing Events + Observing Events + Fetches events by id that something on your screen refers to but you don\'t have yet — a quote, a reply\'s parent, a thread root. + Watches the events currently rendered for new replies, reactions, reposts, zaps and reports, so counts update while you read. Add-ons Relay info Other + + Relay List Finder + Observing Profiles + Your Account\'s data + Home Feed + Relay Groups + + %1$d group + %1$d groups + + Ephemeral Chats + Location Chats + Live Stream Chat + NIP-29 groups you joined. Each group lives on one host relay, so the app connects to every relay that hosts a group of yours. + Chat rooms that keep no history — messages exist only while you are connected, so these stay subscribed to receive anything at all. + Location-based rooms for the areas you follow, asked of the relays that carry them. + Chat and zap goals attached to live streams you have open or follow. + DM Inbox + Wallet + Nutzap Inbox + Mint Directory + Wallet Connect + Community Chats + Community Feeds @@ -2098,9 +2127,13 @@ Profiles of the people currently on screen. Finds which relays each person publishes to, so their posts can be fetched from the right place. Follow lists, used to build your feed and your web of trust. - Reports written by people you follow, asked of each relay those people post to. - Your mints, wallet state and incoming nutzaps. - Active Subscriptions + Reports your follows wrote about the profiles currently on your screen, asked of each relay those follows post to. + Reports from Follows + Your own wallet events, read back from the relays you published them to. + Listens on your nutzap relays plus your inbox and DM relays, so a payment cannot slip past. + Looks across relays for which mints exist and which ones people recommend. + Notifications from your connected wallet. + Active Relay Subscriptions @@ -2118,6 +2151,8 @@ %1$s \u00b7 %2$s Not attributed to an account All + %1$d%% of all + subscriptions filters relays requests reqs connections why diagnostics Posts by people you follow, read from the relays each of them publishes to. Connecting to inbox relays\u2026 Always-on notification service diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt index cc865fdbbd..926e11d11f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt @@ -69,9 +69,28 @@ enum class SubPurpose( /** Reports (kind 1984), mute lists, spam and ban state. */ MODERATION(SubPurposeGroup.ACCOUNT, runsInBackground = true), - /** Cashu wallet, nutzaps, mints and NWC. */ + /** + * Your own NIP-60 wallet state, read back from your outbox relays. Narrow by construction. + * + * Split from the wider wallet jobs because they answer different questions: this is "restore my + * wallet", [NUTZAP_INBOX] is "listen everywhere someone might pay me", and [MINT_DIRECTORY] is + * "what mints exist". Lumping them made a handful of relays look like a dozen. + */ WALLET(SubPurposeGroup.ACCOUNT, runsInBackground = true), + /** + * Inbound nutzaps (kind 9321). Deliberately the **union** of the NIP-61 `10019` relays, the + * NIP-65 inbox and the DM relays — per the assembler, "so a nutzap can't slip past us". That + * union is why this is wide, and it is a choice rather than an accident. + */ + NUTZAP_INBOX(SubPurposeGroup.ACCOUNT, runsInBackground = true), + + /** Mint announcements and recommendations — a discovery query, so it fans out. */ + MINT_DIRECTORY(SubPurposeGroup.ACCOUNT), + + /** Nostr Wallet Connect notifications, on the wallet-connect relay. */ + NWC(SubPurposeGroup.ACCOUNT, runsInBackground = true), + // ---- things addressed to me: always on --------------------------------- /** Mentions, reactions, reposts and zaps addressed to me (`#p` = me). Inbox relays. */ @@ -80,9 +99,28 @@ enum class SubPurpose( /** NIP-17 gift wraps and NIP-04 legacy DMs. DM relays. */ DIRECT_MESSAGES(SubPurposeGroup.MESSAGES, runsInBackground = true), - /** NIP-28/29 public channels and relay groups. */ + /** + * NIP-28 public channels only. + * + * This was a catch-all that also carried NIP-29 relay groups, ephemeral chats, geohash chats and + * live-stream chat — five protocols in one row labelled with the NIP-29 name, so the subscription + * screen could not say where any of them came from. They are separate below, each named and + * counted on its own. + */ PUBLIC_CHATS(SubPurposeGroup.MESSAGES, runsInBackground = true), + /** NIP-29 relay groups. Each group is keyed by (id, host relay). */ + RELAY_GROUPS(SubPurposeGroup.MESSAGES, runsInBackground = true), + + /** NIP-C7 ephemeral chats — no history is stored, so only live delivery exists. */ + EPHEMERAL_CHATS(SubPurposeGroup.MESSAGES, runsInBackground = true), + + /** Location-scoped chat rooms. */ + GEOHASH_CHATS(SubPurposeGroup.MESSAGES, runsInBackground = true), + + /** Chat and zap goals attached to live streams. */ + LIVE_CHAT(SubPurposeGroup.MESSAGES, runsInBackground = true), + /** Concord and Buzz community planes. */ COMMUNITY_CHATS(SubPurposeGroup.MESSAGES, runsInBackground = true), From 4d53bbea9e9564be9d2c7da5aa100745ca146ebd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 21:00:03 -0400 Subject: [PATCH 011/227] fix(relays): attribute every subscription to the account that asked for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filters carry an accountPubKey so the relay screens can group by account, but attribution only happened for keys implementing AccountScopedQuery — and ~50 query states held an `account` without declaring it, so the cast failed silently and their filters showed as unattributed. Two of the gaps were real bugs rather than display issues: - CashuWalletFilterAssembler took `keys.first().pubkey` while flat-mapping every account's relays, so with two wallets logged in the second was never subscribed and its inbox relays were queried for the first account's nutzaps. Now built per account. - UserReportsSubAssembler unioned every account's follow list into one per-relay map, asking one account's follows of another's outbox relays. Now one pass per account. Where a subscription genuinely pools accounts (outbox discovery, on-screen event watching, profile metadata), it is attributed only when a single account is asking rather than inventing an owner. Co-Authored-By: Claude Opus 5 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 11 ++-- .../service/relayClient/AccountScopedQuery.kt | 39 ++++++++++++++ .../eoseManagers/PerUniqueIdEoseManager.kt | 19 ++++++- .../PerUserAndFollowListEoseManager.kt | 19 ++++++- .../eoseManagers/PerUserEoseManager.kt | 19 ++++++- .../SingleSubNoEoseCacheEoseManager.kt | 19 ++++++- .../account/AccountFilterAssembler.kt | 5 +- .../AccountFollowsLoaderSubAssembler.kt | 17 +++++- .../marmot/MarmotGroupEventsEoseManager.kt | 2 +- .../NwcNotificationsEoseManager.kt | 2 +- .../ChannelFinderFilterAssemblyGroup.kt | 5 +- .../event/EventFinderFilterAssembler.kt | 5 +- .../watchers/EventWatcherSubAssembler.kt | 14 ++++- .../FilterRepliesAndReactionsToAddresses.kt | 6 +++ .../FilterRepliesAndReactionsToNotes.kt | 5 ++ .../user/UserFinderFilterAssembler.kt | 5 +- .../loaders/UserOutboxFinderSubAssembler.kt | 29 +++++++++- .../user/watchers/FilterReportsToKey.kt | 2 + .../user/watchers/FilterUserMetadataForKey.kt | 3 ++ .../user/watchers/UserCardsSubAssembler.kt | 9 ++++ .../user/watchers/UserReportsSubAssembler.kt | 23 +++++--- .../user/watchers/UserWatcherSubAssembler.kt | 8 +++ .../searchCommand/SearchFilterAssembler.kt | 6 ++- ...rofileAppRecommendationsFilterAssembler.kt | 5 +- .../datasource/ArticlesFilterAssembler.kt | 5 +- .../datasource/BadgesFilterAssembler.kt | 5 +- .../ProfileBadgesFilterAssembler.kt | 5 +- .../datasource/CalendarsFilterAssembler.kt | 5 +- .../datasource/ChatroomFilterAssembler.kt | 5 +- .../ConcordChannelFilterAssembler.kt | 11 ++-- .../ConcordChannelHistoryFilterAssembler.kt | 7 ++- .../BuzzDmJoinedChatTailFilterAssembler.kt | 5 +- ...RelayGroupJoinedChatTailFilterAssembler.kt | 5 +- .../RelayGroupJoinedStateFilterAssembler.kt | 5 +- ...elayGroupOpenChatHistoryFilterAssembler.kt | 5 +- .../RelayGroupOpenChatTailFilterAssembler.kt | 5 +- ...yGroupOpenThreadsHistoryFilterAssembler.kt | 5 +- .../RelayGroupsDiscoveryFilterAssembler.kt | 5 +- .../RelayGroupsOnRelayFilterAssembler.kt | 5 +- .../datasource/ChatroomListFilterAssembler.kt | 5 +- .../CommunitiesListFilterAssembler.kt | 5 +- .../datasource/DiscoveryFilterAssembler.kt | 5 +- .../BrowseEmojiSetsFilterAssembler.kt | 5 +- .../datasource/FollowPacksFilterAssembler.kt | 5 +- .../GitRepositoriesFilterAssembler.kt | 5 +- .../datasource/HashtagFilterAssembler.kt | 5 +- .../datasource/HighlightsFilterAssembler.kt | 5 +- .../home/datasource/HomeFilterAssembler.kt | 5 +- .../datasource/LiveStreamsFilterAssembler.kt | 5 +- .../longs/datasource/LongsFilterAssembler.kt | 5 +- .../MusicPlaylistsFilterAssembler.kt | 5 +- .../datasource/MusicTracksFilterAssembler.kt | 5 +- .../ConnectedAppsFilterAssembler.kt | 5 +- .../datasource/NappletsFilterAssembler.kt | 5 +- .../datasource/NestRoomFilterAssembler.kt | 5 +- .../NestRoomLivenessProbeAssembler.kt | 5 +- .../nests/datasource/NestsFilterAssembler.kt | 5 +- .../datasource/NsitesFilterAssembler.kt | 5 +- .../datasource/PicturesFilterAssembler.kt | 5 +- .../PodcastEpisodesFilterAssembler.kt | 5 +- .../datasource/PodcastsFilterAssembler.kt | 5 +- .../polls/datasource/PollsFilterAssembler.kt | 5 +- .../datasource/ProductsFilterAssembler.kt | 5 +- .../datasource/PublicChatsFilterAssembler.kt | 5 +- .../datasource/ShortsFilterAssembler.kt | 5 +- .../datasource/SoftwareAppsFilterAssembler.kt | 5 +- .../datasources/ThreadFilterAssembler.kt | 5 +- .../video/datasource/VideoFilterAssembler.kt | 5 +- .../datasource/WorkoutsFilterAssembler.kt | 5 +- .../actions/ConcordSubscriptionPlanner.kt | 22 +++++++- .../CashuMintDirectoryFilterAssembler.kt | 4 +- .../assemblers/CashuWalletFilterAssembler.kt | 27 ++++++++-- .../assemblers/ContactCardFilters.kt | 4 ++ .../assemblers/MetadataFilterAssembler.kt | 8 +++ .../subscriptions/ExplainedFilter.kt | 53 +++++++++++++++---- 75 files changed, 484 insertions(+), 144 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/AccountScopedQuery.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 68df7e7ed0..19699bc448 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3191,9 +3191,14 @@ class Account( val filters = entries.flatMap { entry -> val state = concordSessions.sessionFor(entry.id)?.state?.value ?: return@flatMap emptyList() - ConcordSubscriptionPlanner.channelPreviewFilters(entry, state, lastReadFor = { channelIdHex -> - loadLastRead(concordChannelLastReadRoute(entry.id, channelIdHex)) - }) + ConcordSubscriptionPlanner.channelPreviewFilters( + entry, + state, + lastReadFor = { channelIdHex -> + loadLastRead(concordChannelLastReadRoute(entry.id, channelIdHex)) + }, + accountPubKey = userProfile().pubkeyHex, + ) } if (filters.isEmpty()) return val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/AccountScopedQuery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/AccountScopedQuery.kt new file mode 100644 index 0000000000..3d6bc3fc0c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/AccountScopedQuery.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient + +import com.vitorpamplona.amethyst.model.Account + +/** + * A subscription query state that belongs to one logged-in account. + * + * Around 66 query-state classes already carry an `account`, but nothing tied them together, so a + * subscription manager could not ask "whose subscription is this?" without knowing the concrete + * type. That is why the Active Relay Subscriptions screen filed the home feed under "not attributed" + * despite it being built from one specific person's follow list: the base manager checked for + * `AccountQueryState` and the home feed uses `HomeQueryState`. + * + * Implement this on any query state whose subscriptions belong to a single account, and the base + * managers attribute them automatically. + */ +interface AccountScopedQuery { + val account: Account +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt index 2817e2a39d..0427380423 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relays.EOSEByKey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -114,7 +116,13 @@ abstract class PerUniqueIdEoseManager( uniqueSubscribedAccounts.forEach { val mainKey = id(it) - val newFilters = updateFilter(it, since(it))?.ifEmpty { null } + val newFilters = + updateFilter(it, since(it)) + ?.ifEmpty { null } + // Attribute to the account that owns this subscription, once, here — rather than + // threading a pubkey through every filter builder underneath. Builders that already + // know their account keep what they set. + ?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f } findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay()) updated.add(mainKey) @@ -131,4 +139,13 @@ abstract class PerUniqueIdEoseManager( ): List? abstract fun id(key: T): U + + /** + * The account behind [key], when the key is account-scoped. Null for keys about other users. + * + * Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses + * HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the + * other under "not attributed" despite both being built from a single account's data. + */ + private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt index 80bce3f6fa..cc55f02d7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt @@ -21,7 +21,9 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relays.EOSEAccountKey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -127,7 +129,13 @@ abstract class PerUserAndFollowListEoseManager( uniqueSubscribedAccounts.forEach { val user = user(it) val sub = findOrCreateSubFor(it) - val newFilters = updateFilter(it, since(it))?.ifEmpty { null } + val newFilters = + updateFilter(it, since(it)) + ?.ifEmpty { null } + // Attribute to the account that owns this subscription, once, here — rather than + // threading a pubkey through every filter builder underneath. Builders that already + // know their account keep what they set. + ?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f } sub.updateFilters(newFilters?.groupByRelay()) updated.add(user) } @@ -145,4 +153,13 @@ abstract class PerUserAndFollowListEoseManager( abstract fun user(key: T): User abstract fun list(key: T): U + + /** + * The account behind [key], when the key is account-scoped. Null for keys about other users. + * + * Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses + * HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the + * other under "not attributed" despite both being built from a single account's data. + */ + private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt index ad899cd2c1..b904bf4ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt @@ -21,7 +21,9 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -113,7 +115,13 @@ abstract class PerUserEoseManager( uniqueSubscribedAccounts.forEach { val user = user(it) - val newFilters = updateFilter(it, since(it))?.ifEmpty { null } + val newFilters = + updateFilter(it, since(it)) + ?.ifEmpty { null } + // Attribute to the account that owns this subscription, once, here — rather than + // threading a pubkey through every filter builder underneath. Builders that already + // know their account keep what they set. + ?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f } findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay()) @@ -131,4 +139,13 @@ abstract class PerUserEoseManager( ): List? abstract fun user(key: T): User + + /** + * The account behind [key], when the key is account-scoped. Null for keys about other users. + * + * Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses + * HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the + * other under "not attributed" despite both being built from a single account's data. + */ + private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt index 57fb10f46a..d4df1e5deb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -66,11 +68,26 @@ abstract class SingleSubNoEoseCacheEoseManager( override fun updateSubscriptions(keys: Set) { val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) } - val newFilters = updateFilter(uniqueSubscribedAccounts)?.ifEmpty { null } + val newFilters = + updateFilter(uniqueSubscribedAccounts) + ?.ifEmpty { null } + // Attribute to the account that owns this subscription, once, here — rather than + // threading a pubkey through every filter builder underneath. Builders that already + // know their account keep what they set. + ?.let { f -> accountPubKeyOf(uniqueSubscribedAccounts.firstOrNull())?.let { pk -> f.attributedTo(pk) } ?: f } sub.updateFilters(newFilters?.groupByRelay()) } abstract fun updateFilter(keys: List): List? abstract fun distinct(key: T): Any + + /** + * The account behind [key], when the key is account-scoped. Null for keys about other users. + * + * Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses + * HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the + * other under "not attributed" despite both being built from a single account's data. + */ + private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index f049140194..fe57efc735 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager @@ -38,10 +39,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to logged-in accounts. @Stable class AccountQueryState( - val account: Account, + override val account: Account, val feedContentStates: AccountFeedContentStates, val otherAccounts: Set, -) +) : AccountScopedQuery /** * Always-on account loaders: metadata, gift wraps, drafts, inbox-relay diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 6a3e4ca2d6..4318526e11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -159,6 +159,15 @@ class AccountFollowsLoaderSubAssembler( val connectedRelays = client.connectedRelaysFlow().value + // Attributed only when one account is asking. The follow lists above are unioned across every + // logged-in account and the relays are then picked from that union, so with several accounts + // active no single one owns a given filter. Deduped by pubkey because `Account` uses identity + // equality. + val soleAccountPubKey = + accounts + .mapTo(mutableSetOf()) { it.userProfile().pubkeyHex } + .singleOrNull() + val perRelay = pickRelaysToLoadUsers(users, accounts, connectedRelays, failureTracker.cannotConnectRelays, hasTried) hasTried.removeEveryoneBut(users) @@ -167,7 +176,13 @@ class AccountFollowsLoaderSubAssembler( if (users.isNotEmpty()) { RelayBasedFilter( relay = relay, - filter = ExplainedFilter(purpose = SubPurpose.FOLLOW_LISTS, kinds = listOf(AdvertisedRelayListEvent.KIND), authors = users.sorted()), + filter = + ExplainedFilter( + purpose = SubPurpose.RELAY_LISTS, + kinds = listOf(AdvertisedRelayListEvent.KIND), + authors = users.sorted(), + accountPubKey = soleAccountPubKey, + ), ) } else { null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt index fe34cca31b..f81c301100 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt @@ -91,7 +91,7 @@ class MarmotGroupEventsEoseManager( "(metadataRelays=${groupRelays?.size ?: 0}, usingFallback=${groupRelays.isNullOrEmpty()}): ${relaysForGroup.map { it.url }}" } for (relay in relaysForGroup) { - result.add(RelayBasedFilter(relay = relay, filter = ExplainedFilter.of(filter, SubPurpose.ENCRYPTED_GROUPS, "MLS group messages"))) + result.add(RelayBasedFilter(relay = relay, filter = ExplainedFilter.of(filter, SubPurpose.ENCRYPTED_GROUPS, "MLS group messages", entityIds = listOf(groupId)))) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt index 62594bacc4..384c89df06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt @@ -90,7 +90,7 @@ class NwcNotificationsEoseManager( relay = wallet.uri.relayUri, filter = ExplainedFilter( - purpose = SubPurpose.WALLET, + purpose = SubPurpose.NWC, kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND), authors = listOf(wallet.uri.pubKeyHex), tags = mapOf("p" to listOf(signer.pubKey)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt index 3f12d0bd05..2560fcc2cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -32,8 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @Stable class ChannelFinderQueryState( val channel: Channel, - val account: Account, -) + override val account: Account, +) : AccountScopedQuery @Stable class ChannelFinderFilterAssemblyGroup( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt index 3e00e65548..f1f320ae50 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.AddressableAuthorRelayLoaderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler @@ -35,8 +36,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @Stable class EventFinderQueryState( val note: Note, - val account: Account, -) + override val account: Account, +) : AccountScopedQuery @Stable class EventFinderFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt index 91358cdfd0..e58b596d2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt @@ -60,6 +60,16 @@ class EventWatcherSubAssembler( lastNotesOnFilter = keys.map { it.note } + // Attributed only when one account is watching. The notes here are whatever is rendered, not + // anything an account owns, so with several accounts active no single one is the honest owner — + // and splitting would re-request the same replies/zaps once per account. + // Deduped by pubkey, not by `Account`: that class uses identity equality, so two objects for + // the same logged-in user would look like two accounts and suppress attribution entirely. + val soleAccountPubKey = + keys + .mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex } + .singleOrNull() + return groupByRelayPresence(lastNotesOnFilter, latestEOSEs) .map { group -> if (group.isNotEmpty()) { @@ -67,8 +77,8 @@ class EventWatcherSubAssembler( val events = group.mapNotNull { if (it !is AddressableNote) it else null } listOfNotNull( - filterRepliesAndReactionsToNotes(events, findMinimumEOSEs(events, latestEOSEs)), - filterRepliesAndReactionsToAddresses(addressables, findMinimumEOSEs(addressables, latestEOSEs)), + filterRepliesAndReactionsToNotes(events, findMinimumEOSEs(events, latestEOSEs), soleAccountPubKey), + filterRepliesAndReactionsToAddresses(addressables, findMinimumEOSEs(addressables, latestEOSEs), soleAccountPubKey), ).flatten() } else { emptyList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index 7a172477d8..87f42c8d1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -72,6 +73,7 @@ val TextNoteKindList = listOf(TextNoteEvent.KIND) fun filterRepliesAndReactionsToAddresses( keys: List, since: SincePerRelayMap?, + accountPubKey: HexKey? = null, ): List? { if (keys.isEmpty()) return null @@ -94,6 +96,7 @@ fun filterRepliesAndReactionsToAddresses( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, + accountPubKey = accountPubKey, kinds = RepliesAndReactionsToAddressesKinds1, tags = mapOf("a" to sortedList), since = since, @@ -106,6 +109,7 @@ fun filterRepliesAndReactionsToAddresses( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, + accountPubKey = accountPubKey, kinds = PostsAndChatMessagesToAddresses, tags = mapOf("a" to sortedList), since = since, @@ -118,6 +122,7 @@ fun filterRepliesAndReactionsToAddresses( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, + accountPubKey = accountPubKey, kinds = DeletionKindList, tags = mapOf("a" to sortedList), since = since, @@ -130,6 +135,7 @@ fun filterRepliesAndReactionsToAddresses( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, + accountPubKey = accountPubKey, kinds = TextNoteKindList, tags = mapOf("q" to sortedList), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 494ee84209..3763929013 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent @@ -78,6 +79,7 @@ val RepliesAndReactionsKinds2 = fun filterRepliesAndReactionsToNotes( events: List, since: SincePerRelayMap?, + accountPubKey: HexKey? = null, ): List? { if (events.isEmpty()) return null @@ -101,6 +103,7 @@ fun filterRepliesAndReactionsToNotes( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, + accountPubKey = accountPubKey, kinds = RepliesAndReactionsKinds, tags = mapOf("e" to sortedList), since = since, @@ -113,6 +116,7 @@ fun filterRepliesAndReactionsToNotes( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, + accountPubKey = accountPubKey, kinds = RepliesAndReactionsKinds2, tags = mapOf("e" to sortedList), since = since, @@ -124,6 +128,7 @@ fun filterRepliesAndReactionsToNotes( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, + accountPubKey = accountPubKey, kinds = listOf(TextNoteEvent.KIND, CommentEvent.KIND), tags = mapOf("q" to sortedList), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt index 8e72efe9ef..b0383beda5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserCardsSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler @@ -36,8 +37,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT @Stable class UserFinderQueryState( val user: User, - val account: Account, -) + override val account: Account, +) : AccountScopedQuery @Stable class UserFinderFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt index 1710572657..1e535e8e97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt @@ -103,6 +103,18 @@ class UserOutboxFinderSubAssembler( val accounts = keys.mapTo(mutableSetOf()) { it.account } val connectedRelays = client.connectedRelaysFlow().value + // Attributed only when one account is asking. Unlike the reports sweep, the relay choice here + // is made from the *union* of every logged-in account's tiers (`pickRelaysToLoadUsers` below), + // and the users being resolved are whoever is on screen rather than anyone's follow list — so + // with several accounts active there is no single honest owner for a given filter, and + // splitting the sweep per account would re-issue the same lookups once per account. + // Deduped by pubkey, not by `Account`: that class uses identity equality, so two objects for + // the same logged-in user would look like two accounts and suppress attribution entirely. + val soleAccountPubKey = + accounts + .mapTo(mutableSetOf()) { it.userProfile().pubkeyHex } + .singleOrNull() + val perRelayKeysBoth = pickRelaysToLoadUsers( noOutboxList, @@ -118,7 +130,13 @@ class UserOutboxFinderSubAssembler( if (sortedUsers.isNotEmpty()) { RelayBasedFilter( relay = it.key, - filter = ExplainedFilter(kinds = relayListKinds, authors = sortedUsers, purpose = SubPurpose.RELAY_LISTS), + filter = + ExplainedFilter( + kinds = relayListKinds, + authors = sortedUsers, + purpose = SubPurpose.RELAY_LISTS, + accountPubKey = soleAccountPubKey, + ), ) } else { null @@ -148,7 +166,14 @@ class UserOutboxFinderSubAssembler( fallbackRelays.map { relay -> RelayBasedFilter( relay = relay, - filter = ExplainedFilter(kinds = relayListKinds, authors = sortedAbandoned, purpose = SubPurpose.RELAY_LISTS, purposeDetail = "outbox discovery for users whose relay list we lost"), + filter = + ExplainedFilter( + kinds = relayListKinds, + authors = sortedAbandoned, + purpose = SubPurpose.RELAY_LISTS, + purposeDetail = "outbox discovery for users whose relay list we lost", + accountPubKey = soleAccountPubKey, + ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt index 0e5d73a549..ba3022af91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt @@ -34,6 +34,7 @@ fun filterReportsToKeysFromTrusted( trustedAccounts: List, relay: NormalizedRelayUrl, since: Long?, + accountPubKey: HexKey? = null, ): RelayBasedFilter? { if (targets.isEmpty() || trustedAccounts.isEmpty()) return null return RelayBasedFilter( @@ -43,6 +44,7 @@ fun filterReportsToKeysFromTrusted( purpose = SubPurpose.MODERATION, kinds = ReportKindList, authors = trustedAccounts, + accountPubKey = accountPubKey, tags = mapOf("p" to targets.sorted()), since = since, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index 14ec5e3d96..488b4b1297 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -63,6 +63,7 @@ fun filterUserMetadataForKey( indexRelays: Set, cannotConnectRelays: Set, since: EOSEAccountFast, + accountPubKey: HexKey? = null, ): List { val perRelayUsers = mapOfSet { @@ -114,6 +115,7 @@ fun filterUserMetadataForKey( filter = ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, + accountPubKey = accountPubKey, kinds = UserMetadataForKeyKinds, authors = firstTimers.sorted(), ), @@ -127,6 +129,7 @@ fun filterUserMetadataForKey( filter = ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, + accountPubKey = accountPubKey, kinds = UserMetadataForKeyKinds, authors = updates.sorted(), since = minimumTime, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt index 5ab831c9c7..a833114d6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -70,6 +70,13 @@ class UserCardsSubAssembler( val accounts = keys.mapTo(mutableSetOf()) { it.account } + // Attributed only when one account is asking: the trusted-author sets below are pooled across + // accounts, so with several active none of them owns a given filter. + val soleAccountPubKey = + accounts + .mapTo(mutableSetOf()) { it.userProfile().pubkeyHex } + .singleOrNull() + val trustedAccounts: Map> = mapOfSet { accounts.forEach { account -> @@ -92,12 +99,14 @@ class UserCardsSubAssembler( val trustedAccounts = trustedUsersInThisRelay.sorted() listOfNotNull( filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( + accountPubKey = soleAccountPubKey, targets = groups.usersWithoutEose.toHexSet(), trustedAccounts = trustedAccounts, relay = relay, since = null, ), filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( + accountPubKey = soleAccountPubKey, targets = groups.usersWithEose.toHexSet(), trustedAccounts = trustedAccounts, relay = relay, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt index fd2e68532d..4371a4f822 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers import com.vitorpamplona.amethyst.commons.model.toHexSet import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState @@ -31,7 +32,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.utils.mapOfSet class UserReportsSubAssembler( client: INostrClient, @@ -65,14 +65,19 @@ class UserReportsSubAssembler( if (lastUsersOnFilter.isEmpty()) return null - val trustedAccountsPerRelay = - mapOfSet { - accounts.map { it.declaredFollowsPerOutboxRelay.value }.forEach { - add(it) - } - } + // One pass per account, never a union. "Whose follows wrote this report" is the whole point of + // the filter, so merging every logged-in account's follow list into one per-relay map both made + // the result unattributable and asked one account's follows of another account's outbox relays. + return accounts.flatMap { account -> filtersFor(account, lastUsersOnFilter) }.ifEmpty { null } + } - return trustedAccountsPerRelay + private fun filtersFor( + account: Account, + lastUsersOnFilter: Set, + ): List { + val accountPubKey = account.userProfile().pubkeyHex + + return account.declaredFollowsPerOutboxRelay.value .flatMap { (relay, trustedUsersInThisRelay) -> // this relay + accounts are where we could find reports. // we might have already loaded them, so let's separate new targets that were checked before from the others @@ -84,12 +89,14 @@ class UserReportsSubAssembler( trustedAccounts = trustedAccounts, relay = relay, since = null, + accountPubKey = accountPubKey, ), filterReportsToKeysFromTrusted( targets = groups.usersWithEose.toHexSet(), trustedAccounts = trustedAccounts, relay = relay, since = findMinimumEOSEsForUsers(groups.usersWithEose, relay), + accountPubKey = accountPubKey, ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt index 6d8f7efb23..d695cd8e7e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -96,6 +96,13 @@ class UserWatcherSubAssembler( } // assembles all index relays from all accounts + // Attributed only when one account is looking: the index relays below are pooled across every + // account and the users are whoever is on screen, so with several askers none of them owns it. + val soleAccountPubKey = + keys + .mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex } + .singleOrNull() + val indexRelays = mutableSetOf() keys.mapTo(mutableSetOf()) { it.account }.forEach { indexRelays.addAll( @@ -110,6 +117,7 @@ class UserWatcherSubAssembler( indexRelays, failureTracker.cannotConnectRelays, latestEOSEs, + soleAccountPubKey, ).ifEmpty { null } sub.updateFilters(newFilters?.groupByRelay()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt index ebbd64c4aa..abde8f87a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies.SearchPostWatcherSubAssembler import com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies.SearchUserWatcherSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -35,8 +36,9 @@ import kotlinx.coroutines.flow.MutableStateFlow @Stable class SearchQueryState( val searchQuery: MutableStateFlow, - val account: Account, -) : MutableQueryState { + override val account: Account, +) : MutableQueryState, + AccountScopedQuery { override fun flow(): Flow = searchQuery } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssembler.kt index bbce9cc824..d4e687213f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/datasource/ProfileAppRecommendationsFilterAssembler.kt @@ -23,11 +23,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.datas import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient class ProfileAppRecommendationsQueryState( - val account: Account, -) + override val account: Account, +) : AccountScopedQuery @Stable class ProfileAppRecommendationsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/datasource/ArticlesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/datasource/ArticlesFilterAssembler.kt index 5ee186f653..23b2195f26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/datasource/ArticlesFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/datasource/ArticlesFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class ArticlesQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class ArticlesFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt index 74d95bc09e..40eb063052 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/BadgesFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class BadgesQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class BadgesFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt index 02892b5a27..065964fd39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/profile/datasource/ProfileBadgesFilterAssembler.kt @@ -23,11 +23,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient class ProfileBadgesQueryState( - val account: Account, -) + override val account: Account, +) : AccountScopedQuery @Stable class ProfileBadgesFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssembler.kt index 79c1cddd0e..51d04db05e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/CalendarsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class CalendarsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class CalendarsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index 9366082074..4bd284de17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -22,14 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey // This allows multiple screen to be listening to tags, even the same tag class ChatroomQueryState( val room: ChatroomKey, - val account: Account, -) { + override val account: Account, +) : AccountScopedQuery { val listId = room.hashCode().toString() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt index cd9e4280c4..9d8f1b00b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -34,8 +35,8 @@ import kotlinx.coroutines.Job /** One screen's request to keep the user's joined Concord Channels live. */ class ConcordChannelQueryState( - val account: Account, -) + override val account: Account, +) : AccountScopedQuery /** * Keeps every joined Concord community's planes live while a Concord-bearing @@ -81,7 +82,11 @@ class ConcordChannelSubAssembler( // per-filter result cap (the "Soapbox shows 1 of 12 channels" bug). See // [ConcordSubscriptionPlanner.controlIsolatedFilters]. val all = - ConcordSubscriptionPlanner.controlIsolatedFilters(entries, since = since) { entry -> + ConcordSubscriptionPlanner.controlIsolatedFilters( + entries, + since = since, + accountPubKey = account.userProfile().pubkeyHex, + ) { entry -> account.concordSessions .sessionFor(entry.id) ?.state diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt index 2ada002093..2720cb1d77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId @@ -44,10 +45,10 @@ import kotlinx.coroutines.flow.StateFlow /** One open Concord Channel whose older history the screen wants paged in. */ class ConcordChannelHistoryQueryState( - val account: Account, + override val account: Account, val communityId: String, val channelId: String, -) +) : AccountScopedQuery /** * Mounts the on-demand **history** pager for whichever Concord Channel screen is open. The live @@ -133,6 +134,8 @@ class ConcordChannelHistorySubAssembler( filter = ExplainedFilter( purpose = SubPurpose.COMMUNITY_CHATS, + purposeDetail = "concord channel history", + entityIds = listOf(key.communityId), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), // All epoch planes at once: the relay serves them interleaved by created_at, so // one backward cursor walks across the Refounding boundaries; "exhausted" then diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzDmJoinedChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzDmJoinedChatTailFilterAssembler.kt index 0ebe1be439..d3ca4ea0ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzDmJoinedChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzDmJoinedChatTailFilterAssembler.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterGroupNotificationsToPubkey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -40,9 +41,9 @@ import kotlinx.coroutines.flow.StateFlow /** One Buzz DM channel whose recent chat is kept live. */ class BuzzDmJoinedChatTailQueryState( - val account: Account, + override val account: Account, val groupId: GroupId, -) +) : AccountScopedQuery /** * Always-on **live tail** for the recent chat of every Buzz DM channel the viewer belongs to — the DM diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt index 66cd9bdad0..9685069cb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterGroupNotificationsToPubkey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -41,9 +42,9 @@ import kotlinx.coroutines.flow.StateFlow /** One joined group whose recent chat is kept live for the Messages-list preview. */ class RelayGroupJoinedChatTailQueryState( - val account: Account, + override val account: Account, val groupId: GroupId, -) +) : AccountScopedQuery /** * Always-on **live tail** for the recent chat of every NIP-29 group the user has joined — the group diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt index 3f55679e1b..7a2dc48cf1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -33,8 +34,8 @@ import kotlinx.coroutines.Job /** One request to keep the relay-signed state of the user's joined groups live. */ class RelayGroupJoinedStateQueryState( - val account: Account, -) + override val account: Account, +) : AccountScopedQuery /** * Keeps the relay-signed **state** of every group the user has joined live and current — name, picture, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt index 3cad4be707..7b6f7bbcac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -40,9 +41,9 @@ import kotlinx.coroutines.flow.StateFlow /** One open NIP-29 group whose older history the chat screen wants paged in. */ class RelayGroupOpenChatHistoryQueryState( - val account: Account, + override val account: Account, val groupId: GroupId, -) +) : AccountScopedQuery /** * Mounts the on-demand **history** pager for whichever NIP-29 group chat screen is open. The live diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt index af33acea2e..e151b15ebf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailFilterAssembler.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -37,9 +38,9 @@ import kotlinx.coroutines.flow.StateFlow /** One open NIP-29 group whose recent chat the screen wants live. */ class RelayGroupOpenChatTailQueryState( - val account: Account, + override val account: Account, val groupId: GroupId, -) +) : AccountScopedQuery /** * Per-open-group **live tail**: the recent chat window of the *currently open* group, `#h`-scoped on diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt index 6eb149347f..ddbef10a9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -40,9 +41,9 @@ import kotlinx.coroutines.flow.StateFlow /** One open NIP-29 group whose older forum threads the Threads tab wants paged in. */ class RelayGroupOpenThreadsHistoryQueryState( - val account: Account, + override val account: Account, val groupId: GroupId, -) +) : AccountScopedQuery /** * Mounts the on-demand **history** pager for whichever NIP-29 group's Threads tab is open. The Threads diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt index 5dde33785b..cd99f96616 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class RelayGroupsDiscoveryQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class RelayGroupsDiscoveryFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt index 3fdbb97692..65d8cf66fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -31,8 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** One screen's request for the full channel directory of a single relay. */ class RelayGroupsOnRelayQueryState( val relay: NormalizedRelayUrl, - val account: Account, -) + override val account: Account, +) : AccountScopedQuery /** * Subscribes to the relay-signed directory (kinds 39000-39003) of a single relay, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 286b506641..649ab893af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -23,13 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag @Stable class ChatroomListState( - val account: Account, -) + override val account: Account, +) : AccountScopedQuery @Stable class ChatroomListFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt index 1ac72e047c..b5f19f4752 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasourc import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class CommunitiesListQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class CommunitiesListFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt index dc49d49b5e..01ccad2648 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssembler.kt @@ -22,16 +22,17 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope // This allows multiple screen to be listening to tags, even the same tag class DiscoveryQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery class DiscoveryFilterAssembler( client: INostrClient, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsFilterAssembler.kt index f33b19c848..e1fe9f9c7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasour import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class BrowseEmojiSetsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class BrowseEmojiSetsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/datasource/FollowPacksFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/datasource/FollowPacksFilterAssembler.kt index 02c80a798a..83114ea4a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/datasource/FollowPacksFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/datasource/FollowPacksFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasourc import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class FollowPacksQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class FollowPacksFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesFilterAssembler.kt index 2514e313b2..56626774d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class GitRepositoriesQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class GitRepositoriesFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt index d394b588d1..1750ec7fd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -29,8 +30,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class HashtagQueryState( val hashtag: String, val relays: Set, - val account: Account, -) { + override val account: Account, +) : AccountScopedQuery { val lowercaseHashtag = hashtag.lowercase() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/HighlightsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/HighlightsFilterAssembler.kt index 61e50a3b27..7006c1e79d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/HighlightsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/HighlightsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class HighlightsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class HighlightsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt index e76959df4b..0f3a3bd769 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomeOutboxEventsEoseManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -32,10 +33,10 @@ import kotlinx.coroutines.CoroutineScope @Stable class HomeQueryState( - val account: Account, + override val account: Account, val feedState: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class HomeFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/datasource/LiveStreamsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/datasource/LiveStreamsFilterAssembler.kt index 5252bf8c3b..306666585e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/datasource/LiveStreamsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/datasource/LiveStreamsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class LiveStreamsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class LiveStreamsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/LongsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/LongsFilterAssembler.kt index 173b0a2ed4..b57c0ef105 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/LongsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/LongsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class LongsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class LongsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssembler.kt index 90a17ec475..3918a83811 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicPlaylistsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class MusicPlaylistsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class MusicPlaylistsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssembler.kt index 7751934347..693ee0286f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/MusicTracksFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class MusicTracksQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class MusicTracksFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssembler.kt index 331a494a85..09a94d7c30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/ConnectedAppsFilterAssembler.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -32,9 +33,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient * been granted permissions in the user's ledger. */ class ConnectedAppsQueryState( - val account: Account, + override val account: Account, val authors: Set, -) +) : AccountScopedQuery /** * Live subscription for NIP-5D napplet manifests (kinds 15129/35129) while diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/NappletsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/NappletsFilterAssembler.kt index 3ac9f54b57..f31a00821e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/NappletsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/NappletsFilterAssembler.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope @@ -33,9 +34,9 @@ import kotlinx.coroutines.CoroutineScope * selection) and a scope. */ class NappletsQueryState( - val account: Account, + override val account: Account, val scope: CoroutineScope, -) +) : AccountScopedQuery /** * Subscribes to NIP-5D napplet manifests (kinds 15129/35129) while a napplet screen is open, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt index f3dd500d6c..c5d33cb800 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwa import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -53,8 +54,8 @@ import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent @Stable class NestRoomQueryState( val note: AddressableNote, - val account: Account, -) + override val account: Account, +) : AccountScopedQuery /** * Single per-room sub-assembler. Issues two [Filter]s per outbox diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt index 3ae8d18843..59842afb75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwa import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -51,8 +52,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresence @Stable class NestRoomLivenessQueryState( val note: AddressableNote, - val account: Account, -) + override val account: Account, +) : AccountScopedQuery /** * Single per-room sub-assembler for the feed thumbnail liveness diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestsFilterAssembler.kt index a05c0d0703..01e3884616 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class NestsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class NestsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/NsitesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/NsitesFilterAssembler.kt index b57fcb7523..a3661e8f9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/NsitesFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/NsitesFilterAssembler.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope @@ -33,9 +34,9 @@ import kotlinx.coroutines.CoroutineScope * scope. */ class NsitesQueryState( - val account: Account, + override val account: Account, val scope: CoroutineScope, -) +) : AccountScopedQuery /** * Subscribes to NIP-5A static-site manifests (kinds 15128/35128) while an nSite screen is open, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssembler.kt index 11afee9e15..0e6cafdcda 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class PicturesQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class PicturesFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt index efb9bdb167..f407de6b67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class PodcastEpisodesQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class PodcastEpisodesFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt index 85d6e8c995..b1df728a8d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class PodcastsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class PodcastsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/PollsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/PollsFilterAssembler.kt index a518072e43..51ef565d14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/PollsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/PollsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class PollsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class PollsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/datasource/ProductsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/datasource/ProductsFilterAssembler.kt index 8d4354d8e1..95998e4ef0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/datasource/ProductsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/datasource/ProductsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class ProductsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class ProductsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/datasource/PublicChatsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/datasource/PublicChatsFilterAssembler.kt index 0a12bd9320..fa36618731 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/datasource/PublicChatsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/datasource/PublicChatsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class PublicChatsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class PublicChatsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/ShortsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/ShortsFilterAssembler.kt index 28ddef8aff..81107e925c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/ShortsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/ShortsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class ShortsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class ShortsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SoftwareAppsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SoftwareAppsFilterAssembler.kt index ee51bbe8d2..235c34d412 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SoftwareAppsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SoftwareAppsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class SoftwareAppsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class SoftwareAppsFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt index 9cb7560137..4a25956659 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadEventLoaderSubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadFilterSubAssembler import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -32,8 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @Stable class ThreadQueryState( val eventId: HexKey, - val account: Account, -) + override val account: Account, +) : AccountScopedQuery @Stable class ThreadFilterAssembler( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt index ca5fa42e7d..bf4ab512fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssembler.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.VideoOutboxEventsFilterSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -29,10 +30,10 @@ import kotlinx.coroutines.CoroutineScope // This allows multiple screen to be listening to tags, even the same tag class VideoQueryState( - val account: Account, + override val account: Account, val feedState: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery class VideoFilterAssembler( client: INostrClient, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsFilterAssembler.kt index baab033a30..9ddb312de1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsFilterAssembler.kt @@ -23,15 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope class WorkoutsQueryState( - val account: Account, + override val account: Account, val feedStates: AccountFeedContentStates, val scope: CoroutineScope, -) +) : AccountScopedQuery @Stable class WorkoutsFilterAssembler( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 6e8dff7700..74ee3ca92b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -165,6 +166,7 @@ object ConcordSubscriptionPlanner { lastReadFor: (channelIdHex: String) -> Long, catchUpLimit: Int = 50, previewLimit: Int = 10, + accountPubKey: HexKey? = null, ): List { val authorsByChannel = LinkedHashMap>() val relaysByChannel = HashMap>() @@ -180,6 +182,8 @@ object ConcordSubscriptionPlanner { ExplainedFilter( purpose = SubPurpose.COMMUNITY_CHATS, purposeDetail = "concord community planes", + entityIds = listOf(entry.id), + accountPubKey = accountPubKey, kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = authors.toList(), // -1 so the last-read message (created_at == lastRead) is itself returned: @@ -191,6 +195,8 @@ object ConcordSubscriptionPlanner { ExplainedFilter( purpose = SubPurpose.COMMUNITY_CHATS, purposeDetail = "concord community planes", + entityIds = listOf(entry.id), + accountPubKey = accountPubKey, kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = authors.toList(), limit = previewLimit, @@ -232,6 +238,7 @@ object ConcordSubscriptionPlanner { fun controlIsolatedFilters( entries: List, since: SincePerRelayMap?, + accountPubKey: HexKey? = null, stateOf: (ConcordCommunityListEntry) -> ConcordCommunityState?, ): List { val controlSubs = controlPlaneSubs(entries) @@ -243,7 +250,7 @@ object ConcordSubscriptionPlanner { otherSubs += channelPlaneSubs(entry, state) } - return relayBasedFilters(controlSubs, since).orEmpty() + relayBasedFilters(otherSubs, since).orEmpty() + return relayBasedFilters(controlSubs, since, accountPubKey).orEmpty() + relayBasedFilters(otherSubs, since, accountPubKey).orEmpty() } /** @@ -258,10 +265,18 @@ object ConcordSubscriptionPlanner { fun relayBasedFilters( subs: List, since: SincePerRelayMap?, + accountPubKey: HexKey? = null, ): List? { val authorsByRelay = HashMap>() + // Which communities each relay is being asked about. Collapsing planes into one filter per + // relay is what makes the subscription screen unable to name them otherwise — the plane + // pubkeys are stream keys, not something a user can recognise. + val communitiesByRelay = HashMap>() for (sub in subs) { - for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) + for (relay in sub.relays) { + authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) + sub.channelId?.let { communitiesByRelay.getOrPut(relay) { HashSet() }.add(it.communityId) } + } } if (authorsByRelay.isEmpty()) return null @@ -271,6 +286,9 @@ object ConcordSubscriptionPlanner { filter = ExplainedFilter( purpose = SubPurpose.COMMUNITY_CHATS, + purposeDetail = "concord live planes", + entityIds = communitiesByRelay[relay]?.sorted(), + accountPubKey = accountPubKey, // Stored plane wraps (1059) plus ephemeral ones (21059) — the latter carry the // live-only typing heartbeats a relay broadcasts but never stores. kinds = listOf(ConcordStreamEnvelope.KIND_WRAP, ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuMintDirectoryFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuMintDirectoryFilterAssembler.kt index d40853b271..836f4ad0b9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuMintDirectoryFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuMintDirectoryFilterAssembler.kt @@ -85,7 +85,7 @@ private class CashuMintDirectorySubAssembler( val mintAnnouncements = ExplainedFilter( - purpose = SubPurpose.WALLET, + purpose = SubPurpose.MINT_DIRECTORY, purposeDetail = "mint directory", kinds = listOf(CashuMintEvent.KIND), ) @@ -95,7 +95,7 @@ private class CashuMintDirectorySubAssembler( // recommendations here. val cashuRecommendations = ExplainedFilter( - purpose = SubPurpose.WALLET, + purpose = SubPurpose.MINT_DIRECTORY, purposeDetail = "mint directory", kinds = listOf(MintRecommendationEvent.KIND), tags = mapOf("k" to listOf(CashuMintEvent.KIND.toString())), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt index f86a8b9fe0..1831b0353e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt @@ -95,16 +95,31 @@ private class CashuWalletSubAssembler( ) : SingleSubEoseManager(client, allKeys, invalidateAfterEose = true) { override fun distinct(key: CashuWalletQueryState): Any = key.pubkey + /** + * One set of filters **per account**, never a merged one. + * + * [SingleSubEoseManager] hands over every distinct key, so with two wallets logged in this used + * to take `keys.first().pubkey` while pooling *both* accounts' relays — the second account's + * wallet was never subscribed, and its inbox relays were queried for the first account's + * nutzaps. Keeping each account's pubkey with its own relay sets is also what lets the + * subscription screen attribute these filters instead of piling them under "not attributed". + */ override fun updateFilter( keys: List, since: SincePerRelayMap?, ): List? { if (keys.isEmpty()) return null + return keys.flatMap { filtersFor(it, since) }.ifEmpty { null } + } - val pubkey = keys.first().pubkey - val ownEventRelays = keys.flatMap { it.ownEventRelays }.toSet() - val inboxRelays = keys.flatMap { it.inboxRelays }.toSet() - if (ownEventRelays.isEmpty() && inboxRelays.isEmpty()) return null + private fun filtersFor( + key: CashuWalletQueryState, + since: SincePerRelayMap?, + ): List { + val pubkey = key.pubkey + val ownEventRelays = key.ownEventRelays + val inboxRelays = key.inboxRelays + if (ownEventRelays.isEmpty() && inboxRelays.isEmpty()) return emptyList() val ownedFilter = ExplainedFilter( @@ -123,13 +138,15 @@ private class CashuWalletSubAssembler( MintRecommendationEvent.KIND, ), authors = listOf(pubkey), + accountPubKey = pubkey, ) val inboundNutzapsFilter = ExplainedFilter( - purpose = SubPurpose.WALLET, + purpose = SubPurpose.NUTZAP_INBOX, kinds = listOf(NutzapEvent.KIND), tags = mapOf("p" to listOf(pubkey)), + accountPubKey = pubkey, ) // Own NIP-60 events are read from the user's outbox; inbound nutzaps diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt index 4bdfb7623e..3c654bf55b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt @@ -40,6 +40,7 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( trustedAccounts: List, relay: NormalizedRelayUrl, since: Long?, + accountPubKey: HexKey? = null, ): RelayBasedFilter? { if (targets.isEmpty() || trustedAccounts.isEmpty()) return null return RelayBasedFilter( @@ -47,6 +48,7 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( filter = ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, + accountPubKey = accountPubKey, kinds = ContactCardKindList, authors = trustedAccounts, // kind:30382 addresses the target user in the d-tag @@ -72,6 +74,8 @@ fun filterContactCardsByAuthorInTheRelay( filter = ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, + // This variant fetches an account's OWN contact card, so the author is the owner. + accountPubKey = author, kinds = ContactCardKindList, authors = listOf(author), limit = limit, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt index c81db0984c..5ba35efcdc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt @@ -37,6 +37,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl data class MetadataQueryState( val pubkeys: Set, val indexRelays: Set, + /** Which account is looking. Null when the caller has no account context (kept out of attribution). */ + val accountPubKey: HexKey? = null, ) /** @@ -69,6 +71,11 @@ class MetadataFilterAssembler( if (allPubkeys.isEmpty() || allRelays.isEmpty()) return null + // Attributed only when one account is looking. These pubkeys are whoever is rendered rather + // than anything an account owns, and several query states are batched into one filter, so with + // more than one asker there is no single honest owner. + val soleAccountPubKey = keys.mapNotNullTo(mutableSetOf()) { it.accountPubKey }.singleOrNull() + val pubkeyList = allPubkeys.toList() // Create filter for metadata (Kind 0) @@ -78,6 +85,7 @@ class MetadataFilterAssembler( authors = pubkeyList, limit = pubkeyList.size, purpose = SubPurpose.PROFILE_METADATA, + accountPubKey = soleAccountPubKey, ) // Apply since times per relay diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt index 436fac0fd2..65d9821165 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter /** @@ -66,12 +67,18 @@ class ExplainedFilter( /** Free-form extra context for [SubPurpose.OTHER] or for narrowing a bucket while debugging. */ val purposeDetail: String? = null, /** - * The thing this filter serves — a community, group, channel or mint id. Deliberately an **id, - * not a name**: names change, are not always loaded when the filter is built, and would pin a - * stale copy into a long-lived subscription. The UI resolves it against `LocalCache` at render - * time, so it always shows the current name and shows nothing gracefully when unknown. + * The things this filter serves — community, group, channel or mint ids. + * + * A **list**, because filters are routinely batched: relay-group state is fetched with one `#d` + * filter per host relay carrying every joined group on it, so a single filter can legitimately + * serve a dozen chats. Modelling one id would have forced either a wrong answer ("All") or a + * filter-per-chat, which is far more REQs than the relays want. + * + * Ids, not names: names change, are often not loaded when the filter is built, and would pin a + * stale copy into a long-lived subscription. The UI resolves them against `LocalCache` at render + * time, so it shows the current name and degrades to a short id when unknown. */ - val entityId: HexKey? = null, + val entityIds: List? = null, /** * Which logged-in account asked for this. Several accounts are commonly active at once and they * do not share relay sets, so "why is this relay connected" is only answerable per account — @@ -93,7 +100,7 @@ class ExplainedFilter( until: Long?, limit: Int?, search: String?, - ) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityId, accountPubKey) + ) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityIds, accountPubKey) companion object { /** Tags [filter] with a [purpose], preserving every protocol field. */ @@ -101,7 +108,7 @@ class ExplainedFilter( filter: Filter, purpose: SubPurpose, detail: String? = null, - entityId: HexKey? = null, + entityIds: List? = null, accountPubKey: HexKey? = null, ) = ExplainedFilter( filter.ids, @@ -115,7 +122,7 @@ class ExplainedFilter( filter.search, purpose, detail, - entityId, + entityIds, accountPubKey, ) } @@ -135,8 +142,14 @@ fun Collection.purposes(): Set = mapNotNullTo(mutableSetOf() * whose. Entries with no entity collapse to a single row for that purpose. */ fun Collection.purposeEntities(): Set = - mapNotNullTo(mutableSetOf()) { filter -> - (filter as? ExplainedFilter)?.let { PurposeEntity(it.purpose, it.entityId, it.accountPubKey, it.purposeDetail) } + flatMapTo(mutableSetOf()) { filter -> + val explained = filter as? ExplainedFilter ?: return@flatMapTo emptyList() + val ids = explained.entityIds + if (ids.isNullOrEmpty()) { + listOf(PurposeEntity(explained.purpose, null, explained.accountPubKey, explained.purposeDetail)) + } else { + ids.map { PurposeEntity(explained.purpose, it, explained.accountPubKey, explained.purposeDetail) } + } } /** A single "this relay is doing X, for Y, on behalf of account Z" fact. Ids only; names resolve in the UI. */ @@ -146,3 +159,23 @@ data class PurposeEntity( val accountPubKey: HexKey? = null, val detail: String? = null, ) + +/** + * Stamps [accountPubKey] onto every tagged filter that does not already name one. + * + * Applied once where a subscription manager knows its account, rather than threading a pubkey + * parameter through the ~200 filter builders below it. Filters that already name an account are left + * alone, so a builder with better knowledge always wins. + */ +fun List.attributedTo(accountPubKey: HexKey): List = + map { relayFilter -> + val filter = relayFilter.filter + if (filter is ExplainedFilter && filter.accountPubKey == null) { + RelayBasedFilter( + relay = relayFilter.relay, + filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, accountPubKey), + ) + } else { + relayFilter + } + } From 0ad4726d41dd7b96b106a7b67f20de2fcac5c6ba Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 21:00:18 -0400 Subject: [PATCH 012/227] feat(relays): name the things each subscription is for, not just their ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entity rows resolved to a short hex whenever LocalCache could not place the id, which covered Marmot groups, Concord communities and geohash cells — the three that most needed naming. Each now resolves through the path its own subsystem uses: Marmot reads the per-account chatroom StateFlows and its encrypted Blossom avatar, Concord reads the folded Control Plane keyed on the session revision, and geohash cells go through the app's reverse-geocode cache so a cell reads as a place. All three route to their screen on tap. Relay groups pivot on the host relay, expandable to the groups it carries: a group is keyed by (id, host relay) and one community relay routinely hosts many, so the flat list repeated one hostname seven times. Co-Authored-By: Claude Opus 5 (1M context) --- .../ActiveSubscriptionsScreen.kt | 615 +++++++++++++++--- .../ActiveSubscriptionsViewModel.kt | 23 +- 2 files changed, 531 insertions(+), 107 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt index 7381020bd6..130f83f3a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt @@ -20,15 +20,27 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.HorizontalDivider +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -39,147 +51,550 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource 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 androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurposeGroup import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter +import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.warningColor import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlin.math.roundToInt /** * Explains why the app is holding the number of subscriptions it currently holds. * - * Pivoted on purpose rather than relay, because the relay-shaped view hides exactly the thing worth - * finding: a probe holding hundreds of filters across hundreds of relays looks like one ordinary - * entry repeated on every row, while here it is a single line that dwarfs everything under it. + * Pivoted on purpose rather than relay: the relay-shaped view hides exactly the thing worth finding, + * because a probe holding hundreds of filters across hundreds of relays looks like one ordinary row + * repeated hundreds of times. Here it is one card whose share bar runs the full width while + * everything under it is a stub. * * Account is the outer grouping — several are normally logged in, they do not share relay sets, and * a mixed total cannot be acted on. */ @Composable -fun ActiveSubscriptionsScreen(viewModel: ActiveSubscriptionsViewModel = viewModel()) { +fun ActiveSubscriptionsScreen( + accountViewModel: AccountViewModel, + nav: INav, + viewModel: ActiveSubscriptionsViewModel = viewModel(), +) { LaunchedEffect(Unit) { viewModel.startPolling() } val state by viewModel.state.collectAsStateWithLifecycle() - LazyColumn(Modifier.fillMaxWidth()) { - item { - Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Scaffold( + topBar = { TopBarWithBackButton(stringRes(R.string.active_subs_title), nav) }, + ) { pad -> + LazyColumn( + modifier = Modifier.padding(pad).fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + item { TotalsHeader(state) } + + state.accounts.forEach { account -> + item(key = "acct-${account.accountPubKey ?: "none"}") { AccountHeader(account) } + + items(account.purposes, key = { "${account.accountPubKey}-${it.purpose.name}" }) { purpose -> + PurposeCard(purpose, state.totalFilters, accountViewModel, nav) + } + } + } + } +} + +@Composable +private fun TotalsHeader(state: ActiveSubscriptionsState) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(16.dp)) { + Text( + text = pluralStringResource(R.plurals.active_subs_filters, state.totalFilters, state.totalFilters), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = pluralStringResource(R.plurals.active_subs_relays, state.totalRelays, state.totalRelays), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (state.untaggedFilters > 0) { + // Stated rather than hidden: an untagged filter is a subscription this screen cannot + // explain, and a total that claims to be fully attributed would defeat the point. + Spacer(Modifier.height(6.dp)) Text( - countsLine(state.totalFilters, state.totalRelays), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + text = pluralStringResource(R.plurals.active_subs_untagged, state.untaggedFilters, state.untaggedFilters), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, ) - if (state.untaggedFilters > 0) { - // Stated rather than hidden: an untagged filter is a subscription this screen - // cannot explain, and pretending the total is fully attributed would be a lie. + } + } + } +} + +@Composable +private fun AccountHeader(account: SubscriptionAccountRow) { + val name = account.accountPubKey?.let { displayNameOf(it) } ?: stringRes(R.string.active_subs_unattributed) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, top = 10.dp, bottom = 2.dp), + ) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = pluralStringResource(R.plurals.active_subs_relays, account.relays.size, account.relays.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } +} + +@Composable +private fun PurposeCard( + row: SubscriptionPurposeRow, + totalFilters: Int, + accountViewModel: AccountViewModel, + nav: INav, +) { + var expanded by rememberSaveable(row.purpose) { mutableStateOf(false) } + val accent = colorOf(row.purpose.group) + + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = + Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .animateContentSize(), + ) { + Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(10.dp).clip(CircleShape).background(accent)) + Spacer(Modifier.width(10.dp)) + Text( + text = stringRes(SubPurposeLabels.labelOf(row.purpose)), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = pluralStringResource(R.plurals.active_subs_filters, row.filterCount, row.filterCount), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + + Spacer(Modifier.height(10.dp)) + + // Share of ALL subscriptions, not of the biggest one: measuring against the biggest + // makes one bar permanently full and says nothing about how much of the app's traffic a + // purpose actually accounts for. + val share = if (totalFilters > 0) row.filterCount / totalFilters.toFloat() else 0f + ShareBar(fraction = share, color = accent) + + Spacer(Modifier.height(8.dp)) + + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = pluralStringResource(R.plurals.active_subs_relays, row.relays.size, row.relays.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.weight(1f), + ) + Text( + text = stringRes(R.string.active_subs_share, (share * 100).roundToInt()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + + if (expanded) { + SubPurposeLabels.explainerOf(row.purpose)?.let { + Spacer(Modifier.height(10.dp)) Text( - pluralStringResource(R.plurals.active_subs_untagged, state.untaggedFilters, state.untaggedFilters), + text = stringRes(it), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } - HorizontalDivider() - } - items(state.accounts, key = { it.accountPubKey ?: "unattributed" }) { account -> - AccountSection(account) - HorizontalDivider() - } - } -} + // Relay-hosted purposes pivot on the relay instead of listing every entity flat: a + // NIP-29 group is keyed by (id, host relay), and one community relay routinely hosts + // many of them — measured here, 13 groups across 5 relays, with one hostname repeated + // seven times. Grouping folds that repetition away and answers the question the screen + // exists for: *this* relay is connected because of *these* groups. + if (row.purpose == SubPurpose.RELAY_GROUPS) { + Spacer(Modifier.height(4.dp)) + RelayGroupedEntities(row.entities, accountViewModel, nav) + } else { + // Community Chats drops its unnamed row: Concord filters that name no community + // are the plane-level machinery (control, guestbook, rekey), and rendering them as + // a nameless "All" alongside real communities read as an unexplained extra rather + // than as detail. The card's own filter count still includes them, so nothing is + // lost from the totals — only from the per-entity list. + val shown = + if (row.purpose == SubPurpose.COMMUNITY_CHATS) { + row.entities.filter { it.entityId != null } + } else { + row.entities + } -@Composable -private fun AccountSection(account: SubscriptionAccountRow) { - val name = account.accountPubKey?.let { displayNameOf(it) } ?: stringRes(R.string.active_subs_unattributed) - - Column(Modifier.padding(vertical = 4.dp)) { - Text( - text = name, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), - ) - account.purposes.forEach { PurposeSection(it) } - } -} - -@Composable -private fun PurposeSection(purposeRow: SubscriptionPurposeRow) { - var expanded by rememberSaveable(purposeRow.purpose) { mutableStateOf(false) } - - Column( - Modifier - .fillMaxWidth() - .clickable { expanded = !expanded } - .padding(horizontal = 16.dp, vertical = 6.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text( - text = stringRes(SubPurposeLabels.labelOf(purposeRow.purpose)), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - Text( - text = countsLine(purposeRow.filterCount, purposeRow.relays.size), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (expanded) { - SubPurposeLabels.explainerOf(purposeRow.purpose)?.let { - Text( - text = stringRes(it), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp, bottom = 2.dp), - ) - } - purposeRow.entities.forEach { entity -> - val label = - entity.entityId?.let { displayNameOf(it) } - ?: entity.detail - ?: stringRes(R.string.active_subs_no_entity) - Text( - text = stringRes(R.string.active_subs_pair, label, pluralStringResource(R.plurals.active_subs_relays, entity.relays.size, entity.relays.size)), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 12.dp, top = 2.dp), - ) + shown.forEach { entity -> + Spacer(Modifier.height(12.dp)) + EntityBlock(entity, accountViewModel, nav) + } + } } } } } -/** "N filters · M relays", each noun pluralised on its own count. */ +/** The entity's name, coloured as a link only when tapping it actually goes somewhere. */ @Composable -private fun countsLine( - filters: Int, - relays: Int, -): String = - stringRes( - R.string.active_subs_pair, - pluralStringResource(R.plurals.active_subs_filters, filters, filters), - pluralStringResource(R.plurals.active_subs_relays, relays, relays), +private fun EntityLabelText( + text: String, + route: Route?, +) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (route != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) +} /** - * Resolves an id to whatever name is loaded right now, falling back to a short id. + * One row per host relay, expandable to the groups it carries. * - * Deliberately at render time rather than baked into the filter: names arrive after the subscription - * that needed them, so a name captured at filter-construction would usually be missing and would go - * stale when the user renames. + * Collapsed by default because the relay is the thing that costs a connection — the question "why am + * I talking to this host" is answered by the header alone, and the group list is the follow-up only + * some people want. The count sits on the header so a busy relay is obvious without opening it. */ @Composable -private fun displayNameOf(id: HexKey): String { - val cached = - remember(id) { - LocalCache.getUserIfExists(id)?.toBestDisplayName() - ?: LocalCache.getNoteIfExists(id)?.event?.let { LocalCache.getUserIfExists(it.pubKey)?.toBestDisplayName() } +private fun RelayGroupedEntities( + entities: List, + accountViewModel: AccountViewModel, + nav: INav, +) { + // An entity can name several relays in principle; each pairing is its own row under that relay. + val byRelay = + remember(entities) { + entities + .flatMap { entity -> entity.relays.map { relay -> relay to entity } } + .groupBy({ it.first }, { it.second }) + .toList() + .sortedWith(compareByDescending>> { it.second.size }.thenBy { it.first.url }) } - return cached ?: id.take(8) + + byRelay.forEach { (relay, hosted) -> + var open by rememberSaveable(relay.url) { mutableStateOf(false) } + + Spacer(Modifier.height(10.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable { open = !open }, + ) { + RelayLine(relay, accountViewModel, modifier = Modifier.weight(1f)) + Text( + text = pluralStringResource(R.plurals.active_subs_groups, hosted.size, hosted.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + + if (open) { + hosted.forEach { entity -> + Spacer(Modifier.height(6.dp)) + // Indented, and without the relay repeated — it is the header directly above. + Column(Modifier.padding(start = 28.dp)) { + EntityBlock(entity, accountViewModel, nav, showRelays = false) + } + } + } + } } + +@Composable +private fun ShareBar( + fraction: Float, + color: Color, +) { + Box( + Modifier + .fillMaxWidth() + .height(6.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.12f)), + ) { + Box( + Modifier + .fillMaxWidth(fraction.coerceIn(0.02f, 1f)) + .height(6.dp) + .clip(CircleShape) + .background(color), + ) + } +} + +/** One colour per family, so the eye groups Messages/Account/Feeds without reading labels. */ +@Composable +private fun colorOf(group: SubPurposeGroup): Color = + when (group) { + SubPurposeGroup.MESSAGES -> MaterialTheme.colorScheme.primary + SubPurposeGroup.ACCOUNT -> MaterialTheme.colorScheme.allGoodColor + SubPurposeGroup.FEEDS -> MaterialTheme.colorScheme.tertiary + SubPurposeGroup.CURRENT_SCREEN -> MaterialTheme.colorScheme.warningColor + SubPurposeGroup.OTHER -> MaterialTheme.colorScheme.placeholderText + } + +@Composable +private fun EntityBlock( + entity: SubscriptionEntityRow, + accountViewModel: AccountViewModel, + nav: INav, + showRelays: Boolean = true, +) { + // Marmot groups are resolved first and separately: their name and avatar live in per-account + // StateFlows (and the avatar is an encrypted Blossom blob needing a cipher registration), so + // they cannot come from the plain LocalCache lookup that serves every other entity type. + val marmot = entity.entityId?.let { id -> rememberMarmotEntity(id, accountViewModel) } + val concord = entity.entityId?.let { id -> rememberConcordEntity(id, accountViewModel) } + val resolved = marmot ?: concord ?: entity.entityId?.let { id -> remember(id, entity.relays) { resolveEntity(id, entity.relays) } } + // Deliberately not falling back to `entity.detail`: that field is a developer breadcrumb set in + // `commons`, where Android string resources do not exist, so it is hardcoded English and could + // never be translated. A localized "no entity" line is better than an untranslatable one. + val label = resolved?.name ?: stringRes(R.string.active_subs_no_entity) + + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .then( + // Only tappable when we know where it goes; a dead tap target is worse than none. + resolved?.route?.let { route -> Modifier.clickable { nav.nav(route) } } ?: Modifier, + ).padding(vertical = 2.dp), + ) { + if (resolved?.picture != null) { + RobohashFallbackAsyncImage( + robot = entity.entityId ?: label, + model = resolved.picture, + contentDescription = label, + modifier = Modifier.size(24.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = false, + ) + Spacer(Modifier.width(8.dp)) + } + // A geohash cell reads as a place everywhere else in the app, so it does here too. Same + // helper the note headers and the geohash screen use, which means the same reverse-geocode + // cache and the same graceful fallback to the raw cell on devices with no Geocoder backend. + val cell = (resolved?.route as? Route.GeohashChat)?.geohash + if (cell != null) { + LoadCityName( + geohashStr = cell, + onLoading = { EntityLabelText(label, resolved.route) }, + ) { city -> + EntityLabelText("#" + city, resolved.route) + } + } else { + EntityLabelText(label, resolved?.route) + } + } + if (showRelays) { + entity.relays.forEach { relay -> RelayLine(relay, accountViewModel) } + } + } +} + +/** A relay with its real icon and the app's own relay-name colour, not a bare URL string. */ +@Composable +private fun RelayLine( + relay: NormalizedRelayUrl, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val info by loadRelayInfo(relay) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(top = 6.dp, start = 2.dp), + ) { + // Plain favicon, not RenderRelayIcon: that one overlays a ping-count badge which, at this + // size, covers the icon entirely and reads as a random number next to every relay. + RobohashFallbackAsyncImage( + robot = relay.displayUrl(), + model = info.icon, + contentDescription = relay.displayUrl(), + colorFilter = RelayIconFilter, + modifier = Modifier.size(20.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = false, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = relay.displayUrl(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * What an entity id resolves to right now: a name, a picture, and where tapping it goes. + * + * Resolved at render time rather than baked into the filter — names and pictures arrive *after* the + * subscription that needed them, so anything captured at filter-construction would usually be empty + * and would go stale on rename. + * + * The relays matter: a NIP-29 group is keyed by [GroupId] (id **and** host relay), so the id alone + * cannot find it. Those are exactly the rows that used to render as bare hex. + */ +private class ResolvedEntity( + val name: String, + val picture: String?, + val route: Route?, +) + +private fun resolveEntity( + id: HexKey, + relays: List, +): ResolvedEntity { + relays.forEach { relay -> + LocalCache.getRelayGroupChannelIfExists(GroupId(id, relay))?.let { + return ResolvedEntity(it.toBestDisplayName(), it.profilePicture(), Route.RelayGroup(id, relay.url)) + } + } + LocalCache.getPublicChatChannelIfExists(id)?.let { + return ResolvedEntity(it.toBestDisplayName(), it.profilePicture(), Route.PublicChatChannel(id)) + } + // Geohash cells: the id IS the cell, and it renders as "#cell" everywhere else in the app. + LocalCache.getGeohashChannelIfExists(id)?.let { + return ResolvedEntity(it.toBestDisplayName(), null, Route.GeohashChat(id)) + } + LocalCache.getUserIfExists(id)?.let { + return ResolvedEntity(it.toBestDisplayName(), it.profilePicture(), Route.Profile(id)) + } + // Known-but-unresolvable (a Concord community, a mint) still shows something stable rather than + // disappearing — a short id is honest about what we have. + return ResolvedEntity(id.take(8), null, null) +} + +/** + * A Marmot (MLS) group, or null when [id] names no group this account knows. + * + * Kept apart from [resolveEntity] because none of it is a plain cache read: the name and avatar are + * `StateFlow`s that fill in after the subscription starts, and the avatar is an encrypted Blossom + * blob whose decryption cipher `rememberMarmotGroupIconUrl` registers as a side effect. Looking the + * room up with `rooms.get` rather than `getOrCreateGroup` keeps an unknown id from minting an empty + * room just because a diagnostics screen asked about it. + */ +@Composable +private fun rememberMarmotEntity( + id: HexKey, + accountViewModel: AccountViewModel, +): ResolvedEntity? { + val chatroom = + remember(id) { + accountViewModel.account.marmotGroupList.rooms + .get(id) + } ?: return null + + val displayName by chatroom.displayName.collectAsStateWithLifecycle() + val image by chatroom.image.collectAsStateWithLifecycle() + val relays by chatroom.relays.collectAsStateWithLifecycle() + val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle() + + // Same name/icon precedence the chat-rooms list uses, so a group reads identically in both places. + val name = displayName?.takeIf { it.isNotBlank() } ?: stringRes(R.string.marmot_group_fallback_name, id.take(8)) + val picture = + if (image != null) { + rememberMarmotGroupIconUrl(image, accountViewModel, adminPubkeys) + } else { + loadMarmotRelayIcon(relays) + } + + return ResolvedEntity(name, picture, Route.MarmotGroupChat(id)) +} + +/** + * A Concord community, or null when [id] names none this account has folded. + * + * Also not a cache read: the name and icon come from the folded Control Plane, and a fold is + * announced by `concordSessions.revision` rather than by the session's own state, so the read has to + * be keyed on that counter or the row keeps whatever it saw first. The icon is a CORD-02 + * [ImagePointer] — usually an encrypted blob — so it goes through `rememberConcordImageModel` rather + * than being handed to Coil as a URL. + * + * Falls back to the joined-list entry's name, which is present before the first fold completes, and + * returns null when neither knows the community so the caller can keep looking. + */ +@Composable +private fun rememberConcordEntity( + id: HexKey, + accountViewModel: AccountViewModel, +): ResolvedEntity? { + val account = accountViewModel.account + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle() + + val metadata = + remember(id, revision) { + account.concordSessions + .sessionFor(id) + ?.state + ?.value + ?.metadata + } + val fallbackName = remember(communities, id) { communities.firstOrNull { it.id == id }?.name?.ifBlank { null } } + + // Resolved unconditionally: bailing out early would make the number of composable calls depend on + // whether the community happens to be known yet, which changes as folds land. + val model = rememberConcordImageModel(metadata?.icon, accountViewModel) + val name = metadata?.name?.takeIf { it.isNotBlank() } ?: fallbackName + + return name?.let { ResolvedEntity(it, model, Route.ConcordServer(id)) } +} + +@Composable +private fun displayNameOf(id: HexKey): String = remember(id) { resolveEntity(id, emptyList()).name } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt index 24f04cf36f..889cd6cb08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt @@ -85,7 +85,10 @@ data class ActiveSubscriptionsState( val totalRelays: Int = 0, /** Filters in flight that carry no purpose — assemblers not yet migrated. Honesty, not a bug. */ val untaggedFilters: Int = 0, -) +) { + /** The largest purpose, so every card can draw its share against a common scale. */ + val busiestPurposeFilters: Int = accounts.flatMap { it.purposes }.maxOfOrNull { it.filterCount } ?: 0 +} class ActiveSubscriptionsViewModel : ViewModel() { private val _state = MutableStateFlow(ActiveSubscriptionsState()) @@ -121,12 +124,18 @@ class ActiveSubscriptionsViewModel : ViewModel() { return@forEach } allRelays.add(relay) - byAccount - .getOrPut(explained.accountPubKey) { mutableMapOf() } - .getOrPut(explained.purpose) { mutableMapOf() } - .getOrPut(explained.entityId) { mutableListOf() } - .add(relay) - detailOf[explained.purpose to explained.entityId] = explained.purposeDetail + // A batched filter serves several entities at once — relay-group state is one #d + // filter per host relay carrying every joined group on it — so it contributes a + // row to each of them rather than collapsing to "All". + val entities: List = explained.entityIds?.takeIf { it.isNotEmpty() } ?: listOf(null) + entities.forEach { entityId -> + byAccount + .getOrPut(explained.accountPubKey) { mutableMapOf() } + .getOrPut(explained.purpose) { mutableMapOf() } + .getOrPut(entityId) { mutableListOf() } + .add(relay) + detailOf[explained.purpose to entityId] = explained.purposeDetail + } } } From 5f6d09425dbcbb57635e7ab7b91d0bce7dfc512e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 21:00:29 -0400 Subject: [PATCH 013/227] feat(notifications): open the subscriptions screen from the ongoing notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping the always-on notification landed on whatever tab was last open, which does not answer the question that notification raises. It now deep-links to Active Relay Subscriptions via an `activesubs` route, and the screen gets its own entry in All Settings. The expanded breakdown also held its last value across reconnects. It is derived from the *connected* relays, so a drop to zero — the "connecting" state — emptied it and collapsed the expanded view to a single line exactly when someone was most likely reading it. What each connection is for does not change while it re-establishes. Co-Authored-By: Claude Opus 5 (1M context) --- .../notifications/NotificationRelayService.kt | 23 +++++++++++++++++-- .../vitorpamplona/amethyst/ui/MainActivity.kt | 10 ++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 +- .../settings/SettingsCatalogBuilder.kt | 1 + 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index 28cd04414c..3411379e4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -36,6 +36,7 @@ import android.os.SystemClock import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat +import androidx.core.net.toUri import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R @@ -79,6 +80,10 @@ class NotificationRelayService : Service() { companion object { private const val TAG = "NotificationRelayService" private const val CHANNEL_ID = "notification_relay_service" + + /** Parsed back into `Route.ActiveSubscriptions` by `MainActivity.uriToRoute`. */ + private const val ACTIVE_SUBSCRIPTIONS_URI = "activesubs" + private const val NOTIFICATION_ID = 9832 private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE" @@ -141,6 +146,9 @@ class NotificationRelayService : Service() { private var relayServiceCollectorJob: Job? = null private var connectedRelayCount = 0 + /** Last non-empty per-job breakdown, kept so a reconnect does not blank the expanded view. */ + private var lastBreakdown: List = emptyList() + override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { @@ -336,9 +344,12 @@ class NotificationRelayService : Service() { pluralStringRes(this, R.plurals.always_on_notif_connected, connectedRelays, connectedRelays) } + // Tapping goes to the screen that answers the question the notification raises — "why is it + // connected to N relays" — rather than to whatever tab was last open. val openAppIntent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + data = ACTIVE_SUBSCRIPTIONS_URI.toUri() } val pendingIntent = PendingIntent.getActivity( @@ -351,8 +362,16 @@ class NotificationRelayService : Service() { // Expanded only. The collapsed line stays the bare count it has always been — that is all // most people want from an ongoing notification — and the per-job breakdown appears solely // when someone deliberately expands it to ask why the phone is talking to N relays. - // Skipped entirely when nothing is attributed yet, so we never render an empty section. - val breakdown = RelayPurposeSummary.lines(this).takeIf { it.isNotEmpty() } + // + // Held across reconnects rather than recomputed blindly: the breakdown is derived from the + // *connected* relays, so a drop to zero (the "connecting…" state) would otherwise empty it and + // the expanded view would collapse to a single line exactly when someone is most likely + // looking at it. What each connection is *for* does not change while it is re-establishing, + // so the last known answer is still the right one; only the count above it goes stale, and + // that count is already labelled "connecting". + val fresh = RelayPurposeSummary.lines(this) + if (fresh.isNotEmpty()) lastBreakdown = fresh + val breakdown = fresh.ifEmpty { lastBreakdown }.takeIf { it.isNotEmpty() } return NotificationCompat .Builder(this, CHANNEL_ID) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 2f2ac4eba9..0af898dac4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -203,6 +203,13 @@ fun isWalletConnectRoute(uri: String) = uri.startsWith("dlnwc?value=") || uri.st fun isMarmotGroupRoute(uri: String) = uri.startsWith("marmot:") +/** + * Tapping the always-on service notification. That notification's whole subject is the relay + * connections it is holding open, so it lands on the screen that explains them rather than on + * whatever tab happened to be last open. + */ +fun isActiveSubscriptionsRoute(uri: String) = uri.startsWith("activesubs", true) || uri.startsWith("nostr:activesubs", true) + private val MARMOT_HEX = Regex("^[0-9a-fA-F]+$") fun uriToRoute( @@ -213,6 +220,9 @@ fun uriToRoute( val scrollTo = runCatching { java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).findParameterValue("scrollTo") }.getOrNull() return Route.Notification(scrollToEventId = scrollTo) } + if (isActiveSubscriptionsRoute(uri)) { + return Route.ActiveSubscriptions + } if (isHashtagRoute(uri)) { return Route.Hashtag(uri.removePrefix(NOSTR_URI_PREFIX).removePrefix("hashtag?id=").lowercase()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 41ca6d47ae..723466368e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -592,7 +592,7 @@ fun BuildNavigation( composableFromEndArgs { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } - composableFromEndArgs { ActiveSubscriptionsScreen() } + composableFromEndArgs { ActiveSubscriptionsScreen(accountViewModel, nav) } composableFromEnd { EventSyncScreen(accountViewModel, nav) } composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 8026d2a9c7..d9a2d09b87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -104,6 +104,7 @@ fun buildSettingsCatalog( symEntry(R.string.calendar_reminder_settings_title, MaterialSymbols.CalendarMonth, R.string.calendar_reminder_search_keywords, Route.CalendarReminderSettings), symEntry(R.string.ots_explorer_settings, MaterialSymbols.Search, R.string.ots_explorer_search_keywords, Route.OtsSettings), symEntry(R.string.namecoin_settings, MaterialSymbols.Security, R.string.namecoin_search_keywords, Route.NamecoinSettings), + symEntry(R.string.active_subs_title, MaterialSymbols.CloudSync, R.string.active_subs_search_keywords, Route.ActiveSubscriptions), symEntry(R.string.resource_usage_title, MaterialSymbols.Bolt, R.string.resource_usage_search_keywords, Route.ResourceUsage), ), ) From 4f1bd6e0c2dc43bbea61d3d14f5ea31832786556 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 21:05:57 -0400 Subject: [PATCH 014/227] feat(relays): name which public chats each subscription serves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public Chat collapsed into a single unnamed row because none of its producers carried an entity id, so the screen could say how many filters were running but not which chats caused them. The six channel-scoped producers now tag their channel ids — including the batched ones, which carry every channel a relay serves so the screen fans them into a row each. The six discovery producers (global, by author, by community, by hashtag, by geohash) stay untagged on purpose: they search for chats rather than serving known ones, so there is no entity to name. Co-Authored-By: Claude Opus 5 (1M context) --- .../nip28PublicChats/FilterChannelMetadataCreationById.kt | 1 + .../nip28PublicChats/FilterChannelMetadataUpdatesById.kt | 1 + .../datasource/subassemblies/FilterMessagesToPublicChat.kt | 1 + .../datasource/subassemblies/FilterMyMessagesToPublicChat.kt | 1 + .../chats/rooms/datasource/FilterFollowingPublicChats.kt | 1 + .../rooms/datasource/FilterLastMessageFollowingPublicChats.kt | 2 ++ 6 files changed, 7 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt index c02515c72a..ce2042c354 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt @@ -57,6 +57,7 @@ fun filterMissingChannelsById(keys: List): List Date: Thu, 30 Jul 2026 22:14:15 -0400 Subject: [PATCH 015/227] refactor(commons): move the top-nav per-relay filter values out of the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The topNavFeeds package holds two layers. The value layer — the per-relay filter sets, plain `Map` of authors, hashtags, geohashes — is pure data. The resolution layer around it (TopNavFilter, FeedFlow, the loaders and decryption caches) evaluates feeds against the live event graph and needs LocalCache, NoteState and the outbox loaders, so it is app-coupled by nature and stays put. Moving the 25 value types lets commons describe a feed selection without depending on the app, which is what an ExplainedFilter needs if it is to carry the top-nav scope instead of a flattened List. Desktop gets them too. Co-Authored-By: Claude Opus 5 (1M context) --- .../model/topNavFeeds/IFeedTopNavFilter.kt | 1 + .../model/topNavFeeds/OutboxLoaderState.kt | 3 ++- .../AllFollowsByOutboxTopNavFilter.kt | 2 ++ .../AllFollowsByProxyTopNavFilter.kt | 2 ++ .../AllUserFollowsByOutboxTopNavFilter.kt | 4 ++-- .../AllUserFollowsByProxyTopNavFilter.kt | 4 ++-- .../topNavFeeds/aroundMe/AroundMeFeedFlow.kt | 1 + .../aroundMe/LocationTopNavFilter.kt | 2 ++ .../AllFavoriteAlgoFeedsTopNavFilter.kt | 2 ++ .../FavoriteAlgoFeedTopNavFilter.kt | 2 ++ .../topNavFeeds/global/GlobalTopNavFilter.kt | 2 ++ .../hashtag/HashtagTopNavFilter.kt | 2 ++ .../AllCommunitiesTopNavFilter.kt | 2 ++ .../author/AuthorsByOutboxTopNavFilter.kt | 2 ++ .../author/AuthorsByProxyTopNavFilter.kt | 2 ++ .../community/SingleCommunityTopNavFilter.kt | 2 ++ .../muted/MutedAuthorsByOutboxTopNavFilter.kt | 2 ++ .../muted/MutedAuthorsByProxyTopNavFilter.kt | 2 ++ .../topNavFeeds/relay/RelayTopNavFilter.kt | 1 + .../unknown/UnknownTopNavFilter.kt | 1 + .../badges/datasource/FilterBadges.kt | 10 +++++----- .../calendars/datasource/SubAssemblyHelper.kt | 14 ++++++------- .../subassemblies/FilterCalendarsByAuthors.kt | 4 ++-- .../subassemblies/FilterCalendarsByFollows.kt | 2 +- .../FilterCalendarsByGeohashes.kt | 2 +- .../subassemblies/FilterCalendarsByHashtag.kt | 2 +- .../subassemblies/FilterCalendarsGlobal.kt | 2 +- .../dal/GroupDiscoveryConstraint.kt | 20 +++++++++---------- .../datasource/RelayGroupsDiscoveryFilter.kt | 20 +++++++++---------- .../RelayGroupsDiscoverySubAssembler.kt | 4 ++-- .../FilterRelayGroupsByAuthors.kt | 4 ++-- .../FilterRelayGroupsByCommunity.kt | 4 ++-- .../FilterRelayGroupsByFollows.kt | 2 +- .../FilterRelayGroupsByGeohashes.kt | 2 +- .../FilterRelayGroupsByHashtag.kt | 2 +- .../subassemblies/FilterRelayGroupsByRelay.kt | 2 +- .../subassemblies/FilterRelayGroupsGlobal.kt | 2 +- .../nip23LongForm/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterLongFormByAllCommunities.kt | 2 +- .../subassemblies/FilterLongFormByAuthors.kt | 4 ++-- .../FilterLongFormByCommunity.kt | 2 +- .../subassemblies/FilterLongFormByFollows.kt | 2 +- .../subassemblies/FilterLongFormByGeohash.kt | 2 +- .../subassemblies/FilterLongFormByHashtag.kt | 2 +- .../subassemblies/FilterLongFormGlobal.kt | 2 +- .../discover/nip28Chats/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterPublicChatsByAllCommunities.kt | 2 +- .../FilterPublicChatsByAuthors.kt | 4 ++-- .../FilterPublicChatsByCommunity.kt | 2 +- .../FilterPublicChatsByFollows.kt | 2 +- .../FilterPublicChatsByGeohash.kt | 2 +- .../FilterPublicChatsByHashtag.kt | 2 +- .../subassemblies/FilterPublicChatsGlobal.kt | 2 +- .../nip51FollowSets/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterFollowSetsByAllCommunities.kt | 2 +- .../FilterFollowSetsByAuthors.kt | 4 ++-- .../FilterFollowSetsByCommunity.kt | 2 +- .../FilterFollowSetsByFollows.kt | 2 +- .../FilterFollowSetsByGeohash.kt | 2 +- .../FilterFollowSetsByHashtag.kt | 2 +- .../subassemblies/FilterFollowSetsGlobal.kt | 2 +- .../nip53LiveActivities/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterLiveActivitiesByAllCommunities.kt | 2 +- .../FilterLiveActivitiesByAuthors.kt | 4 ++-- .../FilterLiveActivitiesByCommunity.kt | 2 +- .../FilterLiveActivitiesByFollows.kt | 2 +- .../FilterLiveActivitiesByGeohash.kt | 2 +- .../FilterLiveActivitiesByHashtag.kt | 2 +- .../FilterLiveActivitiesGlobal.kt | 2 +- .../nip72Communities/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterCommunitiesByAllCommunities.kt | 2 +- .../FilterCommunitiesByAuthors.kt | 4 ++-- .../FilterCommunitiesByCommunity.kt | 2 +- .../FilterCommunitiesByFollows.kt | 2 +- .../FilterCommunitiesByGeohash.kt | 2 +- .../FilterCommunitiesByHashtag.kt | 2 +- .../subassemblies/FilterCommunitiesGlobal.kt | 2 +- .../discover/nip90DVMs/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterContentDVMsByAllCommunities.kt | 2 +- .../FilterContentDVMsByAuthors.kt | 4 ++-- .../FilterContentDVMsByCommunity.kt | 2 +- .../FilterContentDVMsByFollows.kt | 2 +- .../FilterContentDVMsByGeohash.kt | 2 +- .../FilterContentDVMsByHashtag.kt | 2 +- .../subassemblies/FilterContentDVMsGlobal.kt | 2 +- .../nip99Classifieds/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterClassifiedsByAllCommunities.kt | 2 +- .../FilterClassifiedsByAuthors.kt | 4 ++-- .../FilterClassifiedsByCommunity.kt | 2 +- .../FilterClassifiedsByFollows.kt | 2 +- .../FilterClassifiedsByGeohash.kt | 2 +- .../FilterClassifiedsByHashtag.kt | 2 +- .../subassemblies/FilterClassifiedsGlobal.kt | 2 +- .../browse/datasource/SubAssemblyHelper.kt | 12 +++++------ .../FilterBrowseEmojiSetsByAuthors.kt | 4 ++-- .../FilterBrowseEmojiSetsByFollows.kt | 2 +- .../FilterBrowseEmojiSetsByHashtag.kt | 2 +- .../FilterBrowseEmojiSetsGlobal.kt | 2 +- .../datasource/GitRepositoriesFilter.kt | 18 ++++++++--------- .../FilterGitRepositoriesByAllCommunities.kt | 4 ++-- .../FilterGitRepositoriesByAuthors.kt | 4 ++-- .../FilterGitRepositoriesByFollows.kt | 2 +- .../FilterGitRepositoriesByGeohashes.kt | 2 +- .../FilterGitRepositoriesByHashtag.kt | 2 +- .../FilterGitRepositoriesGlobal.kt | 2 +- .../highlights/datasource/HighlightsFilter.kt | 14 ++++++------- .../FilterHighlightsByAuthors.kt | 4 ++-- .../FilterHighlightsByFollows.kt | 2 +- .../FilterHighlightsByGeohashes.kt | 2 +- .../FilterHighlightsByHashtag.kt | 2 +- .../subassemblies/FilterHighlightsGlobal.kt | 2 +- .../nip01Core/FilterHomePostsByGeohashes.kt | 2 +- .../nip01Core/FilterHomePostsByGlobal.kt | 2 +- .../nip01Core/FilterHomePostsByHashtags.kt | 2 +- .../nip01Core/FilterHomePostsByRelay.kt | 2 +- .../FilterHomePostsByAllFollows.kt | 2 +- .../nip65Follows/FilterHomePostsByAuthors.kt | 4 ++-- .../HomeOutboxEventsEoseManager.kt | 20 +++++++++---------- .../FilterHomePostsByAllCommunities.kt | 2 +- .../FilterHomePostsFromCommunities.kt | 2 +- .../FilterHomePostsByAlgoFeedIds.kt | 4 ++-- .../longs/datasource/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterLongsByAllCommunities.kt | 4 ++-- .../subassemblies/FilterLongsByAuthors.kt | 4 ++-- .../subassemblies/FilterLongsByFollows.kt | 2 +- .../subassemblies/FilterLongsByGeohashes.kt | 2 +- .../subassemblies/FilterLongsByHashtag.kt | 2 +- .../subassemblies/FilterLongsGlobal.kt | 2 +- .../music/datasource/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterMusicEventsByAuthors.kt | 4 ++-- .../FilterMusicEventsByCommunities.kt | 4 ++-- .../FilterMusicEventsByFollows.kt | 2 +- .../FilterMusicEventsByGeohashes.kt | 2 +- .../FilterMusicEventsByHashtag.kt | 2 +- .../subassemblies/FilterMusicEventsGlobal.kt | 2 +- .../napplets/datasource/SubAssemblyHelper.kt | 10 +++++----- .../subassemblies/FilterNappletsByAuthors.kt | 4 ++-- .../subassemblies/FilterNappletsByFollows.kt | 2 +- .../subassemblies/FilterNappletsGlobal.kt | 2 +- .../nests/datasource/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterNestsByAllCommunities.kt | 2 +- .../subassemblies/FilterNestsByAuthors.kt | 4 ++-- .../subassemblies/FilterNestsByCommunity.kt | 2 +- .../subassemblies/FilterNestsByFollows.kt | 2 +- .../subassemblies/FilterNestsByGeohash.kt | 2 +- .../subassemblies/FilterNestsByHashtag.kt | 2 +- .../subassemblies/FilterNestsGlobal.kt | 2 +- .../nsites/datasource/SubAssemblyHelper.kt | 10 +++++----- .../subassemblies/FilterNsitesByAuthors.kt | 4 ++-- .../subassemblies/FilterNsitesByFollows.kt | 2 +- .../subassemblies/FilterNsitesGlobal.kt | 2 +- .../pictures/datasource/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterPicturesByAllCommunities.kt | 4 ++-- .../subassemblies/FilterPicturesByAuthors.kt | 4 ++-- .../subassemblies/FilterPicturesByFollows.kt | 2 +- .../FilterPicturesByGeohashes.kt | 2 +- .../subassemblies/FilterPicturesByHashtag.kt | 2 +- .../subassemblies/FilterPicturesGlobal.kt | 2 +- .../podcasts/datasource/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterPodcastEventsByAuthors.kt | 4 ++-- .../FilterPodcastEventsByCommunities.kt | 4 ++-- .../FilterPodcastEventsByFollows.kt | 2 +- .../FilterPodcastEventsByGeohashes.kt | 2 +- .../FilterPodcastEventsByHashtag.kt | 2 +- .../FilterPodcastEventsGlobal.kt | 2 +- .../polls/datasource/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterPollsByAllCommunities.kt | 4 ++-- .../subassemblies/FilterPollsByAuthors.kt | 4 ++-- .../subassemblies/FilterPollsByFollows.kt | 2 +- .../subassemblies/FilterPollsByGeohashes.kt | 2 +- .../subassemblies/FilterPollsByHashtag.kt | 2 +- .../subassemblies/FilterPollsGlobal.kt | 2 +- .../shorts/datasource/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterPollsByAllCommunities.kt | 4 ++-- .../subassemblies/FilterShortsByAuthors.kt | 4 ++-- .../subassemblies/FilterShortsByFollows.kt | 2 +- .../subassemblies/FilterShortsByGeohashes.kt | 2 +- .../subassemblies/FilterShortsByHashtag.kt | 2 +- .../subassemblies/FilterShortsGlobal.kt | 2 +- .../datasource/SubAssemblyHelper.kt | 12 +++++------ .../FilterSoftwareAppsByAuthors.kt | 4 ++-- .../FilterSoftwareAppsByFollows.kt | 2 +- .../FilterSoftwareAppsByHashtag.kt | 2 +- .../subassemblies/FilterSoftwareAppsGlobal.kt | 2 +- .../VideoOutboxEventsFilterSubAssembler.kt | 16 +++++++-------- .../FilterPictureAndVideoByGeohash.kt | 2 +- .../FilterPictureAndVideoByHashtag.kt | 2 +- .../nip01Core/FilterPictureAndVideoGlobal.kt | 2 +- .../FilterPictureAndVideoByAuthors.kt | 4 ++-- .../FilterPictureAndVideoByFollows.kt | 2 +- .../FilterPictureAndVideoByAllCommunities.kt | 2 +- .../FilterPictureAndVideoByCommunity.kt | 2 +- .../workouts/datasource/SubAssemblyHelper.kt | 18 ++++++++--------- .../FilterWorkoutsByAllCommunities.kt | 4 ++-- .../subassemblies/FilterWorkoutsByAuthors.kt | 4 ++-- .../subassemblies/FilterWorkoutsByFollows.kt | 2 +- .../FilterWorkoutsByGeohashes.kt | 2 +- .../subassemblies/FilterWorkoutsByHashtag.kt | 2 +- .../subassemblies/FilterWorkoutsGlobal.kt | 2 +- .../dal/RelayGroupDiscoveryConstraintTest.kt | 12 +++++------ .../FilterHomePostsByAlgoFeedIdsTest.kt | 4 ++-- .../topNavFeeds/IFeedTopNavPerRelayFilter.kt | 2 +- .../IFeedTopNavPerRelayFilterSet.kt | 2 +- .../MergedTopFeedAuthorListsState.kt | 18 ++++++++--------- .../AllFollowsTopNavPerRelayFilter.kt | 4 ++-- .../AllFollowsTopNavPerRelayFilterSet.kt | 4 ++-- .../topNavFeeds/aroundMe/AroundMeExpander.kt | 2 +- .../aroundMe/LocationTopNavPerRelayFilter.kt | 4 ++-- .../LocationTopNavPerRelayFilterSet.kt | 4 ++-- .../FavoriteAlgoFeedTopNavPerRelayFilter.kt | 4 ++-- ...FavoriteAlgoFeedTopNavPerRelayFilterSet.kt | 4 ++-- .../global/GlobalTopNavPerRelayFilter.kt | 4 ++-- .../global/GlobalTopNavPerRelayFilterSet.kt | 4 ++-- .../hashtag/HashtagTopNavPerRelayFilter.kt | 4 ++-- .../hashtag/HashtagTopNavPerRelayFilterSet.kt | 4 ++-- .../AllCommunitiesTopNavPerRelayFilter.kt | 4 ++-- .../AllCommunitiesTopNavPerRelayFilterSet.kt | 4 ++-- .../author/AuthorsTopNavPerRelayFilter.kt | 4 ++-- .../author/AuthorsTopNavPerRelayFilterSet.kt | 4 ++-- .../SingleCommunityTopNavPerRelayFilter.kt | 4 ++-- .../SingleCommunityTopNavPerRelayFilterSet.kt | 4 ++-- .../muted/MutedAuthorsTopNavPerRelayFilter.kt | 4 ++-- .../MutedAuthorsTopNavPerRelayFilterSet.kt | 4 ++-- .../relay/RelayTopNavPerRelayFilter.kt | 4 ++-- .../relay/RelayTopNavPerRelayFilterSet.kt | 4 ++-- .../unknown/UnknownTopNavPerRelayFilterSet.kt | 4 ++-- 226 files changed, 509 insertions(+), 478 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt (94%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt (94%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/MergedTopFeedAuthorListsState.kt (82%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt (91%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt (89%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/aroundMe/AroundMeExpander.kt (96%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt (90%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt (89%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt (89%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt (92%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt (88%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt (89%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt (90%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt (89%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt (87%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt (88%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilter.kt (88%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt (88%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt (89%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt (88%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilter.kt (88%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt (89%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/relay/RelayTopNavPerRelayFilter.kt (88%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt (89%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt (88%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt index c144917206..b35a1d02f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt index 7087316550..276e554749 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxLoaderState.kt @@ -20,8 +20,9 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt index 2ec8bbf84e..7c7d3b95f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt index 8c8b5a26f2..652cf1b0f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByProxyTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByOutboxTopNavFilter.kt index 25b4142de9..dfa894880f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByOutboxTopNavFilter.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByProxyTopNavFilter.kt index 37e3282003..722f233b49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByProxyTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allUserFollows/AllUserFollowsByProxyTopNavFilter.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt index a42ce71241..329d2b1a1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeFeedFlow.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.compute50kmRange import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.service.location.LocationState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt index 24b39bee60..8102e92182 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt index eba50b6bcf..ff25143e5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt index 6cd70135c2..cfaf3629ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Address diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt index 6ee086ab5d..88e12bef45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.global import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt index fb724b88c4..5665eb6d0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt index 71df70bafd..d947ba6f0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt index a527720df8..d04ab0f024 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt index a5a9268a24..eb2db69cd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByProxyTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt index bf877e1260..37edb872c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt index 65027a3f90..31eac2dd69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt index a4b5aaef9a..bea37b9ef0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByProxyTopNavFilter.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavFilter.kt index ce3cfe4da9..915eec656a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavFilter.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.relay import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt index d5734bda9d..5a0e0575d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.unknown import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt index 729a08b7ca..fde9c08ecb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt index 4b47c5e53c..fff17417a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByFollows diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByAuthors.kt index 84633d38ec..fa51b0e133 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByFollows.kt index 508ef13cc7..bae9664e49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByGeohashes.kt index ce43a7f5da..60cd1d69ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByGeohashes.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByHashtag.kt index 5a26043d82..6c5bfae372 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsGlobal.kt index 7f99aa7ba2..8606dfbe76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/subassemblies/FilterCalendarsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt index 8b67b05bf0..69f7969579 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt @@ -21,16 +21,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.GroupDiscoveryConstraint -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt index 5d3172b3c6..446c26f336 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt @@ -20,16 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt index 6acb4c5cef..24c9ec5e14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt index 1e0e1291f8..a10dfb13f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt index 6d4050bcdc..05316192db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt index 64e5295a7e..87aeb967e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt index 7b9c8b5922..0ae3bbc0fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt index 98847f9bdd..c8634d931b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByRelay.kt index 7974bd50bf..2768dfb6ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByRelay.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt index 144b6061ad..5a54a354f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt index 0b32f8924e..5b084b3d7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt index 1d773ea040..db43249573 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAllCommunities.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt index e87828fa52..9f161f4f41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt index ec867450d8..e2b6c60aa7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByCommunity.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt index 907214fe8e..d01bdbf7bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt index e753b72ce0..bca1c7f02b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByGeohash.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByGeohash import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt index 5a11af780c..b365e48079 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt index c68babe190..0d1d254efe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/subassemblies/FilterLongFormGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt index f3d9e910bf..4cef2d6b60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt index 38244e6737..46536f2687 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAllCommunities.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt index b4da28138c..9944e7dc6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt index 1973662fab..f000bdd7ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByCommunity.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt index 73068610ae..ce9919dcbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt index bc60f7d6f8..d94b5a3166 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByGeohash.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt index f3c2ed65a6..a544c8a11f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt index 3342839f42..aa448d0477 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/subassemblies/FilterPublicChatsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt index 4e8a8a8ba4..eaf3b772ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt index 6ed7223153..c7d179fdba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAllCommunities.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsAllCommunities import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt index 9cd220a447..0d28f32ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt index 6f9ee782ec..c3e944c20b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByCommunity.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt index fb11096666..99be6c1d8d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt index e4eca9fa18..53c2c58f90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByGeohash.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt index 12a994c227..02a4703da0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt index 788ee527b5..6ecddb7b5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/subassemblies/FilterFollowSetsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt index 6f5c5a5170..1c971dcc0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt index 74fd1d9bf3..e61c53753d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAllCommunities.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt index a51b12f82e..a6a266bce7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt index 449cdbdb96..1667601ae3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByCommunity.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt index b7bdf4cd84..a5911dead4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt index 5eea6e7c53..e458cda38c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByGeohash.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt index 28b5de9f64..4fa08d63bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt index f55370b6ce..27b83d9d8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/subassemblies/FilterLiveActivitiesGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt index 0b1513406a..362c942fce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt index 89bc8c6e52..eadc45dac5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAllCommunities.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt index 27b1047080..1b8fda38d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt index 6f928b6ff7..319fcfbd95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByCommunity.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt index 6feb68c1e9..1010de5216 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt index 304469da3b..9393e18407 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByGeohash.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt index 620abd126f..341be760da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt index ad580b93c3..9b7127947a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt index a78047c893..2f984e5a2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt index 708eff563e..919a800fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAllCommunities.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt index 9820641f47..7302c37ee3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt index c83c695711..fc0b52a574 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByCommunity.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt index 5e964daa0a..8e89fa12a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt index 1b53060611..7c5620f290 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByGeohash.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt index d6437759ce..c6ed995c09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt index 618c3c1ecf..e636f5e4e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/subassemblies/FilterContentDVMsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt index 80d824fa55..ef6822c706 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt index 31e717ed13..b765994423 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAllCommunities.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt index 307f970e53..94d7cd3cf9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt index f7027966ae..27eb96b534 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByCommunity.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt index 929ecf44de..174341b5ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt index 366449b616..91f4624bbf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByGeohash.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt index 709af9ca9e..9e9cae2ed6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt index f2ed19d8cb..f0258b6bbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/subassemblies/FilterClassifiedsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/SubAssemblyHelper.kt index 4988afc4e7..d801597e56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/SubAssemblyHelper.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies.filterBrowseEmojiSetsByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies.filterBrowseEmojiSetsByFollows diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt index 605cc42df7..1e1270fd35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByFollows.kt index a842fbcf7e..ee33a35f06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt index 9eabf847df..603312e0fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt index 9cd2bb4184..c1ec5ae23e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesFilter.kt index bc343e103f..f2d6907931 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesFilter.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies.filterGitRepositoriesByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies.filterGitRepositoriesByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAllCommunities.kt index 3cb32b21af..a44f51c659 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAllCommunities.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAuthors.kt index 4316cac70e..024c621f99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByFollows.kt index ed93f7038b..958c010b23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByGeohashes.kt index 0762319d4d..279ec88355 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByGeohashes.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByHashtag.kt index 4dd1939660..39e5b7959f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesGlobal.kt index d3f40af9d6..1cd01f1522 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/HighlightsFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/HighlightsFilter.kt index 6ca38932bc..081757b928 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/HighlightsFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/HighlightsFilter.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByFollows diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByAuthors.kt index 500ccfabef..7293b253f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByFollows.kt index 21e002809a..72a9264ab6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByGeohashes.kt index 625683fb71..5fbe2d6cf2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByGeohashes.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByHashtag.kt index 4a075c18c9..75cf990338 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsGlobal.kt index b4ba6fb4be..44ec01a5ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/subassemblies/FilterHighlightsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt index 5c571806ca..cba9f3c879 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt index 46a970546c..d81320bda0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsConversationKinds import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds1 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt index c966de7a9e..b88f91e15d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByRelay.kt index 9c78192919..70c190fff4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByRelay.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsConversationKinds import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds1 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt index b048c002f5..0ede82c304 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAllFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGeohashes import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByHashtags diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index 46ec797c00..3675dca537 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index d7b253d242..b9e14c1e2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -20,19 +20,19 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.HomeFeedType import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQueryState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt index 9b731dd8db..e8dae12ba1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsByAllCommunities.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt index 8284b0bc23..d2fac784f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt index c4edb1073d..4fb8984ef8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/SubAssemblyHelper.kt index fdb9f523d8..bf38841bdf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAllCommunities.kt index 7e5c287cbd..e8e6473c15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAllCommunities.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAuthors.kt index 7abcb602d2..1b72089da1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByFollows.kt index 8d44c6c749..405b7c5a3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByGeohashes.kt index e9ad6f93eb..d1e7898c71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByGeohashes.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByHashtag.kt index 3af9195851..43175be4be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsGlobal.kt index 34577f364a..57158d9095 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/subassemblies/FilterLongsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt index 1c075e9c58..3bd3dfb23e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_PLAYLIST_KINDS import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_TRACK_KINDS diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt index 6820936028..f646b5195c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt index 0e315e84e8..a2bda6f774 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByCommunities.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByFollows.kt index 804b88f793..d68e5064d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt index 2a1d14a505..cf8130d7cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByGeohashes.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt index 15748ceafe..90d7ca8ec1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt index 05164fffa3..dc036fe285 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/subassemblies/FilterMusicEventsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/SubAssemblyHelper.kt index a162768277..a4fde42d99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/SubAssemblyHelper.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies.filterNappletsByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies.filterNappletsByFollows diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByAuthors.kt index 6c415a452a..a49dad5c4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NAPPLET_PAGE_LIMIT import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByFollows.kt index b6d257a3f0..8f72844139 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsGlobal.kt index 929a24de6b..583da4c532 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/subassemblies/FilterNappletsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NAPPLET_PAGE_LIMIT import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/SubAssemblyHelper.kt index 9959437461..b0bf34fdf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies.filterNestsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies.filterNestsByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAllCommunities.kt index 89d166c8bb..69e6d76697 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAllCommunities.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAuthors.kt index 7a29bef733..b62324500b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByCommunity.kt index 3cb5722213..cd7a5e3dd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByCommunity.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByFollows.kt index 51af051fd8..0d81afe157 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByGeohash.kt index a044bca29d..002b668555 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByGeohash.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByHashtag.kt index b9a558630e..8da8b7ebbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt index 966966fe7b..259b9b6239 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/SubAssemblyHelper.kt index 5bb05ba946..27cb00fabd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/SubAssemblyHelper.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.subassemblies.filterNsitesByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.subassemblies.filterNsitesByFollows diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByAuthors.kt index 9c4154105d..613f066d92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NSITE_PAGE_LIMIT import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByFollows.kt index 0943f6462d..e909ed35ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesGlobal.kt index f39af90993..2a3465d246 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/subassemblies/FilterNsitesGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NSITE_PAGE_LIMIT import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/SubAssemblyHelper.kt index ed220e85e7..617bb66573 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies.filterPicturesByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies.filterPicturesByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAllCommunities.kt index f1992e40fb..f0cdfc0141 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAllCommunities.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt index f9e769998e..0d81724e85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByFollows.kt index f630c6bca4..d773d4322e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByGeohashes.kt index 23a3762bf4..9c8c0b3d56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByGeohashes.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt index 4913579efd..52703da2ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt index 9d898918eb..098234fa75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip68Picture.PictureEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt index d332342799..ff87f90c63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCASTING20_METADATA_KINDS import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_EPISODE_KINDS diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt index 75a6410dbe..70c29799c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt index 4aad2b6400..b1fe15c6ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt index 4249f4982d..23df1b396a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt index f0e511d112..09f8c8f2bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt index d6b189ed23..68c3ef6dc8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt index 093f5c3b45..47d9a4b015 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/SubAssemblyHelper.kt index 15efc077ab..862140b65f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAllCommunities.kt index 3c7f65da1f..33e4269897 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAllCommunities.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt index 72ac8ebd89..576a13594d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByFollows.kt index f610f2e46b..1cc7fb7dd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByGeohashes.kt index fecc93a0c1..8cac68cd07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByGeohashes.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt index dffbab4c0d..18b1e7800b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt index 0216068391..9a41d2aeb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/subassemblies/FilterPollsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/SubAssemblyHelper.kt index a3ed26e650..e53a715c45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies.filterShortsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies.filterShortsByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterPollsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterPollsByAllCommunities.kt index 15a3c51460..79d69c98a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterPollsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterPollsByAllCommunities.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByAuthors.kt index fcade6e8c9..7bebcdd43b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByFollows.kt index 058e907400..e7b7f073b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByGeohashes.kt index 117ca69717..10c7e52f76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByGeohashes.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByHashtag.kt index a0df247758..486502c088 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsGlobal.kt index d9975d402b..b6658ffdf8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/subassemblies/FilterShortsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip71Video.VideoShortEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SubAssemblyHelper.kt index 6dbfbf2fc2..36339f10e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SubAssemblyHelper.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.subassemblies.filterSoftwareAppsByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.subassemblies.filterSoftwareAppsByFollows diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByAuthors.kt index d24de2b9bf..8728c8e4cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByFollows.kt index 4f85ab2be6..3f39670eb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByHashtag.kt index 3c32ed466a..7a5533ae83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsGlobal.kt index 62298cbf96..dc7cc13b92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/subassemblies/FilterSoftwareAppsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt index 5b02873027..149a724eec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt @@ -20,16 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoQueryState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt index dc785068bd..32830a15f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByGeohash.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt index b0d29d160d..13aa9b0608 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt index 8ffb150967..5e8b281e04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip01Core/FilterPictureAndVideoGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypeMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt index 1175551bc4..15fb28ad71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip65Follows +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypeMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKinds diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt index 6fa23fcc8d..d02f7b8724 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip65Follows/FilterPictureAndVideoByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip65Follows -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoGeohash import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip01Core.filterPictureAndVideoHashtag diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt index 1d9a517d3f..cdae35187c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByAllCommunities.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKTags diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt index 2bdec3af51..c3f4f4f9da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/nip72Communities/FilterPictureAndVideoByCommunity.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies.nip72Communities +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.LegacyMimeTypes import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.PictureAndVideoKTags diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/SubAssemblyHelper.kt index cb44f698ca..33f39d9d4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/SubAssemblyHelper.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsByAuthors diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt index 31e074a6aa..924f7c3e69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt index 13382e375f..17835acf1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByFollows.kt index 84892a981d..694d8f7c42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByFollows.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt index 6d0f83cd41..5f1c432e70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt index 117e8137f6..9801e6b92c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt index d798092b08..0ad68a945d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryConstraintTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryConstraintTest.kt index 09dd71b292..efaf124679 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryConstraintTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryConstraintTest.kt @@ -22,12 +22,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.GroupDiscoveryConstraint import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt index 5ae5d27a18..dccb0dc6b7 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt index 1999e780d2..3d8beafcd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilter.kt @@ -18,6 +18,6 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds +package com.vitorpamplona.amethyst.commons.model.topNavFeeds interface IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt index de816ce29e..ece74cd8c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt @@ -18,6 +18,6 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds +package com.vitorpamplona.amethyst.commons.model.topNavFeeds interface IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/MergedTopFeedAuthorListsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/MergedTopFeedAuthorListsState.kt similarity index 82% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/MergedTopFeedAuthorListsState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/MergedTopFeedAuthorListsState.kt index e316b3d9d0..670930f6c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/MergedTopFeedAuthorListsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/MergedTopFeedAuthorListsState.kt @@ -18,16 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds +package com.vitorpamplona.amethyst.commons.model.topNavFeeds -import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt index 443bfd5c0b..b7ac858208 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilter.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt index b4a8c866f0..cc7481733b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class AllFollowsTopNavPerRelayFilterSet( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/AroundMeExpander.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/AroundMeExpander.kt index 1e83a7dc03..12dca0250c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/AroundMeExpander.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt index bd1cd0743c..6cb13bb47e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilter.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId @Immutable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt index 07caf629ab..565cbb2c07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class LocationTopNavPerRelayFilterSet( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt index 374f5ecf0b..a0c3db328b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.HexKey @Immutable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt index a111c93613..9ad81f1e0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt index 33ae131924..c97579c917 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilter.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.global +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.global import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter @Immutable object GlobalTopNavPerRelayFilter : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt index 301912b429..26a032841b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.global +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.global -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class GlobalTopNavPerRelayFilterSet( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt index 2aea9624b1..5a6c9a3bd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilter.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @Immutable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt index 6edf13091e..1621d64ca3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class HashtagTopNavPerRelayFilterSet( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt index 358e79db17..12329b0590 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilter.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter class AllCommunitiesTopNavPerRelayFilter( val communities: Set, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt index e5e1d9024a..541647c10f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class AllCommunitiesTopNavPerRelayFilterSet( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilter.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilter.kt index 5d98ea5a74..87b47c2c99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilter.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter @Immutable class AuthorsTopNavPerRelayFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt index 4034f9771b..6c72c0b6d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class AuthorsTopNavPerRelayFilterSet( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt index 4e21d72502..8ba7c2edc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilter.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter import com.vitorpamplona.quartz.nip01Core.core.Address @Immutable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt index dbd16e182d..d64a809749 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class SingleCommunityTopNavPerRelayFilterSet( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilter.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilter.kt index bb8cfea608..1a93a2fa51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilter.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter @Immutable class MutedAuthorsTopNavPerRelayFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt index f62cd5e4c6..2f52a15003 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class MutedAuthorsTopNavPerRelayFilterSet( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavPerRelayFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilter.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavPerRelayFilter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilter.kt index b7d26800e8..767c2c20c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavPerRelayFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilter.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.relay +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter @Immutable object RelayTopNavPerRelayFilter : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt index b27d1517dc..b3d891b5ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.relay +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class RelayTopNavPerRelayFilterSet( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt index 41156ec221..7c01d40708 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt @@ -18,8 +18,8 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.topNavFeeds.unknown +package com.vitorpamplona.amethyst.commons.model.topNavFeeds.unknown -import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet object UnknownTopNavPerRelayFilterSet : IFeedTopNavPerRelayFilterSet From 54afbf95e7fdaf723c107e7c598eb9345eba0241 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 22:58:05 -0400 Subject: [PATCH 016/227] docs(notifications): describe the cold-start floor as what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment called it a backward-paging boundary that asks for everything older than the feed's oldest card. It is neither: backward paging lives in AccountNotificationsHistoryEoseManager, and `since` means newer-than, so it floors the query at the depth the feed already reaches instead of asking all-time again. It is also read only until a relay has an EOSE. Records that it is always null for an account with no UI, since nothing fills that feed — which is what makes the field inert on a headless path. Co-Authored-By: Claude Opus 5 (1M context) --- .../AccountNotificationsEoseFromInboxRelaysManager.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index c73b0dd9c0..4da8494533 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -49,9 +49,13 @@ class AccountNotificationsEoseFromInboxRelaysManager( key: AccountQueryState, since: SincePerRelayMap?, ): List { - // Backward-paging boundary: once the feed has filled a page, ask for everything older than - // its oldest card. It stays null until then — see the note on the missing week floor below, - // which is what let it stay null forever on a quiet inbox. + // A cold-start floor, NOT paging — backward paging lives in + // [AccountNotificationsHistoryEoseManager]. Read only when a relay has no EOSE yet; once it + // does, the EOSE time wins and this is never consulted. + // + // `since` means *newer than*, so this floors the query at the depth the feed already reaches + // rather than asking all-time again. It stays null until the feed holds a full page — and is + // therefore always null for an account with no UI, since nothing fills that feed. val pagingBoundary = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() val inbox = From 548a5c979edb584e359774cd16b5a896d14639c1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 23:31:58 -0400 Subject: [PATCH 017/227] feat(subscriptions): subscribe every background account, not just the active one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An account that opted into "keep this account active in the background" got nothing from that setting but a running service. Subscriptions were only ever created from a composable holding an AccountViewModel, so the account on screen was the only one talking to relays; the rest were loaded purely so pushed gift wraps could be decrypted by their owner. On a device with no push — no Play Services, no UnifiedPush distributor, no Pokey — those accounts pulled nothing at all, and the setting quietly meant less than its name. BackgroundAccountSubscriptionRegistry mounts the always-on loaders for each participating account directly, with no view model behind them. AlwaysOnNotificationServiceManager already watched the two flags that define participation (the account's own toggle, or its NIP-46 signer), so it now keeps the set instead of collapsing it to "is anyone participating" and hands it to the registry on every change. Running headless meant the key had to stop assuming a screen. AccountQueryState drops to what an Account alone can supply, and AccountUiQueryState adds the feed states for screens — the only always-on reader is the notifications cold-start floor, which reads a feed nothing fills without UI and so could only ever be null for these accounts. An account can now be mounted twice, on screen and in the background; the managers dedup by user but keep whichever key arrived first, so preferredKeys picks the screen's key and the account being looked at keeps its floor rather than losing it to a race. The Cashu wallet had the mirror-image bug: it subscribed from every Account's own scope, ungated, which put a Wallet and a Nutzap Inbox on the wire for every saved account — including accounts that never opted in and have no wallet. It now follows AccountFilterAssembler.subscribedAccounts, the same truth both mount paths write to. Verified on emulator-5554 with 4 loaded accounts, 3 participating: each of the 3 gets its own Notifications and DM Inbox (12 and 3 filters for a background-only account), filters scale per account while relays overlap, and the 4th account — loaded but not participating — now holds no subscriptions at all where it previously held Wallet and Nutzap Inbox. Co-Authored-By: Claude Opus 5 (1M context) --- .../NotificationFeedFilterModeOverrideTest.kt | 1 + .../ThreadDualAxisChartAssemblerTest.kt | 1 + .../com/vitorpamplona/amethyst/AppModules.kt | 9 ++ .../vitorpamplona/amethyst/model/Account.kt | 3 + .../model/accountsCache/AccountCacheState.kt | 2 + .../model/nip60Cashu/CashuWalletState.kt | 42 ++++++-- .../AlwaysOnNotificationServiceManager.kt | 29 +++-- .../account/AccountFilterAssembler.kt | 91 +++++++++++++--- .../AccountFilterAssemblerSubscription.kt | 2 +- .../AccountForegroundFilterAssembler.kt | 2 +- ...ntForegroundFilterAssemblerSubscription.kt | 2 +- .../BackgroundAccountSubscriptionRegistry.kt | 101 ++++++++++++++++++ .../AccountFollowsLoaderSubAssembler.kt | 6 +- ...NotificationsEoseFromInboxRelaysManager.kt | 19 ++-- ...otificationsEoseFromRandomRelaysManager.kt | 12 +-- .../ui/screen/loggedIn/AccountViewModel.kt | 4 + 16 files changed, 280 insertions(+), 46 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/BackgroundAccountSubscriptionRegistry.kt diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt index c869020eea..3f2c36b95d 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt @@ -87,6 +87,7 @@ class NotificationFeedFilterModeOverrideTest { geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, nwcFilterAssembler = { NWCPaymentFilterAssembler(client) }, cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) }, + subscribedAccounts = { MutableStateFlow(emptySet()) }, cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) }, okHttpClientForMoney = { OkHttpClient() }, otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 2cc4fcd1e9..38a97dc730 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -78,6 +78,7 @@ class ThreadDualAxisChartAssemblerTest { geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, nwcFilterAssembler = { NWCPaymentFilterAssembler(client) }, cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) }, + subscribedAccounts = { MutableStateFlow(emptySet()) }, cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) }, okHttpClientForMoney = { OkHttpClient() }, otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 6b87d3e391..bb895e81b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoor import com.vitorpamplona.amethyst.service.relayClient.diagnostics.BootRelayDiagnostics import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.BackgroundAccountSubscriptionRegistry import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger @@ -872,6 +873,7 @@ class AppModules( geolocationFlow = { locationManager.geohashStateFlow }, nwcFilterAssembler = { sources.nwc }, cashuWalletFilterAssembler = { sources.cashuWallet }, + subscribedAccounts = { sources.account.subscribedAccounts }, cashuMintDirectoryFilterAssembler = { sources.cashuMintDirectory }, okHttpClientForMoney = roleBasedHttpClientBuilder::okHttpClientForMoney, contentResolverFn = { appContext.contentResolver }, @@ -957,6 +959,12 @@ class AppModules( ) } + // Gives every account that opted into running in the background its own + // account-level subscriptions (notifications, DMs, gift wraps, wallet), with no + // AccountViewModel behind it. Driven by alwaysOnNotificationServiceManager below, + // which already tracks which accounts opted in. + val backgroundAccountSubscriptions = BackgroundAccountSubscriptionRegistry(sources.account) + // Manages always-on notification service lifecycle. Preloads every saved // writable account while enabled so GiftWraps for non-active accounts still // get unwrapped by their owning account's newNotesPreProcessor. @@ -966,6 +974,7 @@ class AppModules( scope = applicationIOScope, accountsCache = accountsCache, localPreferences = LocalPreferences, + backgroundSubscriptions = backgroundAccountSubscriptions, activePubKeyProvider = { sessionManager.loggedInAccount()?.pubKey }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 19699bc448..b05ea7e504 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -442,6 +442,8 @@ class Account( val geolocationFlow: () -> StateFlow, val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler, + /** Accounts with account-level subscriptions mounted — see [cashuWalletState]. */ + val subscribedAccounts: () -> StateFlow>, val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler, val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient, val otsResolverBuilder: () -> OtsResolver, @@ -742,6 +744,7 @@ class Account( cache = cache, scope = scope, assembler = cashuWalletFilterAssembler(), + subscribedAccounts = subscribedAccounts(), outboxRelaysFlow = outboxRelays.flow, inboxRelaysFlow = notificationRelays.flow, dmRelaysFlow = dmRelays.flow, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 00b375d9bf..9fcbfd7e40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -60,6 +60,7 @@ class AccountCacheState( val geolocationFlow: () -> StateFlow, val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler, + val subscribedAccounts: () -> StateFlow>, val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler, val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient, val contentResolverFn: () -> ContentResolver, @@ -267,6 +268,7 @@ class AccountCacheState( geolocationFlow = geolocationFlow, nwcFilterAssembler = nwcFilterAssembler, cashuWalletFilterAssembler = cashuWalletFilterAssembler, + subscribedAccounts = subscribedAccounts, cashuMintDirectoryFilterAssembler = cashuMintDirectoryFilterAssembler, okHttpClientForMoney = okHttpClientForMoney, otsResolverBuilder = otsResolverBuilder, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 3f9c610a9d..b2b2b01a61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -103,6 +103,12 @@ class CashuWalletState( private val cache: LocalCache, private val scope: CoroutineScope, private val assembler: CashuWalletFilterAssembler, + /** + * The accounts whose account-level subscriptions are mounted right now + * (`AccountFilterAssembler.subscribedAccounts`). The wallet follows it so it + * runs for exactly the accounts that pull from relays at all. + */ + private val subscribedAccounts: StateFlow>, private val outboxRelaysFlow: StateFlow>, private val inboxRelaysFlow: StateFlow>, private val dmRelaysFlow: StateFlow>, @@ -472,17 +478,31 @@ class CashuWalletState( jobs += scope.launch(Dispatchers.IO) { combine( + subscribedAccounts, outboxRelaysFlow, inboxRelaysFlow, dmRelaysFlow, _nutzapInfoEvent, - ) { outbox, inbox, dm, info -> - CashuWalletQueryState( - pubkey = pubKey, - ownEventRelays = outbox, - inboxRelays = inbox + dm + (info?.relays() ?: emptyList()), - ) - }.collect { syncSubscription(it) } + ) { subscribed, outbox, inbox, dm, info -> + // Only pull a wallet for an account that is actually subscribed: the + // one on screen, or one the user opted into keeping active in the + // background. Other accounts are held in memory purely so pushed + // gift wraps can be decrypted by their owner — asking relays for + // their wallet put a Wallet and a Nutzap Inbox subscription on the + // wire for every saved account, including accounts that have no + // wallet at all. + if (pubKey !in subscribed) { + null + } else { + CashuWalletQueryState( + pubkey = pubKey, + ownEventRelays = outbox, + inboxRelays = inbox + dm + (info?.relays() ?: emptyList()), + ) + } + }.collect { next -> + if (next == null) clearSubscription() else syncSubscription(next) + } } // Reactive incremental update: any new event arrival that matches our @@ -545,11 +565,15 @@ class CashuWalletState( // ============================================================ // Subscription management // ============================================================ + private fun clearSubscription() { + currentSubscription?.let { runCatching { assembler.unsubscribe(it) } } + currentSubscription = null + } + private fun syncSubscription(next: CashuWalletQueryState) { val previous = currentSubscription if (next.ownEventRelays.isEmpty() && next.inboxRelays.isEmpty()) { - previous?.let { runCatching { assembler.unsubscribe(it) } } - currentSubscription = null + clearSubscription() return } if (previous == next) return // unchanged diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt index 4af3a66589..039398c66f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.notifications import android.content.Context import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.BackgroundAccountSubscriptionRegistry import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -70,6 +71,7 @@ class AlwaysOnNotificationServiceManager( private val scope: CoroutineScope, private val accountsCache: AccountCacheState, private val localPreferences: LocalPreferences, + private val backgroundSubscriptions: BackgroundAccountSubscriptionRegistry, private val activePubKeyProvider: () -> HexKey?, ) { companion object { @@ -121,16 +123,25 @@ class AlwaysOnNotificationServiceManager( val flags = accounts.values.map { account -> account.settings.alwaysOnNotificationService - .combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> alwaysOn || signer } + .combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> + if (alwaysOn || signer) account else null + } } if (flags.isEmpty()) { - flowOf(false) + flowOf(emptyList()) } else { - combine(flags) { values -> values.any { it } } + combine(flags) { values -> values.filterNotNull() } } }.distinctUntilChanged() - .collectLatest { anyParticipating -> - if (anyParticipating) { + .collectLatest { participating -> + // Give every participating account its own account-level + // subscriptions (notifications, DMs, gift wraps, wallet). + // Until this existed, only the account on screen pulled + // anything and the rest waited on a push that may never + // come. + backgroundSubscriptions.sync(participating) + + if (participating.isNotEmpty()) { wasEnabled = true enableServiceLayers() } else if (wasEnabled) { @@ -145,8 +156,7 @@ class AlwaysOnNotificationServiceManager( fun stop() { watchJob?.cancel() watchJob = null - preloadJob?.cancel() - preloadJob = null + stopMultiAccountPreload() // Logout/terminate: tear the layers down explicitly. Otherwise the watchdog alarm // and periodic worker stay scheduled and would resurrect the service for a // logged-out user (nobody participating). @@ -211,10 +221,15 @@ class AlwaysOnNotificationServiceManager( * Cancels the preload collector and releases every cached account except the * currently active one, so users with the master off return to single-account * memory/battery footprint. + * + * Unmounts the background subscriptions too: with the master off, the only + * account that should be talking to relays is the one on screen, and its + * subscription comes from the screen's own mount. */ private fun stopMultiAccountPreload() { preloadJob?.cancel() preloadJob = null + backgroundSubscriptions.clear() // remove this because we don't know which other accounts might be getting used. // val active = activePubKeyProvider() // if (active != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index fe57efc735..e26fdede41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -35,14 +35,46 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59Gi import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow // This allows multiple screen to be listening to logged-in accounts. +// +// Carries only what an account can supply with no screen attached, so the +// background registry can mount the always-on loaders for accounts the user +// opted into keeping active while the app is away. Screens use the richer +// [AccountUiQueryState] below. @Stable -class AccountQueryState( +open class AccountQueryState( override val account: Account, - val feedContentStates: AccountFeedContentStates, val otherAccounts: Set, -) : AccountScopedQuery +) : AccountScopedQuery { + /** + * The feeds this account renders, when a screen is attached — null for + * background accounts. + * + * The only always-on reader is + * [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager], + * which reads it as a cold-start `since` floor via `lastNoteCreatedAtIfFilled()`. + * That floor only arms once the feed holds a full page, and nothing fills a + * feed that has no UI — so for a background account it could only ever + * return null anyway. Leaving the field off the background key states that + * rather than pretending there is a feed to consult. + */ + open val feedContentStates: AccountFeedContentStates? = null +} + +/** + * The key for an account with a screen attached. Adds the feed states, which + * lets the notification loaders floor their cold-start queries at the depth the + * rendered feed already reaches instead of asking all-time again. + */ +@Stable +class AccountUiQueryState( + account: Account, + override val feedContentStates: AccountFeedContentStates, + otherAccounts: Set, +) : AccountQueryState(account, otherAccounts) /** * Always-on account loaders: metadata, gift wraps, drafts, inbox-relay @@ -54,31 +86,66 @@ class AccountFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { // Live tail: the recent week of gift wraps, always open at the top for new messages. - val giftWraps = AccountGiftWrapsEoseManager(client, ::allKeys) + val giftWraps = AccountGiftWrapsEoseManager(client, ::preferredKeys) // History: older gift wraps, loaded on demand in bounded one-shot slices. - val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys) + val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::preferredKeys) // Live tail: the recent week of notifications from the inbox + group host relays. - val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys) + val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::preferredKeys) // History: older notifications, paged backward by until+limit per relay, driven by the feed's markers. - val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::allKeys) + val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::preferredKeys) val group = listOf( - AccountMetadataEoseManager(client, ::allKeys), + AccountMetadataEoseManager(client, ::preferredKeys), giftWraps, giftWrapsHistory, - AccountDraftsEoseManager(client, ::allKeys), + AccountDraftsEoseManager(client, ::preferredKeys), notifications, notificationsHistory, // Live tail: NIP-47 wallet notifications (payment_received) on each connected wallet's own relay. - NwcNotificationsEoseManager(client, ::allKeys), - MarmotGroupEventsEoseManager(client, ::allKeys), + NwcNotificationsEoseManager(client, ::preferredKeys), + MarmotGroupEventsEoseManager(client, ::preferredKeys), ) - override fun invalidateKeys() = invalidateFilters() + /** + * One key per account, preferring a screen's [AccountUiQueryState] over the + * background registry's key. + * + * An account can be mounted twice — the user is looking at it *and* asked to keep + * it running in the background. The managers below all dedup by user, but by + * keeping whichever key they meet first, which is just whoever mounted first. + * Resolving it here means the account being looked at keeps the feed-backed + * cold-start floor instead of losing it to a race. + */ + private fun preferredKeys(): Set = + allKeys() + .groupBy { it.account.userProfile().pubkeyHex } + .values + .mapTo(mutableSetOf()) { keys -> + keys.firstOrNull { it.feedContentStates != null } ?: keys.first() + } + + private val subscribedAccountsInternal = MutableStateFlow>(emptySet()) + + /** + * The accounts whose always-on subscriptions are mounted right now, from + * either mount path: a screen's [AccountFilterAssemblerSubscription] or the + * headless [BackgroundAccountSubscriptionRegistry]. + * + * This is the answer to "does this account currently pull from relays?", so + * account-scoped loaders that live outside this assembler — the Cashu wallet + * — can follow it instead of running for every [Account] object that happens + * to be loaded in memory. + */ + val subscribedAccounts = subscribedAccountsInternal.asStateFlow() + + override fun invalidateKeys() { + subscribedAccountsInternal.value = allKeys().mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex } + invalidateFilters() + } override fun invalidateFilters() = group.forEach { it.invalidateFilters() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt index 6af8b6d74a..0af31be8c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssemblerSubscription.kt @@ -41,7 +41,7 @@ fun AccountFilterAssemblerSubscription( // even if they are tracking the same tag. val state = remember(accountViewModel) { - AccountQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value) + AccountUiQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value) } KeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssembler.kt index 0678be343b..a6d9109bff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssembler.kt @@ -45,7 +45,7 @@ class AccountForegroundFilterAssembler( authenticator: IAuthStatus, failureTracker: RelayOfflineTracker, scope: CoroutineScope, -) : ComposeSubscriptionManager() { +) : ComposeSubscriptionManager() { val group = listOf( AccountFollowsLoaderSubAssembler(client, cache, scope, authenticator, failureTracker, ::allKeys), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssemblerSubscription.kt index 8938fa0ea0..cc3614e05d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountForegroundFilterAssemblerSubscription.kt @@ -39,7 +39,7 @@ fun AccountForegroundFilterAssemblerSubscription( ) { val state = remember(accountViewModel) { - AccountQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value) + AccountUiQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value) } LifecycleAwareKeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/BackgroundAccountSubscriptionRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/BackgroundAccountSubscriptionRegistry.kt new file mode 100644 index 0000000000..dad23b3e85 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/BackgroundAccountSubscriptionRegistry.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log + +/** + * Mounts the always-on account loaders — notifications, DMs, gift wraps, drafts, + * metadata — for accounts that have no screen. + * + * Every other mount of [AccountFilterAssembler] comes from a composable holding an + * `AccountViewModel`, which means it only ever covers the account the user is + * looking at. Accounts the user asked to "keep active in the background" had no + * such mount: they were merely loaded into memory so pushed gift wraps could be + * decrypted by their owner, and everything else about them depended on a push + * message arriving. On a device with no push (no Play Services, no UnifiedPush + * distributor, Pokey not installed) those accounts pulled nothing at all. + * + * This registry is the pull side of that promise. It holds one + * [AccountQueryState] per participating account and drives + * [AccountFilterAssembler.subscribe] / [AccountFilterAssembler.unsubscribe] + * directly — those are plain functions, not composables, so no UI has to exist. + * + * The keys carry no feed states (see [AccountQueryState.feedContentStates]) and no + * `otherAccounts` — populating the account switcher's avatars is a screen's job, + * and these accounts have no screen. + * + * Ownership of the participating set lives in + * [com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager], + * which already watches the two flags that define it, and calls [sync] on every + * change. + */ +class BackgroundAccountSubscriptionRegistry( + private val assembler: AccountFilterAssembler, +) { + companion object { + private const val TAG = "BackgroundAccountSubs" + } + + private val mounted = mutableMapOf() + + /** + * Makes the mounted set match [participants] exactly: subscribes accounts that + * just opted in, unsubscribes accounts that opted out or were unloaded, and + * leaves untouched the ones already mounted. + * + * Idempotent, so callers can hand it the same set on every emission of the + * flags flow without churning subscriptions. + */ + @Synchronized + fun sync(participants: Collection) { + val wanted = participants.associateBy { it.userProfile().pubkeyHex } + + // Unmount accounts that dropped out, and accounts whose Account object was + // replaced (re-login rebuilds it) — the stale instance holds the old + // signer and relay lists, so its filters would be built from dead state. + val stale = mounted.filter { (pubkey, state) -> wanted[pubkey] !== state.account } + stale.forEach { (pubkey, state) -> + mounted.remove(pubkey) + assembler.unsubscribe(state) + } + + var added = 0 + wanted.forEach { (pubkey, account) -> + if (pubkey !in mounted) { + val state = AccountQueryState(account, emptySet()) + mounted[pubkey] = state + assembler.subscribe(state) + added++ + } + } + + if (added > 0 || stale.isNotEmpty()) { + Log.d(TAG) { "Background account subscriptions: ${mounted.size} mounted (+$added, -${stale.size})" } + } + } + + /** Unmounts everything. Used when the master switch goes off, and on logout. */ + @Synchronized + fun clear() = sync(emptyList()) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 4318526e11..6c1f63faed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.BundledUpdate -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountUiQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -62,7 +62,7 @@ class AccountFollowsLoaderSubAssembler( val scope: CoroutineScope, val authStatus: IAuthStatus, val failureTracker: RelayOfflineTracker, - val allKeys: () -> Set, + val allKeys: () -> Set, ) : IEoseManager { private val logTag = "AccountFollowsLoaderSubAssembler" private val orchestrator = SubscriptionController(client) @@ -190,7 +190,7 @@ class AccountFollowsLoaderSubAssembler( } } - fun updateSubscriptions(keys: Set) { + fun updateSubscriptions(keys: Set) { val uniqueSubscribedAccounts = keys.associate { it.account.userProfile() to it.account } val allFilters = updateFilterForAllAccounts(uniqueSubscribedAccounts.values) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index 4da8494533..be972b722f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -54,9 +54,10 @@ class AccountNotificationsEoseFromInboxRelaysManager( // does, the EOSE time wins and this is never consulted. // // `since` means *newer than*, so this floors the query at the depth the feed already reaches - // rather than asking all-time again. It stays null until the feed holds a full page — and is - // therefore always null for an account with no UI, since nothing fills that feed. - val pagingBoundary = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() + // rather than asking all-time again. It stays null until the feed holds a full page — and a + // background account has no feed at all (no screen ever mounted one), which is why the key + // carries none and this reads null there. + val pagingBoundary = key.feedContentStates?.notifications?.lastNoteCreatedAtIfFilled() val inbox = key.account.notificationRelays.flow.value.flatMap { @@ -113,9 +114,15 @@ class AccountNotificationsEoseFromInboxRelaysManager( }, // No group/Buzz-DM watchers here any more: those filters moved to the per-channel // subscriptions, which mount and unmount with the channel itself. - key.account.scope.launch(Dispatchers.IO) { - key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { - invalidateFilters() + ) + + // Only a screen can fill a feed, so there is nothing to watch for a + // background account. + listOfNotNull( + key.feedContentStates?.let { feeds -> + key.account.scope.launch(Dispatchers.IO) { + feeds.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt index 1de1ebd3d9..a60993c8f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01N import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountUiQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -39,9 +39,9 @@ import kotlinx.coroutines.launch class AccountNotificationsEoseFromRandomRelaysManager( client: INostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun user(key: AccountQueryState) = key.account.userProfile() + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountUiQueryState) = key.account.userProfile() /** * Most notifications arrive on the user's own inbox relays. This is the straggler probe for the @@ -61,7 +61,7 @@ class AccountNotificationsEoseFromRandomRelaysManager( * the rotation timer says so. */ override fun updateFilter( - key: AccountQueryState, + key: AccountUiQueryState, since: SincePerRelayMap?, ): List { // only loads this after the feed is built, so it stays null on a quiet inbox. No week floor @@ -92,7 +92,7 @@ class AccountNotificationsEoseFromRandomRelaysManager( val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) - override fun newSub(key: AccountQueryState): Subscription { + override fun newSub(key: AccountUiQueryState): Subscription { val user = user(key) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index a247e1cf1e..28cda53204 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -3008,6 +3008,8 @@ fun mockAccountViewModel(): AccountViewModel { com.vitorpamplona.amethyst.commons.relayClient.assemblers .CashuWalletFilterAssembler(client) }, + // A mock account is always "on screen", so its wallet renders in previews. + subscribedAccounts = { MutableStateFlow(setOf(keyPair.pubKey.toHexKey())) }, cashuMintDirectoryFilterAssembler = { com.vitorpamplona.amethyst.commons.relayClient.assemblers .CashuMintDirectoryFilterAssembler(client) @@ -3068,6 +3070,8 @@ fun mockVitorAccountViewModel(): AccountViewModel { com.vitorpamplona.amethyst.commons.relayClient.assemblers .CashuWalletFilterAssembler(client) }, + // A mock account is always "on screen", so its wallet renders in previews. + subscribedAccounts = { MutableStateFlow(setOf(keyPair.pubKey.toHexKey())) }, cashuMintDirectoryFilterAssembler = { com.vitorpamplona.amethyst.commons.relayClient.assemblers .CashuMintDirectoryFilterAssembler(client) From 8e019a2d5993a75d42086e424a73fe4d69a9b474 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 00:20:41 -0400 Subject: [PATCH 018/227] feat(relays): explain discovery filters by the feed selection behind them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 4f1bd6e0c2 left the six public-chat discovery producers untagged and justified it as "they search for chats rather than serving known ones, so there is no entity to name". The first half is right and the conclusion does not follow: having no entity is not the same as having no explanation. Every one of those filters is built from a top-nav selection — Global, your follows, a hashtag, a geohash, a community — which was known where the filter was built and simply had nowhere to travel. The screen could only render them as "All", which is the one thing they are not. 3f4723437e already moved the 25 top-nav value types into commons for exactly this, so ExplainedFilter now carries the scope. It carries the per-relay value rather than the whole set: the filter is already scoped to one relay, so it holds only the slice that applies to it and no reference to the other relays' authors. It stays a typed value rather than a formatted string because purposeDetail already taught that lesson — text built in commons can never be translated, so the UI matches on the type and picks its own wording. scopedTo() stamps it at each feed's make…Filter dispatch, the last place that still knows the selection; below it the builders have flattened it into authors/#t/#g and it is unrecoverable. IFeedTopNavPerRelayFilterSet grew scopeFor(relay) so that stamping is compiler-enforced across all 11 sets rather than a type-switch that silently misses the next one added. The screen groups these rows by scope *type*, not contents: an author-based selection sends a different slice of the follow list to every relay, so keying on contents would shatter "People you follow" into one row per relay — the opposite of what the screen is for. ExplainedFilterTest had not compiled since 4d53bbea9e renamed entityId to entityIds, because `./gradlew test` does not run :commons:jvmTest. Repaired, and extended to pin the new field: the scope is a slice of the user's follow list or their chosen hashtag, and handing a relay the selection rather than the authors it already sees would tell it which of its neighbours' filters belong together. Verified on emulator-5554: Home Feed's row now reads "People you follow" with its 176 relays, as one row rather than 176. The public-chat discovery producers take the identical path but only mount while the Discover→Chats screen is open, which this device's bottom nav has no tab for, so that specific row is unproven on device. Co-Authored-By: Claude Opus 5 (1M context) --- .../badges/datasource/FilterBadges.kt | 3 +- .../calendars/datasource/SubAssemblyHelper.kt | 3 +- .../datasource/RelayGroupsDiscoveryFilter.kt | 3 +- .../RelayGroupsDiscoverySubAssembler.kt | 5 +- .../nip23LongForm/SubAssemblyHelper.kt | 3 +- .../discover/nip28Chats/SubAssemblyHelper.kt | 3 +- .../nip51FollowSets/SubAssemblyHelper.kt | 3 +- .../nip53LiveActivities/SubAssemblyHelper.kt | 3 +- .../nip72Communities/SubAssemblyHelper.kt | 3 +- .../discover/nip90DVMs/SubAssemblyHelper.kt | 3 +- .../nip99Classifieds/SubAssemblyHelper.kt | 3 +- .../browse/datasource/SubAssemblyHelper.kt | 3 +- .../datasource/GitRepositoriesFilter.kt | 3 +- .../highlights/datasource/HighlightsFilter.kt | 3 +- .../HomeOutboxEventsEoseManager.kt | 3 +- .../longs/datasource/SubAssemblyHelper.kt | 3 +- .../music/datasource/SubAssemblyHelper.kt | 3 +- .../napplets/datasource/SubAssemblyHelper.kt | 3 +- .../nests/datasource/SubAssemblyHelper.kt | 3 +- .../nsites/datasource/SubAssemblyHelper.kt | 3 +- .../pictures/datasource/SubAssemblyHelper.kt | 3 +- .../podcasts/datasource/SubAssemblyHelper.kt | 3 +- .../polls/datasource/SubAssemblyHelper.kt | 3 +- .../ActiveSubscriptionsScreen.kt | 39 ++++++++++++++- .../ActiveSubscriptionsViewModel.kt | 41 ++++++++++++--- .../shorts/datasource/SubAssemblyHelper.kt | 3 +- .../datasource/SubAssemblyHelper.kt | 3 +- .../workouts/datasource/SubAssemblyHelper.kt | 3 +- amethyst/src/main/res/values/strings.xml | 6 +++ .../IFeedTopNavPerRelayFilterSet.kt | 17 ++++++- .../AllFollowsTopNavPerRelayFilterSet.kt | 4 +- .../LocationTopNavPerRelayFilterSet.kt | 4 +- ...FavoriteAlgoFeedTopNavPerRelayFilterSet.kt | 6 ++- .../global/GlobalTopNavPerRelayFilterSet.kt | 4 +- .../hashtag/HashtagTopNavPerRelayFilterSet.kt | 4 +- .../AllCommunitiesTopNavPerRelayFilterSet.kt | 4 +- .../author/AuthorsTopNavPerRelayFilterSet.kt | 4 +- .../SingleCommunityTopNavPerRelayFilterSet.kt | 4 +- .../MutedAuthorsTopNavPerRelayFilterSet.kt | 4 +- .../relay/RelayTopNavPerRelayFilterSet.kt | 7 ++- .../unknown/UnknownTopNavPerRelayFilterSet.kt | 6 ++- .../subscriptions/ExplainedFilter.kt | 50 ++++++++++++++++++- .../subscriptions/ExplainedFilterTest.kt | 14 ++++-- 43 files changed, 247 insertions(+), 51 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt index fde9c08ecb..a6784a40e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/datasource/FilterBadges.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -47,7 +48,7 @@ fun makeBadgesFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterBadgesByMutedAuthors(feedSettings, since, defaultSince) is GlobalTopNavPerRelayFilterSet -> filterBadgesGlobal(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) private fun filterBadgesByAuthorsOnRelay( relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt index fff17417a6..5ada462939 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/datasource/SubAssemblyHelper.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavP import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.subassemblies.filterCalendarsByFollows @@ -49,4 +50,4 @@ fun makeCalendarsFilter( is LocationTopNavPerRelayFilterSet -> filterCalendarsByGeohashes(feedSettings, since, defaultSince) is MutedAuthorsTopNavPerRelayFilterSet -> filterCalendarsByMutedAuthors(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt index 446c26f336..f510d09c74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAuthors @@ -58,4 +59,4 @@ fun filterRelayGroupsDiscovery( is RelayTopNavPerRelayFilterSet -> filterRelayGroupsByRelay(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterRelayGroupsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt index 24c9ec5e14..533e638f0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.User @@ -88,7 +89,9 @@ class RelayGroupsDiscoverySubAssembler( ) } - return base + extra + // `base` is scoped by its own builder; these host-relay rosters are built here, so they get + // the same selection stamped on them rather than showing up as an unexplained extra. + return base + extra.scopedTo(feedSettings) } /** Relays that host NIP-29 groups the user is connected to: joined (kind-10009) + favorited (kind-10012). */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt index 5b084b3d7b..4caa8189af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.subassemblies.filterLongFormByAuthors @@ -54,4 +55,4 @@ fun makeLongFormFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterLongFormByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterLongFormByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt index 4cef2d6b60..3b7d225445 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.subassemblies.filterPublicChatsByAuthors @@ -54,4 +55,4 @@ fun makePublicChatsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterPublicChatsByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterPublicChatsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt index eaf3b772ae..f87c6e1bd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.subassemblies.filterFollowSetsByAuthors @@ -54,4 +55,4 @@ fun makeFollowSetsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterFollowSetsByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterFollowSetsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt index 1c971dcc0a..12cab5f61b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.subassemblies.filterLiveActivitiesByAuthors @@ -54,4 +55,4 @@ fun makeLiveActivitiesFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterLiveActivitiesByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterLiveActivitiesByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt index 362c942fce..e63a9392af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.subassemblies.filterCommunitiesByAuthors @@ -54,4 +55,4 @@ fun makeCommunitiesFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterCommunitiesByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterCommunitiesByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt index 2f984e5a2f..4623328113 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.subassemblies.filterContentDVMsByAuthors @@ -54,4 +55,4 @@ fun makeContentDVMsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterContentDVMsByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterContentDVMsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt index ef6822c706..bb0f59d628 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.subassemblies.filterClassifiedsByAuthors @@ -54,4 +55,4 @@ fun makeClassifiedsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterClassifiedsByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterClassifiedsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/SubAssemblyHelper.kt index d801597e56..3d547bbd30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/SubAssemblyHelper.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavP import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies.filterBrowseEmojiSetsByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies.filterBrowseEmojiSetsByFollows @@ -46,4 +47,4 @@ fun makeBrowseEmojiSetsFilter( is GlobalTopNavPerRelayFilterSet -> filterBrowseEmojiSetsGlobal(feedSettings, since, defaultSince) is HashtagTopNavPerRelayFilterSet -> filterBrowseEmojiSetsByHashtag(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesFilter.kt index f2d6907931..131e02ff9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesFilter.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies.filterGitRepositoriesByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies.filterGitRepositoriesByAuthors @@ -55,4 +56,4 @@ fun makeGitRepositoriesFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterGitRepositoriesByMutedAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterGitRepositoriesByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/HighlightsFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/HighlightsFilter.kt index 081757b928..10123b7961 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/HighlightsFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/highlights/datasource/HighlightsFilter.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavP import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByFollows @@ -57,4 +58,4 @@ fun makeHighlightsFilter( is LocationTopNavPerRelayFilterSet -> filterHighlightsByGeohashes(feedSettings, since, defaultSince) is MutedAuthorsTopNavPerRelayFilterSet -> filterHighlightsByMutedAuthors(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index b9e14c1e2a..bd6a313c80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.model.HomeFeedType import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.User @@ -78,7 +79,7 @@ class HomeOutboxEventsEoseManager( is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince) is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince) else -> emptyList() - } + }.scopedTo(feedSettings) // Drop the kinds the user turned off in Settings › Home from every home relay filter, so a // disabled group is never downloaded regardless of which top-nav strategy built the filters. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/SubAssemblyHelper.kt index bf38841bdf..8d31577519 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/datasource/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByAuthors @@ -55,4 +56,4 @@ fun makeLongsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterLongsByMutedAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterLongsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt index 3bd3dfb23e..66fb95cb19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/datasource/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_PLAYLIST_KINDS import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_TRACK_KINDS @@ -82,4 +83,4 @@ private fun makeMusicFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterMusicEventsByMutedAuthors(feedSettings, kinds, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterMusicEventsByCommunity(feedSettings, kinds, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/SubAssemblyHelper.kt index a4fde42d99..1989a85064 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/SubAssemblyHelper.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollow import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies.filterNappletsByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies.filterNappletsByFollows @@ -51,4 +52,4 @@ fun makeNappletsFilter( is GlobalTopNavPerRelayFilterSet -> filterNappletsGlobal(feedSettings, since, defaultSince) is MutedAuthorsTopNavPerRelayFilterSet -> filterNappletsByMutedAuthors(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/SubAssemblyHelper.kt index b0bf34fdf3..ed939269dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies.filterNestsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies.filterNestsByAuthors @@ -56,7 +57,7 @@ fun makeNestsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterNestsByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterNestsByCommunity(feedSettings, since, defaultSince) else -> return emptyList() - } + }.scopedTo(feedSettings) if (rooms.isEmpty()) return rooms // Add a single presence probe per relay we're already querying for diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/SubAssemblyHelper.kt index 27cb00fabd..9aadcfdb2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/datasource/SubAssemblyHelper.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollow import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.subassemblies.filterNsitesByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.subassemblies.filterNsitesByFollows @@ -51,4 +52,4 @@ fun makeNsitesFilter( is GlobalTopNavPerRelayFilterSet -> filterNsitesGlobal(feedSettings, since, defaultSince) is MutedAuthorsTopNavPerRelayFilterSet -> filterNsitesByMutedAuthors(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/SubAssemblyHelper.kt index 617bb66573..048cb8e4e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies.filterPicturesByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies.filterPicturesByAuthors @@ -55,4 +56,4 @@ fun makePicturesFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterPicturesByMutedAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterPicturesByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt index ff87f90c63..663eaeded3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCASTING20_METADATA_KINDS import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_EPISODE_KINDS @@ -78,4 +79,4 @@ private fun makePodcastFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince, additionalTags) is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince, additionalTags) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/SubAssemblyHelper.kt index 862140b65f..add0a3780c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/datasource/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.subassemblies.filterPollsByAuthors @@ -55,4 +56,4 @@ fun makePollsFilter( is LocationTopNavPerRelayFilterSet -> filterPollsByGeohashes(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterPollsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt index 130f83f3a5..52dc8b0c68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt @@ -60,6 +60,15 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurposeGroup import com.vitorpamplona.amethyst.model.LocalCache @@ -284,6 +293,29 @@ private fun PurposeCard( } } +/** + * The feed selection a discovery filter is searching within, in the app's own words. + * + * Hashtags and geohashes read out their own values — they are short, and the whole point is *which* + * hashtag. The author-based selections do not: a follow list is thousands of keys, and per relay it + * is a different slice of them, so naming the kind is the honest summary. + */ +@Composable +private fun scopeLabel(scope: IFeedTopNavPerRelayFilter): String? = + when (scope) { + is GlobalTopNavPerRelayFilter -> stringRes(R.string.active_subs_scope_global) + is AllFollowsTopNavPerRelayFilter -> stringRes(R.string.active_subs_scope_follows) + is AuthorsTopNavPerRelayFilter -> stringRes(R.string.active_subs_scope_authors) + is MutedAuthorsTopNavPerRelayFilter -> stringRes(R.string.active_subs_scope_muted) + is AllCommunitiesTopNavPerRelayFilter -> stringRes(R.string.active_subs_scope_all_communities) + is FavoriteAlgoFeedTopNavPerRelayFilter -> stringRes(R.string.active_subs_scope_algo) + is HashtagTopNavPerRelayFilter -> scope.hashtags.sorted().joinToString(", ") { "#$it" } + is LocationTopNavPerRelayFilter -> scope.geotags.sorted().joinToString(", ") + // The community and the relay already name themselves — the community through its own + // entity row, the relay through the row's relay list — so repeating it here would be noise. + else -> null + } + /** The entity's name, coloured as a link only when tapping it actually goes somewhere. */ @Composable private fun EntityLabelText( @@ -400,7 +432,12 @@ private fun EntityBlock( // Deliberately not falling back to `entity.detail`: that field is a developer breadcrumb set in // `commons`, where Android string resources do not exist, so it is hardcoded English and could // never be translated. A localized "no entity" line is better than an untranslatable one. - val label = resolved?.name ?: stringRes(R.string.active_subs_no_entity) + // + // A discovery filter has no entity by nature — it searches for chats and articles rather than + // serving ones already named — but it does carry the selection it searches within, which is a + // far better answer than "no entity". The scope arrives as a typed value for exactly this + // reason: the wording is chosen here, where translations exist. + val label = resolved?.name ?: entity.scope?.let { scopeLabel(it) } ?: stringRes(R.string.active_subs_no_entity) Column { Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt index 889cd6cb08..1074c360a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -56,11 +57,29 @@ import kotlinx.coroutines.withContext data class SubscriptionEntityRow( /** Null when the filter named no entity — "the rest of this purpose", not a real entity. */ val entityId: HexKey?, + /** + * The top-nav selection behind a filter that names no entity — what a discovery filter is + * searching *within*. Null for filters that name a real entity, which speaks for itself. + */ + val scope: IFeedTopNavPerRelayFilter?, val detail: String?, val relays: List, val filterCount: Int, ) +/** + * What makes two filters the same row. + * + * [scopeKey] is the scope's **type**, not its contents: an author-based selection carries a + * different slice of the follow list to every relay, so keying on contents would shatter "People you + * follow" into one row per relay — the opposite of what this screen is for. Selections whose + * contents are the same everywhere (a hashtag, a geohash) render theirs from the retained instance. + */ +private data class EntityKey( + val entityId: HexKey?, + val scopeKey: String?, +) + @Immutable data class SubscriptionPurposeRow( val purpose: SubPurpose, @@ -109,8 +128,9 @@ class ActiveSubscriptionsViewModel : ViewModel() { val client = Amethyst.instance.client // account -> purpose -> entity -> relays / count - val byAccount = mutableMapOf>>>() - val detailOf = mutableMapOf, String?>() + val byAccount = mutableMapOf>>>() + val detailOf = mutableMapOf, String?>() + val scopeOf = mutableMapOf, IFeedTopNavPerRelayFilter>() var total = 0 var untagged = 0 val allRelays = mutableSetOf() @@ -124,17 +144,23 @@ class ActiveSubscriptionsViewModel : ViewModel() { return@forEach } allRelays.add(relay) + // Discovery filters name no entity — they go looking for things rather than + // serving known ones — but they do carry the selection they search within, which + // is what keeps them from collapsing into one nameless row per purpose. + val scopeKey = explained.scope?.let { it::class.simpleName } // A batched filter serves several entities at once — relay-group state is one #d // filter per host relay carrying every joined group on it — so it contributes a // row to each of them rather than collapsing to "All". val entities: List = explained.entityIds?.takeIf { it.isNotEmpty() } ?: listOf(null) entities.forEach { entityId -> + val key = EntityKey(entityId, scopeKey) byAccount .getOrPut(explained.accountPubKey) { mutableMapOf() } .getOrPut(explained.purpose) { mutableMapOf() } - .getOrPut(entityId) { mutableListOf() } + .getOrPut(key) { mutableListOf() } .add(relay) - detailOf[explained.purpose to entityId] = explained.purposeDetail + detailOf[explained.purpose to key] = explained.purposeDetail + explained.scope?.let { scopeOf.getOrPut(explained.purpose to key) { it } } } } } @@ -147,10 +173,11 @@ class ActiveSubscriptionsViewModel : ViewModel() { .map { (purpose, entities) -> val entityRows = entities - .map { (entityId, relays) -> + .map { (key, relays) -> SubscriptionEntityRow( - entityId = entityId, - detail = detailOf[purpose to entityId], + entityId = key.entityId, + scope = scopeOf[purpose to key], + detail = detailOf[purpose to key], relays = relays.distinct().sortedBy { it.url }, filterCount = relays.size, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/SubAssemblyHelper.kt index e53a715c45..5b7100b3cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/datasource/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies.filterShortsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.subassemblies.filterShortsByAuthors @@ -55,4 +56,4 @@ fun makeShortsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterShortsByMutedAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterShortsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SubAssemblyHelper.kt index 36339f10e3..a5f4f7969d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/softwareapps/datasource/SubAssemblyHelper.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavP import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.subassemblies.filterSoftwareAppsByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.subassemblies.filterSoftwareAppsByFollows @@ -48,4 +49,4 @@ fun makeSoftwareAppsFilter( is HashtagTopNavPerRelayFilterSet -> filterSoftwareAppsByHashtag(feedSettings, since, defaultSince) is MutedAuthorsTopNavPerRelayFilterSet -> filterSoftwareAppsByMutedAuthors(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/SubAssemblyHelper.kt index 33f39d9d4f..72ec1e2da0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/SubAssemblyHelper.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommuni import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.scopedTo import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsByAuthors @@ -55,4 +56,4 @@ fun makeWorkoutsFilter( is MutedAuthorsTopNavPerRelayFilterSet -> filterWorkoutsByMutedAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterWorkoutsByCommunity(feedSettings, since, defaultSince) else -> emptyList() - } + }.scopedTo(feedSettings) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6dcd4e12e3..3a7bb51880 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2151,6 +2151,12 @@ %1$s \u00b7 %2$s Not attributed to an account All + Everyone + People you follow + A chosen list of people + Muted people + Your communities + A favorite algo feed %1$d%% of all subscriptions filters relays requests reqs connections why diagnostics Posts by people you follow, read from the relays each of them publishes to. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt index ece74cd8c9..4addcf75f7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/IFeedTopNavPerRelayFilterSet.kt @@ -20,4 +20,19 @@ */ package com.vitorpamplona.amethyst.commons.model.topNavFeeds -interface IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +interface IFeedTopNavPerRelayFilterSet { + /** + * What this feed selection asks of one relay — the follows, hashtags, geohashes or communities + * scoped to it — or null when the set says nothing per relay. + * + * Declared here so an `ExplainedFilter` can carry the scope that produced it. A discovery filter + * names no entity (it searches for things rather than serving known ones), which used to leave + * the Active Subscriptions screen with a nameless row it could only label "no entity". The + * selection behind it was always known at build time; it just had nowhere to travel. Returning + * the per-relay value rather than the whole set keeps it exact: the filter is already per relay, + * so it carries only the slice that applies to it. + */ + fun scopeFor(relay: NormalizedRelayUrl): IFeedTopNavPerRelayFilter? +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt index cc7481733b..1e550dbdda 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/allFollows/AllFollowsTopNavPerRelayFilterSet.kt @@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class AllFollowsTopNavPerRelayFilterSet( val set: Map, -) : IFeedTopNavPerRelayFilterSet +) : IFeedTopNavPerRelayFilterSet { + override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt index 565cbb2c07..00f3ed6995 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/aroundMe/LocationTopNavPerRelayFilterSet.kt @@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class LocationTopNavPerRelayFilterSet( val set: Map, -) : IFeedTopNavPerRelayFilterSet +) : IFeedTopNavPerRelayFilterSet { + override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt index 9ad81f1e0e..130ff04adc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt @@ -39,4 +39,8 @@ class FavoriteAlgoFeedTopNavPerRelayFilterSet( val contentFetches: Map, val listenRelays: Set, val requestIds: Set, -) : IFeedTopNavPerRelayFilterSet +) : IFeedTopNavPerRelayFilterSet { + // Only the content half is per-relay. [listenRelays] is where the DVMs answer, which is a + // delivery address rather than a scope, so a filter aimed there carries none. + override fun scopeFor(relay: NormalizedRelayUrl) = contentFetches[relay] +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt index 26a032841b..128d9f8386 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/global/GlobalTopNavPerRelayFilterSet.kt @@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class GlobalTopNavPerRelayFilterSet( val set: Map, -) : IFeedTopNavPerRelayFilterSet +) : IFeedTopNavPerRelayFilterSet { + override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt index 1621d64ca3..8ae9a41aec 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/hashtag/HashtagTopNavPerRelayFilterSet.kt @@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class HashtagTopNavPerRelayFilterSet( val set: Map, -) : IFeedTopNavPerRelayFilterSet +) : IFeedTopNavPerRelayFilterSet { + override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt index 541647c10f..b7d9406a3c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavPerRelayFilterSet.kt @@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class AllCommunitiesTopNavPerRelayFilterSet( val set: Map, -) : IFeedTopNavPerRelayFilterSet +) : IFeedTopNavPerRelayFilterSet { + override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt index 6c72c0b6d2..cffff74dc5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/author/AuthorsTopNavPerRelayFilterSet.kt @@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class AuthorsTopNavPerRelayFilterSet( val set: Map, -) : IFeedTopNavPerRelayFilterSet +) : IFeedTopNavPerRelayFilterSet { + override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt index d64a809749..d0178373bd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/community/SingleCommunityTopNavPerRelayFilterSet.kt @@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class SingleCommunityTopNavPerRelayFilterSet( val set: Map, -) : IFeedTopNavPerRelayFilterSet +) : IFeedTopNavPerRelayFilterSet { + override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt index 2f52a15003..942adce733 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/muted/MutedAuthorsTopNavPerRelayFilterSet.kt @@ -25,4 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class MutedAuthorsTopNavPerRelayFilterSet( val set: Map, -) : IFeedTopNavPerRelayFilterSet +) : IFeedTopNavPerRelayFilterSet { + override fun scopeFor(relay: NormalizedRelayUrl) = set[relay] +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt index b3d891b5ba..bf57ace838 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/relay/RelayTopNavPerRelayFilterSet.kt @@ -20,9 +20,14 @@ */ package com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class RelayTopNavPerRelayFilterSet( val relayUrl: NormalizedRelayUrl, -) : IFeedTopNavPerRelayFilterSet +) : IFeedTopNavPerRelayFilterSet { + // The relay *is* the whole selection here, so there is nothing per-relay left to say — the + // filter's own relay already carries it. + override fun scopeFor(relay: NormalizedRelayUrl): IFeedTopNavPerRelayFilter? = null +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt index 7c01d40708..612c5da309 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/unknown/UnknownTopNavPerRelayFilterSet.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.model.topNavFeeds.unknown +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -object UnknownTopNavPerRelayFilterSet : IFeedTopNavPerRelayFilterSet +object UnknownTopNavPerRelayFilterSet : IFeedTopNavPerRelayFilterSet { + override fun scopeFor(relay: NormalizedRelayUrl): IFeedTopNavPerRelayFilter? = null +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt index 65d9821165..7c46aea5ac 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -89,6 +91,24 @@ class ExplainedFilter( * account alive. */ val accountPubKey: HexKey? = null, + /** + * The top-nav selection that produced this filter — Global, the user's follows, a hashtag, a + * geohash, a community. + * + * [entityIds] answers "which known thing does this serve"; discovery filters have no such thing, + * because they go looking for chats/articles/streams rather than serving ones already named. That + * is not the same as having no explanation: the feed selection behind them was always known where + * the filter was built, it simply had nowhere to travel, so the screen could only render those + * rows as "no entity". + * + * The per-relay value, not the whole set: this filter is already scoped to one relay, so it + * carries only the slice that applies to it and holds no reference to the other relays' authors. + * + * A typed value rather than a formatted string, because [purposeDetail] taught the lesson — + * text built in `commons` can never be translated. The UI matches on the type and picks its own + * localized wording. + */ + val scope: IFeedTopNavPerRelayFilter? = null, ) : Filter(ids, authors, kinds, tags, tagsAll, since, until, limit, search) { override fun copy( ids: List?, @@ -100,7 +120,7 @@ class ExplainedFilter( until: Long?, limit: Int?, search: String?, - ) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityIds, accountPubKey) + ) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityIds, accountPubKey, scope) companion object { /** Tags [filter] with a [purpose], preserving every protocol field. */ @@ -110,6 +130,7 @@ class ExplainedFilter( detail: String? = null, entityIds: List? = null, accountPubKey: HexKey? = null, + scope: IFeedTopNavPerRelayFilter? = null, ) = ExplainedFilter( filter.ids, filter.authors, @@ -124,6 +145,7 @@ class ExplainedFilter( detail, entityIds, accountPubKey, + scope, ) } } @@ -173,7 +195,31 @@ fun List.attributedTo(accountPubKey: HexKey): List.scopedTo(feedSettings: IFeedTopNavPerRelayFilterSet): List = + map { relayFilter -> + val filter = relayFilter.filter + val scope = if (filter is ExplainedFilter && filter.scope == null) feedSettings.scopeFor(relayFilter.relay) else null + if (filter is ExplainedFilter && scope != null) { + RelayBasedFilter( + relay = relayFilter.relay, + filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, filter.accountPubKey, scope), ) } else { relayFilter diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt index fb348a933e..61baa6bc71 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.FiltersChanged import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -56,8 +57,9 @@ class ExplainedFilterTest { limit = 20, purpose = SubPurpose.NOTIFICATIONS, purposeDetail = "inbox relays for the active account", - entityId = "cafe0000000000000000000000000000000000000000000000000000000000ff", + entityIds = listOf("cafe0000000000000000000000000000000000000000000000000000000000ff"), accountPubKey = pubkey, + scope = HashtagTopNavPerRelayFilter(setOf("askednostr")), ) // ---- the wire must not learn why we asked ------------------------------- @@ -81,6 +83,11 @@ class ExplainedFilterTest { assertFalse("detail leaked to the wire: $json", json.contains("inbox relays", ignoreCase = true)) assertFalse("entityId leaked to the wire: $json", json.contains("cafe0000", ignoreCase = true)) assertFalse("accountPubKey leaked as a field: $json", json.contains("accountPubKey", ignoreCase = true)) + // The scope is the feed selection behind the filter — a hashtag here, but for a follows feed + // it is a slice of the user's follow list. Handing a relay the *selection* rather than the + // authors it already sees would tell it which of its neighbours' filters belong together. + assertFalse("scope leaked to the wire: $json", json.contains("askednostr", ignoreCase = true)) + assertFalse("scope leaked as a field: $json", json.contains("scope", ignoreCase = true)) } /** The filter is serialized as part of a REQ, so check the real command too, not just the filter. */ @@ -106,9 +113,10 @@ class ExplainedFilterTest { assertTrue("copy() must stay an ExplainedFilter", advanced is ExplainedFilter) assertEquals(SubPurpose.NOTIFICATIONS, advanced.purposeOrNull()) assertEquals("inbox relays for the active account", (advanced as ExplainedFilter).purposeDetail) - // entityId/accountPubKey ride the same path and would vanish just as silently - assertEquals("cafe0000000000000000000000000000000000000000000000000000000000ff", advanced.entityId) + // entityIds/accountPubKey/scope ride the same path and would vanish just as silently + assertEquals(listOf("cafe0000000000000000000000000000000000000000000000000000000000ff"), advanced.entityIds) assertEquals(pubkey, advanced.accountPubKey) + assertEquals(setOf("askednostr"), (advanced.scope as? HashtagTopNavPerRelayFilter)?.hashtags) assertEquals(1_785_379_272L, advanced.since) // and the protocol fields came along untouched assertEquals(listOf(pubkey), advanced.authors) From 3fe936bedbad70f6bb15aed800a8d740ef154b48 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 29 Jul 2026 16:41:02 +0200 Subject: [PATCH 019/227] docs --- ...026-07-29-location-foreground-gate-impl.md | 1457 +++++++++++++++++ .../2026-07-29-location-foreground-gate.md | 773 +++++++++ 2 files changed, 2230 insertions(+) create mode 100644 amethyst/plans/2026-07-29-location-foreground-gate-impl.md create mode 100644 amethyst/plans/2026-07-29-location-foreground-gate.md diff --git a/amethyst/plans/2026-07-29-location-foreground-gate-impl.md b/amethyst/plans/2026-07-29-location-foreground-gate-impl.md new file mode 100644 index 0000000000..2f4afdb777 --- /dev/null +++ b/amethyst/plans/2026-07-29-location-foreground-gate-impl.md @@ -0,0 +1,1457 @@ +# Location Foreground Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop Amethyst holding a location registration when no activity is started, register on one appropriate provider instead of four, and make `location.ms` measure real listening time. + +**Architecture:** A three-state gate over *permission × foreground* inside `LocationState` replaces the permission-only gate, so the registration is released whenever the app is backgrounded. `LocationFlow` selects one provider from an ordered ladder instead of shotgunning `allProviders`, and owns the `onListening` hook so accounting cannot start without a live registration. A `RefCountedSession` serialises the meter's refcount with the transition it drives. + +**Tech Stack:** Kotlin, kotlinx.coroutines Flow/StateFlow, `android.location.LocationManager` (no Play Services), JUnit 4 + MockK + kotlinx-coroutines-test. + +**Design spec:** `amethyst/plans/2026-07-29-location-foreground-gate.md` — read it before starting. This plan implements it; where the two disagree, the spec is wrong and should be corrected. + +## Global Constraints + +- Module is `amethyst` (Android only). Package root `com.vitorpamplona.amethyst`. +- `minSdk = 26`, `targetSdk = 37`, `compileSdk = 37` (`gradle/libs.versions.toml:12-14`). +- Every new `.kt` file starts with the MIT licence header — copy it verbatim from `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt:1-20`, changing nothing. +- Logging uses `com.vitorpamplona.quartz.utils.Log`, never `android.util.Log`. Use the lambda overload (`Log.d("Tag") { "msg $x" }`) when the message interpolates **and there is no throwable**. When a throwable must be logged, use the three-argument form `Log.w("Tag", "message", e)` — the lambda overloads take no `Throwable` (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Log.kt:74-79`), so "converting" such a call to a lambda silently discards the stack trace. That is the exact anti-pattern the repo's `find-non-lambda-logs` skill flags. +- Never pass `--no-verify` to `git commit`. The pre-commit hook runs `./gradlew spotlessCheck` and nothing else — no compilation — so it passes even at the two points in this plan where the module is temporarily red. +- No new third-party dependencies. All libraries used here are already declared. +- Never write a fully-qualified class name inline in a function body — add an `import`. +- Run `./gradlew spotlessApply` before every commit. +- Do not push and do not open a PR. Commit locally only. +- Unit tests live in `amethyst/src/test/java/...`, run with `./gradlew :amethyst:testPlayDebugUnitTest`. + +## Naming contract + +These names are used across tasks. Use them exactly. + +| Name | Defined in | Signature | +|---|---|---| +| `RefCountedSession` | Task 2 | `class RefCountedSession(setSessionActive: (Boolean) -> Unit)`, method `fun setActive(active: Boolean)` | +| `LocationProviderLadder.chooseProviders` | Task 3 | `fun chooseProviders(sdkInt: Int, hasFine: Boolean, exists: (String) -> Boolean): List` | +| `LocationFlow` | Task 4 | `class LocationFlow(locationManager: LocationManager, sdkInt: Int = Build.VERSION.SDK_INT, hasFine: Boolean = false)`, method `fun get(minTimeMs: Long, minDistanceM: Float, onListening: ((Boolean) -> Unit)? = null): Flow` | +| `LocationState` | Task 5 | `class LocationState(context: Context, scope: CoroutineScope, isForeground: StateFlow, onListening: ((Boolean) -> Unit)? = null, locationSource: (Long, Float) -> Flow = …)` | +| `LocationState.COARSE_MIN_TIME` | Task 5 | `const val = 60_000L` | +| `LocationState.COARSE_MIN_DISTANCE` | Task 5 | `const val = 500.0f` | +| `LocationState.PRECISE_MIN_TIME` | Task 5 | `const val = 10_000L` | +| `LocationState.PRECISE_MIN_DISTANCE` | Task 5 | `const val = 100.0f` | +| `LocationState.BACKGROUND_GRACE_MS` | Task 5 | `const val = 5_000L` | + +`LocationState.MIN_TIME` and `LocationState.MIN_DISTANCE` are **deleted** in Task 5. Their only readers are `LocationState.kt:80`, `:119` and `LocationFlow.kt:29-30,42-43`, all rewritten by Tasks 4–5. + +## File structure + +| File | Task | Responsibility | +|---|---|---| +| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt` | 2 | Serialise a refcount with the boolean transition it drives | +| `amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt` | 2 | ↑ | +| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt` | 3 | Pure provider-selection policy | +| `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt` | 3 | ↑ | +| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt` | 4 | Own the OS registration and the paired listening hook | +| `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt` | 4 | ↑ | +| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt` | 5 | Gate on permission × foreground; expose the two geohash StateFlows | +| `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationStateTest.kt` | 5 | ↑ | +| `amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt` | 6 | Wire the foreground signal and the refcounted meter | + +--- + +### Task 1: Verify Hypothesis H1 on an API 26 emulator + +The spec's §H1 predicts that today's `allProviders` loop throws `SecurityException` on `gps`, `passive` and `fused` below API 31, because those required `ACCESS_FINE_LOCATION` before Android 12 and Amethyst holds coarse only. If true, location is **already broken** on Android 8–11 and this change fixes it — which belongs in the PR description. If false, the PR must not claim it. + +This task changes no production code. It is first because the spec says to verify before implementing, and because the answer decides one paragraph of the PR. + +**Files:** +- Modify (temporarily, reverted in Step 5): `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt:55` + +**Interfaces:** +- Consumes: nothing +- Produces: a written observation recorded in Step 6; no code + +- [ ] **Step 1: Boot the API 26 emulator** + +```bash +emulator -avd Medium_Phone_API_26_8_ -no-snapshot-load & +adb wait-for-device +adb shell getprop ro.build.version.sdk +``` + +Expected: prints `26`. + +If the AVD fails to boot, stop and report it. Do not substitute a different API level without saying so — the claim is specifically about API 26–30. + +- [ ] **Step 2: Add a temporary log of the provider order** + +`LocationFlow.kt`, immediately before the `locationManager.allProviders.forEach {` line (currently line 55), insert: + +```kotlin + Log.w("LocationFlowH1") { "allProviders order = ${locationManager.allProviders}" } +``` + +This is the ordering evidence the spec requires: the leak consequence only occurs if `network` precedes a fine-only provider, so the PR cannot claim a leak without seeing the order. + +- [ ] **Step 3: Install and grant coarse location only** + +```bash +./gradlew :amethyst:installPlayDebug +adb shell pm grant com.vitorpamplona.amethyst android.permission.ACCESS_COARSE_LOCATION +adb logcat -c +``` + +- [ ] **Step 4: Trigger a location subscription and capture the result** + +Launch the app, sign in to any account, and open the top-nav filter spinner on Home, selecting "Around Me". Then: + +```bash +adb logcat -d | grep -E "LocationFlowH1|SecurityException|LocationFlow" +``` + +Record verbatim: +1. the `allProviders order = [...]` line +2. whether a `SecurityException` appears, and which provider it names +3. whether any `Requesting Updates` line precedes the exception + +- [ ] **Step 5: Revert the temporary log** + +```bash +git checkout -- amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt +git status --short +``` + +Expected: no changes to `LocationFlow.kt`. + +- [ ] **Step 6: Record the observation in the spec** + +Append to the `## Hypothesis H1` section of `amethyst/plans/2026-07-29-location-foreground-gate.md`, replacing `<...>` with what Step 4 actually showed: + +```markdown +### H1 verification result (2026-07-29, Medium_Phone_API_26_8_, API 26) + +- `allProviders` order: `` +- `SecurityException`: `` +- Registration attempted before the throw: `` + +Conclusion: location is `` on API 26 with coarse-only +permission; registrations `` leak. +``` + +- [ ] **Step 7: If H1 is FALSE, amend the plan before continuing** + +The spec says the design is "correct either way", which is true only in one +direction. Task 3 hard-codes H1's conclusion in `COARSE_ONLY_LEGACY_LADDER` and +two of its tests. If Step 4 showed **no** `SecurityException`, then coarse +permission does reach `gps`/`fused`/`passive` below API 31 on this build, and +restricting pre-31 devices to `network` would be a conclusion drawn from +evidence that contradicts it. It is safe for `geohashStateFlow` — `network` is +the right provider for a 5 km cell either way — but it needlessly denies +`preciseGeohashStateFlow` any provider capable of building-level precision on +Android 8–11. + +So, **only if no `SecurityException` appeared**, make these three amendments +before starting Task 3, and say in the commit message that H1 was disproved: + +1. In Task 3's implementation, delete `COARSE_ONLY_LEGACY_LADDER` and the + `sdkInt`/`hasFine` branch. `chooseProviders` becomes + `fun chooseProviders(exists: (String) -> Boolean): List = FULL_LADDER.filter(exists)`. +2. In Task 3's test, delete `coarseOnlyBelowApi31GetsNetworkOnly` and + `coarseOnlyBelowApi31WithNoNetworkProviderGetsNothing`, and drop the + `sdkInt`/`hasFine` arguments from the remaining four. +3. In Task 4, drop the `sdkInt` and `hasFine` constructor parameters from + `LocationFlow` and the corresponding arguments from its six tests. +4. In Task 4's `get`, update the call site to match amendment 1: + `LocationProviderLadder.chooseProviders(sdkInt, hasFine) { it in providers }` + becomes `LocationProviderLadder.chooseProviders { it in providers }`. + +The per-rung `SecurityException` fall-through stays in **both** cases — it is +what makes the ladder robust to whatever the runtime check actually does, and +it is why H1 being wrong costs nothing. + +- [ ] **Step 8: Commit** + +```bash +git add amethyst/plans/2026-07-29-location-foreground-gate.md +git commit -m "docs(amethyst): record H1 verification result on API 26" +``` + +--- + +### Task 2: RefCountedSession + +**Files:** +- Create: `amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt` +- Test: `amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt` + +**Interfaces:** +- Consumes: nothing +- Produces: `class RefCountedSession(setSessionActive: (Boolean) -> Unit)` with `fun setActive(active: Boolean)`. Task 6 constructs it as `RefCountedSession(locationSession::setActive)`. + +- [ ] **Step 1: Write the failing test** + +Create `amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt` with the MIT header (copy `LocationState.kt:1-20` verbatim), then: + +```kotlin +package com.vitorpamplona.amethyst.service.resourceusage + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RefCountedSessionTest { + @Test + fun overlappingHoldersKeepTheSessionOpenAndReportOnlyTransitions() { + val calls = mutableListOf() + val session = RefCountedSession { calls.add(it) } + + session.setActive(true) // holders 1 — inactive -> active + session.setActive(true) // holders 2 — a second listener joins, no transition + session.setActive(false) // holders 1 — the first one leaves, still active + + assertEquals("only the 0 -> 1 edge is a transition", listOf(true), calls) + + session.setActive(false) // holders 0 — the last one leaves + + assertEquals(listOf(true, false), calls) + } + + @Test + fun unmatchedReleaseDoesNotDriveTheCountNegative() { + val calls = mutableListOf() + val session = RefCountedSession { calls.add(it) } + + session.setActive(false) + session.setActive(false) + + assertEquals("releasing an idle session is a no-op", emptyList(), calls) + + // If the count had gone to -2, one acquire would leave it at -1 and + // report inactive. It must open the session instead. + session.setActive(true) + + assertEquals(listOf(true), calls) + } + + @Test + fun aSingleHolderOpensAndClosesTheSession() { + val calls = mutableListOf() + val session = RefCountedSession { calls.add(it) } + + session.setActive(true) + session.setActive(false) + + assertEquals(listOf(true, false), calls) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*RefCountedSessionTest*' +``` + +Expected: FAIL — compilation error, `Unresolved reference: RefCountedSession`. + +- [ ] **Step 3: Write the implementation** + +Create `amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt` with the MIT header, then: + +```kotlin +package com.vitorpamplona.amethyst.service.resourceusage + +/** + * Refcounts a boolean session so overlapping holders don't close each other's + * segment. [LocationState][com.vitorpamplona.amethyst.service.location.LocationState] + * exposes two independent location flows that can both be listening at once — + * the "Around Me" feed plus an open geohash chat — and a bare + * [SessionTimeIntegrator] would close the segment when either one stops. + * + * The count and the transition it drives are taken under one lock. An + * [java.util.concurrent.atomic.AtomicInteger] beside an unsynchronised call is + * not enough: two threads can leave the counter at 1 while the last + * `setActive(false)` lands after the `setActive(true)`, latching the session + * off with a holder still active. + * + * Takes the setter as a lambda rather than a [SessionTimeIntegrator] because + * that is all it needs — and because constructing a real integrator drags in a + * [ResourceUsageAccountant] and a store file to observe one boolean. + * + * Reports **transitions only**, not every call. A 1 -> 2 acquire would otherwise + * re-enter [SessionTimeIntegrator.setActive] with the session already open, + * splitting one segment into two. That happens to be arithmetically harmless + * (`account()` adds each piece, and the pieces are contiguous), and it does not + * inflate a `*.starts` counter either, because [SessionTimeIntegrator] already + * guards its starts increment on `prev == null`. Transition-only is simply the + * contract the name implies, and it keeps the class honest for a future caller + * that reacts to the callback rather than integrating it. + * + * Releases must be paired with acquires. This class cannot tell an unpaired + * release from a real one, so callers guarantee the pairing; see `LocationFlow`, + * which throws rather than reaching `awaitClose` when nothing registered. + */ +class RefCountedSession( + private val setSessionActive: (Boolean) -> Unit, +) { + private val lock = Any() + private var holders = 0 + + fun setActive(active: Boolean) { + synchronized(lock) { + val wasActive = holders > 0 + holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0) + val isActive = holders > 0 + if (isActive != wasActive) setSessionActive(isActive) + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*RefCountedSessionTest*' +``` + +Expected: PASS, 3 tests. + +- [ ] **Step 5: Format and commit** + +```bash +./gradlew spotlessApply +git add amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt \ + amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt +git commit -m "feat(amethyst): add RefCountedSession for overlapping ledger holders" +``` + +--- + +### Task 3: Provider ladder + +**Files:** +- Create: `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt` +- Test: `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt` + +**Interfaces:** +- Consumes: nothing +- Produces: `LocationProviderLadder.chooseProviders(sdkInt: Int, hasFine: Boolean, exists: (String) -> Boolean): List`. Task 4 calls it. + +- [ ] **Step 1: Write the failing test** + +Create `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt` with the MIT header, then: + +```kotlin +package com.vitorpamplona.amethyst.service.location + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LocationProviderLadderTest { + private val all = setOf("fused", "network", "gps", "passive") + + @Test + fun modernDevicePrefersFusedThenFallsBackInOrder() { + assertEquals( + listOf("fused", "network", "gps", "passive"), + LocationProviderLadder.chooseProviders(sdkInt = 31, hasFine = false) { it in all }, + ) + } + + @Test + fun missingProvidersAreFilteredOutButOrderIsKept() { + val present = setOf("network", "passive") + + assertEquals( + listOf("network", "passive"), + LocationProviderLadder.chooseProviders(sdkInt = 37, hasFine = false) { it in present }, + ) + } + + @Test + fun coarseOnlyBelowApi31GetsNetworkOnly() { + // gps, passive and fused all required ACCESS_FINE_LOCATION before + // Android 12 (see Hypothesis H1 in the design spec). + assertEquals( + listOf("network"), + LocationProviderLadder.chooseProviders(sdkInt = 30, hasFine = false) { it in all }, + ) + } + + @Test + fun fineBelowApi31GetsTheFullLadder() { + assertEquals( + listOf("fused", "network", "gps", "passive"), + LocationProviderLadder.chooseProviders(sdkInt = 26, hasFine = true) { it in all }, + ) + } + + @Test + fun coarseOnlyBelowApi31WithNoNetworkProviderGetsNothing() { + val present = setOf("gps", "passive") + + assertEquals( + emptyList(), + LocationProviderLadder.chooseProviders(sdkInt = 28, hasFine = false) { it in present }, + ) + } + + @Test + fun noProvidersAtAllGetsNothing() { + assertEquals( + emptyList(), + LocationProviderLadder.chooseProviders(sdkInt = 37, hasFine = false) { false }, + ) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*LocationProviderLadderTest*' +``` + +Expected: FAIL — compilation error, `Unresolved reference: LocationProviderLadder`. + +- [ ] **Step 3: Write the implementation** + +Create `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt` with the MIT header, then: + +```kotlin +package com.vitorpamplona.amethyst.service.location + +import android.location.LocationManager +import android.os.Build + +/** + * Picks which location providers to try, in order. + * + * Deliberately selects on **provider existence**, never on + * [LocationManager.isProviderEnabled]. A registration on a disabled provider + * goes live by itself when the user enables location — including from the + * quick-settings shade without leaving the app, which is exactly what someone + * does after seeing an empty "Around Me" feed. An enabled-state guard evaluated + * once at subscription start would lose that. + * + * Below API 31, `gps`, `passive` and `fused` required `ACCESS_FINE_LOCATION`; + * only `network` accepted `ACCESS_COARSE_LOCATION`. Approximate location, which + * lets a coarse-only app request any provider and receive a fuzzed result, is an + * Android 12 change. Amethyst declares coarse only, so [hasFine] is always false + * in production — it is a parameter so the function is total over the permission + * axis and both sides of the API branch are testable, not because fine access is + * anticipated. + * + * Returns the ordered candidate list rather than a single choice so the caller + * can fall through to the next rung if a registration is refused. An empty list + * means no compatible provider exists. + */ +object LocationProviderLadder { + // Compile-time String constants, inlined by the compiler, so naming + // FUSED_PROVIDER (added in API 31) is safe on older runtimes. + private val FULL_LADDER = + listOf( + LocationManager.FUSED_PROVIDER, + LocationManager.NETWORK_PROVIDER, + LocationManager.GPS_PROVIDER, + LocationManager.PASSIVE_PROVIDER, + ) + + private val COARSE_ONLY_LEGACY_LADDER = listOf(LocationManager.NETWORK_PROVIDER) + + fun chooseProviders( + sdkInt: Int, + hasFine: Boolean, + exists: (String) -> Boolean, + ): List { + val ladder = + if (sdkInt >= Build.VERSION_CODES.S || hasFine) { + FULL_LADDER + } else { + COARSE_ONLY_LEGACY_LADDER + } + + return ladder.filter(exists) + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*LocationProviderLadderTest*' +``` + +Expected: PASS, 6 tests. + +- [ ] **Step 5: Format and commit** + +```bash +./gradlew spotlessApply +git add amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt \ + amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt +git commit -m "feat(amethyst): add location provider ladder" +``` + +--- + +### Task 4: LocationFlow — one provider, paired listening hook + +**Files:** +- Modify (full rewrite of the class body): `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt` +- Test: `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt` + +**Interfaces:** +- Consumes: `LocationProviderLadder.chooseProviders` (Task 3) +- Produces: `class LocationFlow(locationManager: LocationManager, sdkInt: Int = Build.VERSION.SDK_INT, hasFine: Boolean = false)` with `fun get(minTimeMs: Long, minDistanceM: Float, onListening: ((Boolean) -> Unit)? = null): Flow`. Task 5 constructs it. + +The constructor takes a `LocationManager`, **not** a `Context`, so it can be tested with a MockK stub. `LocationState` does the `getSystemService` lookup. + +- [ ] **Step 1: Write the failing test** + +Create `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt` with the MIT header, then: + +```kotlin +package com.vitorpamplona.amethyst.service.location + +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LocationFlowTest { + /** + * A LocationManager that reports [providers] and refuses [denied] with a + * SecurityException, mimicking the pre-API-31 fine-location requirement. + */ + private fun manager( + providers: List, + denied: Set = emptySet(), + ): LocationManager { + val lm = mockk(relaxed = true) + every { lm.allProviders } returns providers + every { lm.getLastKnownLocation(any()) } returns null + every { + lm.requestLocationUpdates(any(), any(), any(), any(), any()) + } answers { + val provider = firstArg() + if (provider in denied) throw SecurityException("denied: $provider") + } + return lm + } + + @Test + fun firesNeitherEdgeWhenNoProviderExists() = + runTest { + val edges = mutableListOf() + val flow = LocationFlow(manager(providers = emptyList()), sdkInt = 37).get(60_000L, 500f) { edges.add(it) } + + val failure = runCatching { flow.collect { } }.exceptionOrNull() + + assertTrue("expected SecurityException, got $failure", failure is SecurityException) + assertEquals(emptyList(), edges) + } + + @Test + fun firesNeitherEdgeWhenEveryRungIsDenied() = + runTest { + val edges = mutableListOf() + val lm = manager(providers = listOf("fused", "network"), denied = setOf("fused", "network")) + val flow = LocationFlow(lm, sdkInt = 37).get(60_000L, 500f) { edges.add(it) } + + val failure = runCatching { flow.collect { } }.exceptionOrNull() + + assertTrue("expected SecurityException, got $failure", failure is SecurityException) + assertEquals(emptyList(), edges) + } + + @Test + fun fallsThroughToTheNextRungWhenOneIsDenied() = + runTest { + val edges = mutableListOf() + val lm = manager(providers = listOf("fused", "network"), denied = setOf("fused")) + val job = launch { LocationFlow(lm, sdkInt = 37).get(60_000L, 500f) { edges.add(it) }.collect { } } + + runCurrent() + + assertEquals(listOf(true), edges) + verify { lm.requestLocationUpdates("network", 60_000L, 500f, any(), any()) } + + job.cancelAndJoin() + } + + @Test + fun pairsTheListeningEdgesAroundASuccessfulRegistration() = + runTest { + val edges = mutableListOf() + val lm = manager(providers = listOf("network")) + val job = launch { LocationFlow(lm, sdkInt = 30).get(60_000L, 500f) { edges.add(it) }.collect { } } + + runCurrent() + assertEquals(listOf(true), edges) + + job.cancelAndJoin() + + assertEquals(listOf(true, false), edges) + verify { lm.removeUpdates(any()) } + } + + @Test + fun releasesTheRegistrationWhenCancelledDuringTheSeed() = + runTest { + // `send` in the seed suspends, so a collector cancelling while the + // getLastKnownLocation sweep is in flight unwinds the producer + // there. Cleanup must still run, or the refcount sticks at >= 1 + // forever and the OS registration leaks. + val edges = mutableListOf() + val lm = mockk(relaxed = true) + every { lm.allProviders } returns listOf("network") + + lateinit var job: Job + every { lm.getLastKnownLocation(any()) } answers { + // Cancel from inside the sweep, so the subsequent send() throws. + job.cancel() + mockk { every { time } returns 1L } + } + + job = launch { LocationFlow(lm, sdkInt = 30).get(60_000L, 500f) { edges.add(it) }.collect { } } + runCurrent() + job.join() + + assertEquals("the acquire must be released even on cancellation", listOf(true, false), edges) + verify { lm.removeUpdates(any()) } + } + + @Test + fun registersOnExactlyOneProvider() = + runTest { + val lm = manager(providers = listOf("fused", "network", "gps", "passive")) + val job = launch { LocationFlow(lm, sdkInt = 37).get(60_000L, 500f).collect { } } + + runCurrent() + + verify(exactly = 1) { + lm.requestLocationUpdates(any(), any(), any(), any(), any()) + } + + job.cancelAndJoin() + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*LocationFlowTest*' +``` + +Expected: FAIL — compilation error. `LocationFlow` currently takes a `Context`, and `get` has no `onListening` parameter. + +- [ ] **Step 3: Rewrite LocationFlow** + +Replace everything **below** the MIT header in `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt` with: + +```kotlin +package com.vitorpamplona.amethyst.service.location + +import android.annotation.SuppressLint +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Build +import android.os.Looper +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch + +/** + * Wraps [LocationManager] update registration as a cold [Flow]. + * + * Registers on **one** provider, chosen by [LocationProviderLadder], rather than + * on every provider the device reports. The previous shotgun cost four + * simultaneous registrations — passive, network, fused and gps, the last at + * HIGH_ACCURACY — to produce a 5 km geohash. + * + * Takes a [LocationManager] rather than a `Context` so the registration + * behaviour is unit-testable; the caller does the `getSystemService` lookup. + * + * [onListening] is fired from inside the flow, after a registration succeeds and + * again from `awaitClose`, never as an `onStart`/`onCompletion` pair on the + * returned flow. The distinction matters: an `onStart` fires on collection even + * when nothing registered, so a device with no usable provider would accrue + * location time with no location running, and — because the ledger refcounts the + * two [LocationState] flows together — the unpaired close would steal the other + * flow's holder. + * + * The pair is kept honest from both ends. The acquire cannot fire without a + * registration, because a failure to register throws before reaching it. The + * release cannot be skipped, because everything after the acquire runs inside a + * `try`/`finally` rather than inside `awaitClose` — `send` suspends, so a + * collector that cancels mid-seed would otherwise unwind past an `awaitClose` + * that never ran. + */ +class LocationFlow( + private val locationManager: LocationManager, + private val sdkInt: Int = Build.VERSION.SDK_INT, + private val hasFine: Boolean = false, +) { + @SuppressLint("MissingPermission") + fun get( + minTimeMs: Long, + minDistanceM: Float, + onListening: ((Boolean) -> Unit)? = null, + ): Flow = + callbackFlow { + val locationCallback = + LocationListener { location -> + Log.d("LocationFlow") { "onLocationChanged $location" } + launch { send(location) } + } + + // One binder call, reused for both the ladder filter and the seed. + val providers = locationManager.allProviders + + val candidates = LocationProviderLadder.chooseProviders(sdkInt, hasFine) { it in providers } + + var registered: String? = null + for (provider in candidates) { + try { + locationManager.requestLocationUpdates( + provider, + minTimeMs, + minDistanceM, + locationCallback, + Looper.getMainLooper(), + ) + registered = provider + break + } catch (e: SecurityException) { + Log.w("LocationFlow", "Provider $provider refused the update request", e) + } + } + + if (registered == null) { + throw SecurityException("No usable location provider. Candidates: $candidates") + } + + Log.i("LocationFlow") { "Listening on $registered every ${minTimeMs}ms / ${minDistanceM}m" } + onListening?.invoke(true) + + // Everything after the acquire runs under try/finally, not under + // awaitClose. `send` below suspends, so it is a cancellation point: + // if the collector cancels while the seed is mid-flight, the + // producer throws there and `awaitClose` is never entered. Cleanup + // parked inside awaitClose would then never run — the registration + // would leak and the refcount would stick at >= 1 for the life of + // the process, so location.ms would accrue forever with nothing + // listening. The finally covers normal close and + // cancellation-during-send alike. + try { + // Seeded after registration so the no-provider path throws + // without having emitted anything; seeding first would show the + // consumer Success -> LackPermission on a device with no + // compatible provider. + freshestLastKnownLocation(providers)?.let { + Log.d("LocationFlow") { "Last known location is $it" } + send(it) + } + + awaitClose { } + } finally { + Log.i("LocationFlow") { "Stopped listening on $registered" } + locationManager.removeUpdates(locationCallback) + onListening?.invoke(false) + } + } + + /** + * The freshest cached fix across every provider. Permission-checked per + * provider like the update request is, so each lookup is guarded — on a + * device where a provider refuses us, the others should still seed. + */ + @SuppressLint("MissingPermission") + private fun freshestLastKnownLocation(providers: List): Location? = + providers + .mapNotNull { provider -> + try { + locationManager.getLastKnownLocation(provider) + } catch (e: SecurityException) { + Log.w("LocationFlow", "No permission to read the last known location of $provider", e) + null + } + }.maxByOrNull { it.time } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*LocationFlowTest*' +``` + +Expected: PASS, 6 tests. + +If `releasesTheRegistrationWhenCancelledDuringTheSeed` fails to *fail* against a version without the `try`/`finally` — i.e. it passes either way — the cancellation is not reaching `send` as expected. Do not delete the test: replace the in-answer `job.cancel()` with a `trySend` on a channel the test awaits, or fall back to asserting the same invariant from `LocationStateTest` by cancelling the collector mid-gate. The invariant is what matters, not this particular provocation. + +`LocationState.kt` will not compile yet — it still calls the old `LocationFlow(context)` and `MIN_TIME`. That is Task 5. If the Gradle run fails on `LocationState.kt` compilation rather than on the tests, that is expected; proceed to Task 5 and re-run both suites there. + +- [ ] **Step 5: Commit** + +The module is red until Task 5 lands. That does not block the commit: the pre-commit hook runs `spotlessCheck` only, which does not compile. + +```bash +./gradlew spotlessApply +git add amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt \ + amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt +git commit -m "feat(amethyst): register one location provider with a paired listening hook" +``` + +--- + +### Task 5: LocationState — the foreground gate + +**Files:** +- Modify (full rewrite of the class body): `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt` +- Test: `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationStateTest.kt` + +**Interfaces:** +- Consumes: `LocationFlow` (Task 4) +- Produces: `class LocationState(context, scope, isForeground, onListening, locationSource)` and the five `const val`s in the naming contract. Task 6 constructs it. + +- [ ] **Step 1: Write the failing test** + +Create `amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationStateTest.kt` with the MIT header, then: + +```kotlin +package com.vitorpamplona.amethyst.service.location + +import android.content.Context +import android.location.Location +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LocationStateTest { + private fun locationAt( + lat: Double, + lon: Double, + ): Location = + mockk { + every { latitude } returns lat + every { longitude } returns lon + } + + /** Counts subscriptions and completions of the underlying location source. */ + private class SourceProbe( + private val body: suspend FlowCollector.() -> Unit, + ) { + var subscriptions = 0 + private set + var completions = 0 + private set + + val live: Int get() = subscriptions - completions + + fun source(): (Long, Float) -> Flow = + { _, _ -> + flow(body) + .onStart { subscriptions++ } + .onCompletion { completions++ } + } + } + + private fun neverEmits() = SourceProbe { awaitCancellation() } + + private fun emitsOnceThenHangs( + lat: Double, + lon: Double, + ) = SourceProbe { + emit(locationAt(lat, lon)) + awaitCancellation() + } + + private fun stateWith( + scope: CoroutineScope, + foreground: MutableStateFlow, + probe: SourceProbe, + ) = LocationState( + context = mockk(relaxed = true), + scope = scope, + isForeground = foreground, + locationSource = probe.source(), + ) + + @Test + fun doesNotListenWhileBackgrounded() = + runTest { + val probe = neverEmits() + val foreground = MutableStateFlow(false) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + + assertEquals(0, probe.subscriptions) + } + + @Test + fun listensOnceWhileForegrounded() = + runTest { + val probe = neverEmits() + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + + assertEquals(1, probe.subscriptions) + assertEquals(1, probe.live) + } + + @Test + fun releasesTheSourceAfterTheGracePeriodAndKeepsTheLastFix() = + runTest { + val probe = emitsOnceThenHangs(56.048839, 12.721029) + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + + val fixWhileForeground = state.geohashStateFlow.value + assertTrue("expected a Success, got $fixWhileForeground", fixWhileForeground is LocationState.LocationResult.Success) + + foreground.value = false + advanceUntilIdle() + + assertEquals("source must be released once backgrounded", 0, probe.live) + assertEquals( + "the last geohash must survive the release for the ~60 synchronous .value readers", + fixWhileForeground, + state.geohashStateFlow.value, + ) + } + + @Test + fun keepsListeningAcrossABackgroundEdgeShorterThanTheGracePeriod() = + runTest { + val probe = neverEmits() + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + assertEquals(1, probe.subscriptions) + + foreground.value = false + advanceTimeBy(LocationState.BACKGROUND_GRACE_MS / 2) + foreground.value = true + advanceUntilIdle() + + assertEquals("a brief app switch must not tear down the registration", 1, probe.subscriptions) + assertEquals(1, probe.live) + } + + @Test + fun doesNotReemitLoadingWhenReturningToForegroundWithACachedFix() = + runTest { + val probe = emitsOnceThenHangs(56.048839, 12.721029) + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + val seen = mutableListOf() + backgroundScope.launch { state.geohashStateFlow.collect { seen.add(it) } } + advanceUntilIdle() + + foreground.value = false + advanceUntilIdle() + val afterBackground = seen.size + + foreground.value = true + advanceUntilIdle() + + assertTrue( + "returning to foreground must not flash Loading — AroundMeFeedFlow renders an empty feed for it. Saw: ${seen.drop(afterBackground)}", + seen.drop(afterBackground).none { it is LocationState.LocationResult.Loading }, + ) + } + + @Test + fun emitsLoadingOnTheFirstForegroundWhenThereIsNoCachedFix() = + runTest { + val probe = neverEmits() + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + val seen = mutableListOf() + backgroundScope.launch { state.geohashStateFlow.collect { seen.add(it) } } + advanceUntilIdle() + + assertTrue("expected Loading, saw $seen", seen.any { it is LocationState.LocationResult.Loading }) + } + + @Test + fun reportsLackPermissionRegardlessOfForeground() = + runTest { + val probe = neverEmits() + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(false) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + + assertEquals(LocationState.LocationResult.LackPermission, state.geohashStateFlow.value) + assertEquals(0, probe.subscriptions) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*LocationStateTest*' +``` + +Expected: FAIL — compilation error. `LocationState` has no `isForeground` or `locationSource` parameter and no `BACKGROUND_GRACE_MS`. + +- [ ] **Step 3: Rewrite LocationState** + +Replace everything **below** the MIT header in `amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt` with: + +```kotlin +package com.vitorpamplona.amethyst.service.location + +import android.content.Context +import android.location.Location +import android.location.LocationManager +import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +// `toGeoHash` is an extension on Location declared in LocationGeoHash.kt, same +// package, so it needs no import. + +/** + * Turns the device's location into geohashes, listening **only while the app is + * in the foreground**. + * + * The gate is not an optimisation of last resort: `Account` builds 30 + * `SharingStarted.Eagerly` top-nav filter states on the account scope, and + * `AccountSettings.defaultProductsFollowList` ships as `TopFilter.AroundMe`, so + * without it every user with location permission holds a registration for the + * life of the process. See `amethyst/plans/2026-07-29-location-foreground-gate.md`. + * + * Switching the *consumers* to `WhileSubscribed` is not an option: roughly 60 + * call sites read `account.live*FollowLists.value` synchronously rather than + * collecting, and would silently serve a stale or initial value. + */ +class LocationState( + context: Context, + scope: CoroutineScope, + private val isForeground: StateFlow, + /** + * Resource-ledger hook: true while location updates are actively + * registered. Reaches the OS only through the default [locationSource], + * which hands it to [LocationFlow] — a caller that overrides + * [locationSource] (the tests do) is responsible for firing it, or not. + */ + private val onListening: ((Boolean) -> Unit)? = null, + private val locationSource: (Long, Float) -> Flow = { minTimeMs, minDistanceM -> + LocationFlow(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager) + .get(minTimeMs, minDistanceM, onListening) + }, +) { + companion object { + /** A 5 km cell takes 2.5 minutes to cross at 120 km/h; 60s/500m is ample. */ + const val COARSE_MIN_TIME: Long = 60_000L + const val COARSE_MIN_DISTANCE: Float = 500.0f + + /** Building-level geohashes need the tighter profile. */ + const val PRECISE_MIN_TIME: Long = 10_000L + const val PRECISE_MIN_DISTANCE: Float = 100.0f + + /** + * How long to keep listening after the last activity stops, so a + * one-second app switch doesn't destroy and rebuild the registration. + * Matches the `WhileSubscribed` window below, and is the same intent. + */ + const val BACKGROUND_GRACE_MS: Long = 5_000L + } + + sealed class LocationResult { + data class Success( + val geoHash: GeoHash, + ) : LocationResult() + + object LackPermission : LocationResult() + + object Loading : LocationResult() + } + + private enum class Gate { NoPermission, Paused, Listen } + + private var hasLocationPermission = MutableStateFlow(false) + + // Volatile: R1 below reads these to decide whether to emit Loading, from a + // different coroutine than the onEach that writes them. + @Volatile private var latestLocation: LocationResult = LocationResult.Loading + + @Volatile private var latestPreciseLocation: LocationResult = LocationResult.Loading + + fun setLocationPermission(newValue: Boolean) { + if (newValue != hasLocationPermission.value) { + hasLocationPermission.tryEmit(newValue) + } + } + + /** + * Foreground with an asymmetric delay: leaving the foreground waits out + * [BACKGROUND_GRACE_MS], returning to it is immediate. + * + * `debounce(5000)` would delay both edges, and the duration-selector + * overload that allows an asymmetric delay is `@FlowPreview`. + * `transformLatest` cancels the pending `delay` when foreground returns + * first, which is exactly the semantics wanted, with no preview opt-in. + * + * Known and harmless: [ForegroundTracker] starts at `false`, so on a + * process that starts backgrounded the first emission — and therefore the + * first gate verdict, including `LackPermission` — is delayed by + * [BACKGROUND_GRACE_MS]. Nothing renders while backgrounded, and a process + * that starts into the foreground emits immediately, because the activity's + * `onStart` cancels the pending delay. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private val settledForeground: Flow = + isForeground.transformLatest { foreground -> + if (!foreground) delay(BACKGROUND_GRACE_MS) + emit(foreground) + } + + private val gate: Flow = + combine(hasLocationPermission, settledForeground) { permitted, foreground -> + when { + !permitted -> Gate.NoPermission + foreground -> Gate.Listen + else -> Gate.Paused + } + }.distinctUntilChanged() + + @OptIn(ExperimentalCoroutinesApi::class) + private fun geohashFlow( + tag: String, + charsCount: Int, + minTimeMs: Long, + minDistanceM: Float, + latest: () -> LocationResult, + setLatest: (LocationResult) -> Unit, + ): Flow = + gate.transformLatest { state -> + when (state) { + // Deliberately does NOT write to the cache. Today's code emits + // LackPermission without touching latestLocation, and wiping it + // here would cost a Loading emission — and so an empty-feed + // flash — on every permission flap, which is the regression R1 + // exists to prevent. Consumers already see LackPermission from + // the StateFlow; the cache is internal and only decides whether + // Loading is emitted. + Gate.NoPermission -> emit(LocationResult.LackPermission) + + // Emit nothing: stateIn keeps the last value, so every + // synchronous .value reader still sees the last known geohash + // while the OS registration is released. + Gate.Paused -> Unit + + Gate.Listen -> { + // Only when there is nothing cached. Emitting Loading on + // every foreground return would flash the "Around Me" feed + // empty, because AroundMeFeedFlow.convert maps anything + // that is not Success to an empty geotag set. + if (latest() !is LocationResult.Success) emit(LocationResult.Loading) + + emitAll( + locationSource(minTimeMs, minDistanceM) + .map { LocationResult.Success(it.toGeoHash(charsCount)) as LocationResult } + .onEach { setLatest(it) } + .catch { e -> + Log.w(tag, "Exception in the flow", e) + setLatest(LocationResult.LackPermission) + emit(LocationResult.LackPermission) + }, + ) + } + } + } + + val geohashStateFlow: StateFlow by lazy { + geohashFlow( + tag = "GeohashStateFlow", + charsCount = GeohashPrecision.KM_5_X_5.digits, + minTimeMs = COARSE_MIN_TIME, + minDistanceM = COARSE_MIN_DISTANCE, + latest = { latestLocation }, + setLatest = { latestLocation = it }, + ).stateIn(scope, SharingStarted.WhileSubscribed(5000), latestLocation) + } + + /** + * Like [geohashStateFlow] but at building-level precision + * ([GeohashChannelLevel.BUILDING] = 8 chars). Location channels truncate this + * to every coarser level (a geohash is a prefix code), so one fix yields the + * whole region→building ladder. Kept separate so the coarser + * [geohashStateFlow] the "around me" feed relies on is unchanged. + * + * Note that Amethyst declares only `ACCESS_COARSE_LOCATION`, so Android + * fuzzes every fix to roughly a 3 km grid and this is not in fact + * building-level today. The profile is kept so the intent survives if the + * app ever requests `ACCESS_FINE_LOCATION`. + */ + val preciseGeohashStateFlow: StateFlow by lazy { + geohashFlow( + tag = "PreciseGeohashStateFlow", + charsCount = GeohashChannelLevel.BUILDING.chars, + minTimeMs = PRECISE_MIN_TIME, + minDistanceM = PRECISE_MIN_DISTANCE, + latest = { latestPreciseLocation }, + setLatest = { latestPreciseLocation = it }, + ).stateIn(scope, SharingStarted.WhileSubscribed(5000), latestPreciseLocation) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +./gradlew :amethyst:testPlayDebugUnitTest --tests '*LocationStateTest*' --tests '*LocationFlowTest*' +``` + +Expected: PASS, 7 + 6 tests. `AppModules.kt` still will not compile — it calls the three-argument `LocationState` constructor. That is Task 6. + +- [ ] **Step 5: Commit** + +```bash +./gradlew spotlessApply +git add amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt \ + amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationStateTest.kt +git commit -m "feat(amethyst): gate location listening on foreground" +``` + +`AppModules.kt` lands in Task 6, and the module compiles from there on. + +--- + +### Task 6: Wire it up in AppModules + +**Files:** +- Modify: `amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt` — two edits, anchored on declaration text rather than line numbers, because Step 1 shifts everything below it + +**Interfaces:** +- Consumes: `RefCountedSession` (Task 2), `LocationState` (Task 5) +- Produces: nothing downstream + +- [ ] **Step 1: Add the refcounted session next to the integrator** + +Find the line beginning `private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS)` (currently line 369) and insert immediately after it: + +```kotlin + + // LocationState exposes two independent flows that can both be listening at + // once (the "Around Me" feed plus an open geohash chat). Refcounting keeps + // either one stopping from closing the other's segment. + private val locationRefCount = RefCountedSession(locationSession::setActive) +``` + +Add the import alongside the other `service.resourceusage` imports: + +```kotlin +import com.vitorpamplona.amethyst.service.resourceusage.RefCountedSession +``` + +- [ ] **Step 2: Pass the foreground signal and the refcount into LocationState** + +Find the `val locationManager by lazy` declaration (near line 249, unchanged by Step 1 since that insert was below it) and replace this exact block: + +```kotlin + // App services that should be run as soon as there are subscribers to their flows + val locationManager by lazy { + Log.d("AppModules", "LocationManager Init") + LocationState(appContext, applicationIOScope, onListening = { locationSession.setActive(it) }) + } +``` + +with: + +```kotlin + // App services that should be run as soon as there are subscribers to their + // flows. Location additionally releases its OS registration whenever no + // activity is started — see the foreground gate inside LocationState. + val locationManager by lazy { + Log.d("AppModules", "LocationManager Init") + LocationState( + appContext, + applicationIOScope, + isForeground = foregroundTracker.isForeground, + onListening = { locationRefCount.setActive(it) }, + ) + } +``` + +Both `foregroundTracker` (line 333) and `locationRefCount` (added in Step 1) are declared after `locationManager`, which is fine — `locationManager` is `by lazy`, so the references resolve on first access. `locationSession` was already referenced this way. + +- [ ] **Step 3: Verify the whole module compiles, tests pass, and lint is clean** + +```bash +./gradlew :amethyst:compilePlayDebugKotlin +./gradlew :amethyst:testPlayDebugUnitTest +./gradlew :amethyst:lintPlayDebug +``` + +Expected: BUILD SUCCESSFUL for all three. + +Lint is not optional here. This change names `LocationManager.FUSED_PROVIDER` (API 31) on `minSdk = 26`, and keeps `@SuppressLint("MissingPermission")` on a rewritten method. The ladder's KDoc argues the constant is inlined by the compiler and therefore safe on older runtimes — correct, but an argument, and `NewApi` is exactly the check that settles it. If lint flags `NewApi` on `FUSED_PROVIDER`, replace the constant with the literal `"fused"` and keep the explanatory comment. + +If `compilePlayDebugKotlin` reports an unresolved `MIN_TIME` or `MIN_DISTANCE`, a caller was missed — grep for it and fix. + +```bash +grep -rn --include='*.kt' "MIN_TIME\|MIN_DISTANCE" amethyst/src commons/src desktopApp/src +``` + +Expected: only the four `COARSE_*`/`PRECISE_*` constants in `LocationState.kt` and their uses. + +- [ ] **Step 4: Commit** + +```bash +./gradlew spotlessApply +git add amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +git commit -m "feat(amethyst): wire the location foreground gate and refcounted meter" +``` + +--- + +### Task 7: Verify the acceptance criteria on device + +**Files:** none — this is measurement. + +**Interfaces:** +- Consumes: the whole change +- Produces: the evidence block for the PR description + +- [ ] **Step 1: Install on the Pixel 9a** + +```bash +adb devices -l +./gradlew :amethyst:installPlayDebug +``` + +Expected: one device listed (`model:Pixel_9a`), BUILD SUCCESSFUL. + +- [ ] **Step 2: Record the foreground baseline** + +Open the app to Home, leave it in the foreground, then: + +```bash +adb shell dumpsys location | sed -n '/Location Providers:/,/Historical/p' | grep -c "com.vitorpamplona.amethyst" +adb shell dumpsys location | sed -n '/Location Providers:/,/Historical/p' | grep -A1 "com.vitorpamplona.amethyst" +``` + +Expected: the count is **1** in the steady state where only the "Around Me" feed is live (at most 2 if a geohash chat is also open — the two flows are independent, which is why the ledger refcounts). Before this change it was 4. + +Expected in the request line: `@+60s0ms` and `minUpdateDistance=500.0`. Before this change: `@+10s0ms` and `minUpdateDistance=100.0`. + +- [ ] **Step 3: Verify the registration is released when backgrounded** + +Press Home, wait more than `BACKGROUND_GRACE_MS` (5 s), then: + +```bash +adb shell dumpsys location | sed -n '/Location Providers:/,/Historical/p' | grep -c "com.vitorpamplona.amethyst" +adb shell dumpsys location | grep "com.vitorpamplona.amethyst" | tail -5 +``` + +Expected: the count is **0**, and the last recent-event lines are `-registration` entries timestamped when you pressed Home. + +- [ ] **Step 4: Verify the feed does not flash empty** + +Foreground the app on Home with the top-nav filter set to "Around Me". Note the geohash shown in the spinner. Press Home, wait 10 s, reopen the app. + +Expected: the same geohash is displayed immediately, with no "Loading" state and no empty feed. This is R1; a flash here means the cached-`Success` check is wrong. + +- [ ] **Step 5: Check the ledger invariant after a day of use** + +Open the in-app Resource Usage Report and compare: + +``` +location.ms ≤ app.fgms + 5000 × (number of times the app was backgrounded) +``` + +Both counters are driven by the same `foregroundTracker.isForeground`, so the grace period is the only expected slack. A violation much larger than that indicts `app.fgms` (Finding 4 of the source analysis suspects it of under-reporting) rather than this change. + +- [ ] **Step 6: Record the evidence** + +Append the observed numbers from Steps 2, 3 and 5 to the `## Acceptance criteria` section of `amethyst/plans/2026-07-29-location-foreground-gate.md` under a `### Verified` heading, then: + +```bash +git add amethyst/plans/2026-07-29-location-foreground-gate.md +git commit -m "docs(amethyst): record on-device verification of the location gate" +``` + +--- + +## Self-review notes + +Spec coverage: §A gate → Task 5 (R1 cached-`Success` check, R2 `settledForeground`, R3 `Gate.Paused` emitting nothing, R4 `@Volatile`). §B request shape → Tasks 3 and 4. §C meter → Tasks 2, 4 (R5 pairing via throw) and 6 (R6 wiring). H1 → Task 1. Testing → the test steps of Tasks 2–5. Acceptance criteria → Task 7. + +Not covered by design, and correctly so: the two `Unavailable`-state and Products-default items are Non-goals; the `GeohashChatScreen.kt:161-163` permission latch is a Follow-up. + +Tasks 4 and 5 leave the module temporarily uncompilable. That is a deliberate split — one task spanning `LocationFlow`, `LocationState` and `AppModules` would review far worse — and it costs nothing, because the pre-commit hook is `spotlessCheck` alone. Task 6 restores a green build. diff --git a/amethyst/plans/2026-07-29-location-foreground-gate.md b/amethyst/plans/2026-07-29-location-foreground-gate.md new file mode 100644 index 0000000000..5c844bc117 --- /dev/null +++ b/amethyst/plans/2026-07-29-location-foreground-gate.md @@ -0,0 +1,773 @@ +# Location: foreground-gate the listener, trim the request, fix the meter + +Date: 2026-07-29 +Module: `amethyst` +Origin: Finding 1 of `2026-07-29-resource-report-1.13.0-analysis.md` (1.13.0-PLAY, +Pixel 9a / Android 17) +Revision: 2 — incorporates spec review of 2026-07-29 + +## Context + +The 1.13.0 resource report showed **7.13 h of location listening against 9.1 +seconds of app foreground**, and the accompanying analysis called it the leading +suspect for that day's 11 pp of background battery drain. Root cause given: 30 +`SharingStarted.Eagerly` top-nav filter states on the account scope +(`Account.kt:843-933`), one of which is always `TopFilter.AroundMe` because +`AccountSettings.kt:256` ships that as the Products default. The `AroundMe` +branch collects `locationFlow()`, and an `Eagerly`-shared subscriber holds +`LocationState`'s `WhileSubscribed(5000)` open for the life of the process. + +That chain is real. **The battery conclusion drawn from it is not**, and this +spec is written against the measurement rather than the inference. + +### What the device reports + +`amethyst/src/main/AndroidManifest.xml:80` declares **only** +`ACCESS_COARSE_LOCATION` — no `ACCESS_FINE_LOCATION`, no +`ACCESS_BACKGROUND_LOCATION` — and no service declares +`foregroundServiceType="location"` (the declared types are `mediaPlayback`, +`microphone`, `camera`, `phoneCall`, `shortService`, `dataSync`, `specialUse`). +`targetSdk = 37`. + +`adb shell dumpsys location`, Pixel 9a, 21 d 7 h of uptime. **These are the +`com.vitorpamplona.amethyst` rows** of the per-provider *Historical Aggregate +Location Provider Data* block — not system-wide totals: + +| provider | registration held | **active** | **foreground** | fixes | +|---|---|---|---|---| +| passive | 9 d 14 h 20 m | 8 h 26 m 14 s | 8 h 14 m 50 s | 107 | +| network | 9 d 14 h 20 m | 8 h 26 m 13 s | 8 h 14 m 49 s | 95 | +| fused | 9 d 14 h 20 m | 8 h 26 m 12 s | 8 h 14 m 48 s | 95 | +| gps | 9 d 14 h 20 m | 8 h 26 m 11 s | 8 h 14 m 47 s | 98 | + +Roughly **11 minutes of background-active location in three weeks**, and about +100 delivered fixes per provider. With the app backgrounded at the time of the +dump: `gps provider: service: ProviderRequest[OFF]`, `gps_hardware: +mStarted=false`. + +The cleanest assumption-free comparison: the ledger's **two-day** `location.ms` +total (3.57 h + 7.13 h = 10.70 h) **exceeds the OS's three-week active total** +(8 h 26 m) by 27 %. `location.ms` is not measuring what its label implies. + +**One figure does not reconcile, and is left open.** Registration-held is +9 d 14 h over 21 d 7 h ≈ 10.8 h/day, whereas `location.ms` averages 5.35 h/day +across the two ledger days. If `location.ms` measured subscription existence +these should agree; they are ~2× apart. Candidates: segments open at process +death are lost (the pre-flush hook does not run on a kill, and the report shows +6 process starts across the two days); the ledger covers 2 days while dumpsys +spans 21 with different usage. Not investigated. It does not affect the +conclusion below, which rests on `location.ms` versus OS-*active* time, not +versus registration-held. + +### How far the "no background location" claim generalises + +This matters because the "no behaviour change" argument rests on it, so it is +scoped rather than asserted universally: + +- **API 29+ (Android 10 and up):** `ACCESS_BACKGROUND_LOCATION` gates background + access, and for `targetSdk ≥ 29` an FGS additionally needs + `foregroundServiceType="location"`. Amethyst has neither, so background + registrations are suspended. This is the case the Pixel 9a measurement above + covers. +- **API 26–28 (Android 8–9):** `ACCESS_BACKGROUND_LOCATION` does not exist. + Amethyst *can* receive background location there, throttled by the platform to + a few updates per hour. The gate is a genuine, if small, improvement on these + releases rather than a no-op. + +So "structural" applies to API 29+; on 26–28 the change has real effect. + +### What is actually wrong + +1. **The meter lies.** `location.ms` measures how long a *subscription* existed, + not how long anything listened. That is what made Finding 1 read as the + top-priority battery bug. +2. **The request is 4× redundant.** `LocationFlow.kt:55` iterates + `locationManager.allProviders` and calls `requestLocationUpdates` on each — + passive, network, fused **and** gps (the last tagged `HIGH_ACCURACY`) — every + one at `@+10s0ms, minUpdateDistance=100.0`, to produce a **5 km** geohash. + This burns during the 8 h 14 m the app genuinely is foreground. +3. **The subscription is held for 45 % of device uptime** doing nothing, because + `Eagerly` never lets go. +4. **Every user is exposed**, since Products defaults to `AroundMe` and needs no + opt-in. +5. **Location may be entirely broken on Android 8–11** — see Hypothesis H1. + +## Hypothesis H1 — location is dead below API 31 (unverified) + +Through Android 11, AOSP's `getMinimumPermissionForProvider` required +`ACCESS_FINE_LOCATION` for the `gps`, `passive` and `fused` providers; only +`network` accepted `ACCESS_COARSE_LOCATION`. Approximate-location, which lets a +coarse-only app request any provider and receive a fuzzed result, is an Android +12 (API 31) change. + +Amethyst holds coarse only. So on API 26–30 today's `allProviders` loop should +throw `SecurityException` on three of the four providers. Two consequences: + +- The throw escapes the `callbackFlow` builder → `.catch` in `LocationState` → + `LackPermission`. Location would be **non-functional** on Android 8–11. +- The builder aborting means `awaitClose` never runs, so `removeUpdates` is + never called and any registration made before the throw **leaks** for the life + of the process. + +**The leak is iteration-order dependent, and may not occur at all.** +`getLastKnownLocation` is permission-checked per provider too, and +`LocationFlow.kt:56-68` calls it *before* `requestLocationUpdates` on each +iteration. If a fine-only provider comes first in `allProviders` — `passive` +does, in the common AOSP ordering — the throw lands before any registration +exists: dead, but not leaking. A leak requires `network` to precede a fine-only +provider. + +`@SuppressLint("MissingPermission")` at `LocationFlow.kt:40` suppresses the lint +warning, not the runtime check, so this would not have been caught statically. + +**Not reproduced.** Verify before implementing, on the existing +`Medium_Phone_API_26_8_` AVD: grant coarse only, open a screen that subscribes, +and capture **both** the `SecurityException` *and* the actual +`locationManager.allProviders` order (log it). The PR should claim only what that +run observed — "location is dead on Android 8–11" and "registrations leak" are +separate claims and the second may not hold. + +The design below is written to be correct either way (§B excludes +permission-incompatible providers by API level, and catches `SecurityException` +per provider). If H1 holds, this change also **fixes location on Android 8–11**, +which should be called out in the PR. + +### H1 verification result (2026-07-30, Pixel 9a, API 37) + +Partial. The API-level claim could **not** be tested on this hardware: API 31+ +grants coarse-only apps access to every provider, so no `SecurityException` can +appear regardless of whether H1 is true. Only an API ≤ 30 image can settle it. + +What *was* settled is the ordering, which decides the leak sub-claim. +`dumpsys location` recent-events shows the same iteration order on every +registration cycle across two days, all four sharing one registration id +(`88A8E679`), confirming a single `LocationFlow` subscription: + +``` +07-30 07:09:58.282: passive provider +registration .../88A8E679 +07-30 07:09:58.291: network provider +registration .../88A8E679 +07-30 07:09:58.293: fused provider +registration .../88A8E679 +07-30 07:09:58.299: gps provider +registration .../88A8E679 +``` + +`allProviders` yields **passive first**, and `passive` is one of the fine-only +providers below API 31. So on Android 8–11 the throw would land on the first +iteration, before any registration exists: + +- "location is dead on Android 8–11" — **still unverified**, needs API ≤ 30. +- "registrations leak" — **disproved for this ordering**. Dead, but not leaking. + +The PR must not claim the leak. Caveat: the ordering is observed on API 37 and +`getAllProviders()` could order differently on API 26. + +**Unrelated but decisive "before" datum, same session:** with +`mWakefulness=Dozing` (screen off, device dozing) and `MainActivity` sitting in +`mLastPausedActivity`, Amethyst held **four** live registrations at +`@+10s0ms / minUpdateDistance=100.0`. That is the state §A's gate exists to +eliminate, captured on the owner's daily-driver device rather than an emulator. + +## Goals + +- Release the location registration whenever no activity is started. +- Register on one appropriate, permission-compatible provider at an interval + matched to the precision actually needed. +- Make `location.ms` reflect real listening time, correctly, under concurrency. +- No user-visible behaviour change to the "Around Me" feed or geohash chats. + +## Non-goals + +- Changing the Products `AroundMe` default (`AccountSettings.kt:256`). With the + gate in place its cost is bounded to foreground use. Worth revisiting + separately as a product decision. +- Requesting `ACCESS_FINE_LOCATION` or `ACCESS_BACKGROUND_LOCATION`. +- Findings 2–6 of the source analysis. Finding 2 (the relay reconnect storm, + 1.65 GB/day at a 75 % dial-failure rate) is the more likely explanation for + the background battery drain and should be taken next. + +## Design + +### A. The gate + +`LocationState` gains an `isForeground: StateFlow` parameter, wired in +`AppModules.kt:251` from the existing `foregroundTracker` (`AppModules.kt:333`, +registered at `Amethyst.kt:122`). `locationManager` is `by lazy`, so +initialisation order is safe. + +Today's `hasLocationPermission.transformLatest { … }` becomes a three-state gate +over *permission × foreground*, applied identically to `geohashStateFlow` and +`preciseGeohashStateFlow`: + +| gate state | behaviour | +|---|---| +| no permission | emit `LackPermission` (unchanged) | +| permitted, foreground | **R1**: emit `Loading` *only if* no `Success` is cached; then `emitAll(locationSource(…))` | +| permitted, backgrounded | emit nothing; the registration is released and the `StateFlow` retains its last value | + +**R1 is a requirement, not an improvement.** `AroundMeFeedFlow.convert` collapses +to `geotags = emptySet()` for anything that is not `Success`. Without R1 the gate +would make the "Around Me" feed flash empty on **every** return to foreground — a +new, frequent, user-visible regression introduced by this change. (It also fixes +the same flash on permission grant, which exists today.) + +**R1 corollary: the `NoPermission` branch must not clear the cache.** Today's +code emits `LackPermission` without touching `latestLocation` +(`LocationState.kt:94-96`), and that stays. Clearing it is superficially +attractive — a revoked permission arguably should not leave a fix readable — but +consumers already see `LackPermission` from the `StateFlow`; `latestLocation` is +private and its only jobs are seeding `stateIn` and deciding whether `Loading` is +emitted. Clearing it would therefore buy no privacy and would cost an +empty-feed flash on every permission flap, which is precisely what R1 exists to +prevent. The cache is in-memory and dies with the process regardless. + +The `.catch` branch **does** clear the cache, and keeps doing so. That asymmetry +looks arbitrary next to the paragraph above, so to be explicit: it is inherited, +not introduced. Both branches preserve today's behaviour exactly +(`LocationState.kt:87-91` clears on failure, `:94-96` does not clear on missing +permission). This corollary argues against *adding* a clear, not for removing +the existing one — changing it would be an unmotivated behaviour change. The +asymmetry is also defensible on its own terms: a source that failed mid-stream +says something about the fix's provenance, whereas a permission known to be +absent says nothing about a fix already taken. + +**R2 — grace period on the background edge.** The gate must delay the +`foreground → background` transition by **5 s** before tearing down. Without it a +one-second app switch destroys and rebuilds the registration, including a full +`getLastKnownLocation` sweep, so a user flipping between apps pays more than the +steady state. 5 s matches the existing `WhileSubscribed(5000)` and is the same +intent. The `background → foreground` edge is **not** delayed. + +Mechanism, stated because the obvious operator is the wrong one: `debounce(5000)` +delays both edges, and the duration-selector overload that would allow an +asymmetric delay is `@FlowPreview`. Use `transformLatest`, already in this file +and already opted into via `@OptIn(ExperimentalCoroutinesApi::class)`: + +```kotlin +isForeground.transformLatest { fg -> + if (!fg) delay(BACKGROUND_GRACE_MS) + emit(fg) +} +``` + +`transformLatest` cancels the pending `delay` if foreground returns first, which +is exactly the stated semantics, with no preview opt-in. + +**R3 — the retained-value contract.** The "emit nothing" branch is what keeps +this behaviour-neutral: `stateIn` holds the last `Success`, so the ~60 +synchronous `.value` reads across the feed filters +(`HomeNewThreadFeedFilter.kt`, `VideoFeedFilter.kt`, +`DiscoverLongFormFeedFilter.kt`, …) keep seeing the last known geohash. A 5 km +cell does not meaningfully decay while backgrounded. + +**R4 — memory visibility.** `latestLocation` and `latestPreciseLocation` +(`LocationState.kt:63-64`) are plain `var`s today, used only as `stateIn` initial +values. R1 promotes them to control flow, read from a different coroutine than +the `onEach` that writes them. They must become `@Volatile` (or +`MutableStateFlow`). + +**Rejected alternative:** switching `FeedTopNavFilterState.flow` from `Eagerly` +to `WhileSubscribed`. Roughly 60 call sites read +`account.live*FollowLists.value` synchronously rather than collecting; under +`WhileSubscribed` those reads would silently serve a stale or initial value +whenever no collector happened to be active. That is a correctness regression, +not a battery fix. + +**Rejected alternative:** gating only at the `AppModules` wiring point +(`geolocationFlow = { … }`). Smaller diff, but it leaves the raw +`geohashStateFlow` as a loaded gun for the next eager consumer, does nothing for +`preciseGeohashStateFlow`, and introduces a second `StateFlow` layer over the +same data. + +### B. Request shape + +`LocationFlow.get` registers on **one** provider, chosen by a ladder over +**provider existence and permission compatibility** — both static facts: + +``` +chooseProviders(sdkInt, hasFine, exists) -> List: + API 31+ or hasFine → [FUSED, NETWORK, GPS, PASSIVE] filtered by exists + API < 31, coarse → [NETWORK] filtered by exists (see H1) +``` + +It returns the **ordered candidate list**, not a single choice, because the +per-provider `SecurityException` fall-through below needs somewhere to fall to. +An empty list means no compatible provider exists. + +**The ladder deliberately does not consult `isProviderEnabled`.** Today's code +registers regardless of enabled state, and such a registration goes live by +itself when the user enables location — including from the quick-settings shade +without leaving the app, which is exactly what someone does after seeing "Around +Me" empty. A guard evaluated once at subscription start would lose that, and the +foreground-transition restart does not cover the in-app path. Selecting on +existence keeps the property with no `PROVIDERS_CHANGED_ACTION` receiver. If +field reports show dead feeds on devices where the chosen provider exists but is +disabled while another is enabled, adding that receiver is the follow-up. + +`requestLocationUpdates` is wrapped in a per-provider `SecurityException` catch +that falls through to the next rung, so H1 cannot abort the builder and leak +registrations regardless of how the AOSP check actually behaves. + +**When no provider can be registered** — the candidate list was empty, or every +rung threw — `LocationFlow` **throws** `SecurityException`. It cannot emit +`LackPermission`: the seam is `(Long, Float) -> Flow`, and +`LackPermission` is a `LocationState.LocationResult`, which `LocationFlow` has no +way to express. Throwing routes it through the `.catch` already present in +`LocationState` (`LocationState.kt:87-91`, `:126-130`), which sets +`latestLocation = LackPermission` and emits it — the existing, unchanged path. + +Throwing covers **both** failure cases, and it subsumes R5's `registered`-flag +guard: the throw happens before the acquire, so `onListening(true)` cannot fire +without a live registration and no separate flag is needed. That is only half of +R5's pairing, though — see R5 for the release half, which the throw does **not** +cover and which needs `try`/`finally`. + +**Stated decision: `LackPermission` stays conflated with "no usable provider".** +That value renders `R.string.lack_location_permissions` — "No Location +Permissions" — at `DisplayLocationObserver.kt:49` and `FeedFilterSpinner.kt:224`, +which is wrong for a coarse-only pre-31 device that has no `network` provider. +The conflation is accepted rather than introduced: if H1 holds, today's +`SecurityException` already lands in the same `.catch` and shows the same wrong +message. Adding an `Unavailable` state would ripple through four UI `when`s plus +`LocationState` (10 references across 5 files) and belongs with the H1 fix +messaging, not here. Recorded as a follow-up. + +The `getLastKnownLocation` seed stays a sweep across all providers, taking the +freshest result. It requires no registration and is what makes the first geohash +appear immediately rather than after a fix. + +`MIN_TIME` / `MIN_DISTANCE` split into two profiles, passed per call: + +| flow | precision | interval / distance | provider set | +|---|---|---|---| +| `geohashStateFlow` | `KM_5_X_5` | 10 s / 100 m → **60 s / 500 m** | 4 → 1 | +| `preciseGeohashStateFlow` | `BUILDING` (8 chars) | 10 s / 100 m (kept) | 4 → 1 | + +Both rows change: the ladder narrows the precise flow's provider set too, and +below API 31 that means `network` only, no GPS. Academic while the app holds +coarse only (see Follow-ups), but it is not "unchanged". + +At 120 km/h a 5 km cell takes 2.5 minutes to cross, so 60 s / 500 m has no +observable effect on the feed. + +**Rejected alternative — one shared source at the fine profile,** deriving the +coarse geohash by prefix truncation. It halves registrations and removes the need +for `RefCountedSession` entirely, but it upgrades the **common** case — the +"Around Me" feed alone, which is always on via the Products default — from +60 s/500 m to 10 s/100 m. That trades the change's main win for a rarer one. + +**Rejected alternative — one shared source whose profile tracks the finest +active subscriber.** Recovers the above and is the best of the three on both +axes, but it is refcounting with the counter moved from the meter into the +request path, for a benefit bounded by how often the two flows overlap. They +overlap only while one of three composable-scoped, foreground-only screens is +open (`GeohashChatScreen`, `NewGeohashChatScreen`, +`GeohashLocationPickerDialog`). Not worth the machinery; revisit if that changes. + +### C. The meter + +`AppModules.kt:251` hands both flows the same non-refcounted +`SessionTimeIntegrator`, so `setActive(false)` from either closes the segment +while the other is still listening. Both can be live at once — the "Around Me" +feed plus an open geohash chat. + +**R5 — the hook moves inside `LocationFlow`, and both edges are paired.** Today +`onListening(true)` is an `onStart` on the flow returned by `LocationFlow.get`, +so it fires on *collection* whether or not anything was registered — meaning a +device with no usable provider accrues `location.ms` with nothing listening, +reintroducing the exact defect this section exists to fix. The hook must instead +fire from inside the `callbackFlow`, after `requestLocationUpdates` returns +without throwing, and again on the way out. + +The obvious "way out" is `awaitClose`, and that would introduce a worse bug than +it fixes — twice over. First, `awaitClose` runs on every normal +completion, including one where no rung ever registered, so it would fire an +**unpaired** `onListening(false)`. With R6 that does not merely under-count — it +decrements a holder it never acquired, stealing another flow's. Concretely: +`geohashStateFlow` registers (`holders = 1`), `preciseGeohashStateFlow` fails to +register and closes (`holders = 0`), and the session latches off while the coarse +flow is still listening. `coerceAtLeast(0)` does not help; the count never went +negative. + +Second — and this is the one that survives fixing the first — `awaitClose` also +fails to run at all on some paths that *did* register. See below. + +The pair therefore has to be guaranteed from **both** ends, and the two ends need +different mechanisms. + +*No acquire without a registration* is §B's **throw**: if no rung registers, the +builder throws before reaching the acquire at all. + +*No acquire without a release* needs `try`/`finally`, **not** `awaitClose`. This +is the subtlest point in the document, so the justification below is the one that +was **demonstrated**, not the one that sounds most obvious. + +Anything between the acquire and `awaitClose` that unwinds skips cleanup parked +inside `awaitClose`, because `awaitClose` is never reached to register it. The +registration then leaks and the refcount sticks at ≥ 1 for the life of the +process, so `location.ms` accrues forever with nothing listening — this exact +defect, arrived at from the other direction, and unrecoverable once hit. + +The **proven** path is the seed throwing a non-cancellation exception: +`getLastKnownLocation` is a binder call and can fail. The regression test +`releasesTheRegistrationWhenTheSeedThrows` provokes exactly this and was watched +failing against an `awaitClose`-only implementation +(`expected:<[true, false]> but was:<[true]>`). + +A cancellation during the seed is *in principle* a second such path, since `send` +is a suspending call. Recorded honestly: **this one could not be reproduced.** +Two attempts during implementation both produced tests that passed against a +deliberately broken implementation, because `callbackFlow`'s channel is buffered, +so `send` returns without suspending and never observes the cancel. Do not treat +the cancellation story as the reason for the `try`/`finally`; a future reader who +tries to reproduce it, fails, and concludes the guard is unnecessary would +reintroduce the leak. + +```kotlin +var registered: String? = null +for (provider in candidates) { + try { + locationManager.requestLocationUpdates(provider, minTimeMs, minDistanceM, callback, Looper.getMainLooper()) + registered = provider + break + } catch (e: SecurityException) { /* next rung */ } +} +if (registered == null) throw SecurityException("no usable location provider") + +onListening?.invoke(true) // cannot fire without a registration +try { + freshestLastKnownLocation(providers)?.let { send(it) } // suspends — cancellable + awaitClose { } // only to satisfy callbackFlow's contract +} finally { + locationManager.removeUpdates(callback) + onListening?.invoke(false) // cannot be skipped +} +``` + +`onListening?.invoke(true)` sits immediately before the `try`, with no suspension +between them, so the acquire cannot happen outside the block that guarantees its +release. + +`trySend` for the seed would also close this particular hole, being +non-suspending. It is rejected because it leaves the invariant resting on nobody +adding a suspending call to that block later — vigilance rather than +impossibility, which is the standard the rest of R5 is held to. + +**R6 — refcounting.** An `AtomicInteger` beside the `setActive` call is not +sufficient: two threads can leave the counter at 1 while the last +`setActive(false)` lands after the `setActive(true)`, latching the session off. +The count and the transition must move under one lock. New class in +`service/resourceusage/`: + +```kotlin +class RefCountedSession(private val setSessionActive: (Boolean) -> Unit) { + private val lock = Any() + private var holders = 0 + + fun setActive(active: Boolean) = + synchronized(lock) { + holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0) + setSessionActive(holders > 0) + } +} +``` + +It takes the setter as a lambda rather than a `SessionTimeIntegrator` because +that is all it needs, and because constructing a real integrator in a unit test +would drag in a `ResourceUsageAccountant`, a `ResourceUsageStore` and a temp +file to observe one boolean. + +`AppModules` wires `RefCountedSession(locationSession::setActive)` and passes +`onListening = { locationRefCount.setActive(it) }`. The outer +lock serialises entry into `SessionTimeIntegrator.setActive`, whose own lock is +then nested but never acquired in the reverse order, so there is no deadlock. +`coerceAtLeast(0)` guards an unmatched release. + +### What `location.ms` means after this change + +Stated plainly, because the finding that opened this spec is "the meter lies" +and the next reader should not over-trust the fixed number the way the last one +over-trusted the broken one: + +> `location.ms` measures **how long a location registration was held while the +> app was in the foreground**. It is not radio-on time and not an energy +> figure. A `network`-provider registration at 60 s costs close to nothing; a +> `gps` registration at 10 s costs a great deal. The counter cannot tell them +> apart. + +Reading it as a battery signal requires knowing which provider was chosen — +which the ledger does not record. Recording the chosen provider as a separate +counter is a possible follow-up. + +## Testing + +JVM unit tests — JUnit + MockK + `kotlinx-coroutines-test`, no Robolectric, +alongside the existing `service/resourceusage/ResourceUsageLedgerTest.kt`. + +**Gate.** `LocationState` gains `locationSource: (Long, Float) -> Flow`, +defaulting to `LocationFlow(context.getSystemService(…) as LocationManager)::get` +(see *Registration pairing* for why `LocationFlow` now takes the manager rather +than the `Context`). That is the seam: +`Location.toGeoHash` is `GeoHash.encode(lat, lon, chars)` from quartz — pure +Kotlin — and `unitTests.isReturnDefaultValues = true` is already set, so a +`mockk` with stubbed `latitude`/`longitude` suffices. Against a +counting fake source: + +- backgrounded + permitted → source never subscribed +- foreground + permitted → subscribed exactly once +- foreground → background → subscription released after the R2 grace period, + last `Success` still readable via `.value` +- background edge shorter than the grace period → subscription **not** torn down +- return to foreground with a cached `Success` → **no** `Loading` emission (R1) +- return to foreground with no cached fix → `Loading` first +- permission revoked → `LackPermission` regardless of foreground state + +**Provider ladder.** Extracted as a pure function +`chooseProviders(sdkInt: Int, hasFine: Boolean, exists: (String) -> Boolean): +List` so §B is covered rather than sitting below the seam. Cases: rungs +returned in order; missing rungs filtered out; API < 31 coarse-only yields +`[network]`; API < 31 with fine yields the full ladder; no compatible provider +yields an empty list. + +`hasFine` is **always `false` in production** — the non-goals rule out ever +requesting `ACCESS_FINE_LOCATION`. It is a parameter rather than a constant so +that the function is total over the permission axis and the API < 31 branch can +be tested from both sides, not because fine access is anticipated. If that +changes, the ladder is already correct. + +R5 sits below the `locationSource` seam, so a fake source never fires it. It gets +its own tests against a mocked `LocationManager` — see *Registration pairing* +below. + +**Meter.** `RefCountedSession`: overlapping holders keep the session open; +balanced pairs close it; an unmatched release does not drive the count negative. + +Note what this class **cannot** do: it cannot distinguish an unpaired release +from a legitimate one, so `acquire → unpaired release` closes the session even +while another holder is listening. That is precisely the R5 bug, and +`coerceAtLeast(0)` is no defence against it. **The pairing guarantee belongs to +`LocationFlow`, not here** — which is why it needs its own test below. + +**Registration pairing (R5).** To make this testable rather than device-only, +`LocationFlow` takes a `LocationManager` instead of a `Context` +(`LocationFlow(context)` → `LocationFlow(locationManager)`; the caller in +`LocationState` does the `getSystemService` lookup). A `mockk` +then covers: + +- every rung throws `SecurityException` → the flow throws and `onListening` fires + **neither** edge +- `chooseProviders` returns an empty list → same: throws, neither edge +- an earlier rung throws and a later one succeeds → registration falls through, + exactly one `true` +- a successful registration → exactly one `true`, and exactly one `false` plus + `removeUpdates` on cancellation +- **cancellation mid-seed**, while the `getLastKnownLocation` sweep is in flight + → both edges still fire and `removeUpdates` is still called. This is the case + the `try`/`finally` exists for; without it the test fails by hanging the + refcount at 1 rather than by throwing, so assert on the edges, not on the + absence of an exception. +These cover the *semantics* only — the two-thread interleaving that motivates the +lock is made unobservable by the lock itself and is not reproduced by any test +here. + +## Acceptance criteria + +On device, re-running the measurement above: + +- **Backgrounded:** after the 5 s grace period, `adb shell dumpsys location` + shows no `com.vitorpamplona.amethyst` entry under any provider's `listeners:`, + and a `-registration` in the recent-events log. +- **Foregrounded:** **one registration per actively-collected flow — at most + two**, and one in the steady state where only the "Around Me" feed is live + (§C exists precisely because the two flows may overlap). Not four. The coarse + registration reads `@+60s0ms` / `minUpdateDistance=500.0` rather than + `@+10s0ms` / `100.0`. +- The historical aggregates are cumulative since boot; compare deltas across a + foreground/background cycle, not absolute totals. +- **Invariant:** a subsequent in-app Resource Usage Report shows + + ``` + location.ms ≤ app.fgms + 5 s × (background transitions) + ``` + + Both counters are driven by the same `foregroundTracker.isForeground` flow, so + without R2 this would hold exactly. R2 is deliberately the error term: the + registration really *is* live during the grace period, so counting it is the + honest reading, and a stated fudge factor beats an invariant quietly known to + be false. The term is not negligible — the source report shows 6 process + starts across two days, and app switches are far more frequent than that — so + writing `location.ms ≤ app.fgms` would guarantee that the first person to + check it files a bug against this change. + + Second caveat: Finding 4 of the source analysis suspects `app.fgms` of + under-reporting, so a violation beyond the grace term indicts that counter + rather than this one. +- If H1 holds: location works on the API 26 AVD after the change and did not + before. + +### Verified on device (2026-07-30, Pixel 9a / Android 17, API 37) + +Measured against the `benchmark` variant — `initWith(release)`, so R8-minified with +the shipping proguard rules, installed as `com.vitorpamplona.amethyst.benchmark` +beside the untouched Play install. The Play install was force-stopped for the +duration so its own (unfixed) registrations could not be mistaken for these. + +| criterion | before | after | +|---|---|---| +| registrations, foreground | **4** (passive, network, fused, gps) | **1** (fused) | +| request profile | `@+10s0ms HIGH_ACCURACY`, `minUpdateDistance=100.0` | `@+1m0s0ms BALANCED`, `minUpdateDistance=500.0` | +| registrations, backgrounded | **4**, held while `mWakefulness=Dozing` | **0** | + +Event trace for one full cycle, process alive throughout (pid 28682): + +``` +17:57:00.117 +registration fused …/40F5A6D7 @+1m0s0ms BALANCED, minUpdateDistance=500.0 +17:57:33.894 -registration fused …/40F5A6D7 ← HOME pressed, released after the grace +17:58:05.798 +registration fused …/091EDA96 @+1m0s0ms BALANCED, minUpdateDistance=500.0 + (HOME then reopen within 2 s — no -/+ pair; 091EDA96 survives) +``` + +- **§A gate** — zero registrations while backgrounded, with the process still + alive. That is the state the change exists to create; before, four + registrations survived screen-off and doze. +- **§B request shape** — one provider, top of the ladder (`fused`), at exactly + `COARSE_MIN_TIME` / `COARSE_MIN_DISTANCE`. The OS tags it `(COARSE)` and + coalesces the effective service request to `@+10m0s0ms LOW_POWER`. +- **R2 grace period** — a sub-grace app switch produced **no** teardown/rebuild + pair, so a brief switch no longer costs a re-registration and a fresh + `getLastKnownLocation` sweep. + +**The OS aggregate after four foreground/background cycles is the headline +result**, because it is the same counter shape `location.ms` measures: + +``` +com.vitorpamplona.amethyst.benchmark: + min/max interval = 60s/60s + total/active/foreground duration = +2m33s542ms / +2m33s456ms / +2m33s531ms + locations = 4 +``` + +Total ≈ active ≈ foreground, all three within 90 ms — against the Play install's +`9d14h20m / 8h26m / 8h14m`, where registration was held for 45 % of uptime while +only 1.7 % was active. The four foreground windows sum to 153.6 s, matching the +aggregate exactly, so nothing is held outside them. Registration-held time now +*equals* foreground time, which is precisely what makes `location.ms` honest: the +counter measures registration lifetime, and that quantity is no longer divorced +from reality. + +**A fix arrives within milliseconds of every re-registration**, which bounds the +staleness the coarser profile was feared to introduce: + +``` +17:57:00.117 +registration → 17:57:00.127 delivered location[1] (10 ms) +17:58:05.798 +registration → 17:58:05.802 delivered location[1] ( 4 ms) +17:59:09.965 +registration → 17:59:09.967 delivered location[1] ( 2 ms) +18:00:09.206 +registration → 18:00:09.213 delivered location[1] ( 7 ms) +``` + +The `fused` provider hands over its cached fix on registration, so the window in +which a returning user could act on a stale geohash is milliseconds, not the 60 s +poll interval. Caveat: that cache is warm on this device because Maps and GMS +keep it fresh; on a device with no other location consumer it could be colder, +which is what `freshestLastKnownLocation` exists to cover. + +**Side-by-side A/B, same device, same instant, both clients backgrounded and +running.** The unmodified release client (1.13.1, installed via Obtainium, pid +5552) and the benchmark build of this branch (pid 28682) were sampled together: + +``` +com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[PASSIVE, minUpdateDistance=100.0] (inactive) +com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive) +com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive) +com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive) + ← com.vitorpamplona.amethyst.benchmark: no rows at all +``` + +Four held registrations versus zero. Note the release client's rows are all +`{bg, na} … (inactive)`: the OS has throttled the effective interval to 10 +minutes and suspended delivery, exactly as §"What the device reports" describes — +but the **registration is still held**, and registration-held time is precisely +what `location.ms` counts. That is the inflation, visible in one frame. + +Naming note for anyone re-reading the numbers above: both artifacts are `play` +**flavor** builds and differ only by buildType, so "the Play install" is an +ambiguous label. The unmodified client here is the *release* build, and on this +device it came from Obtainium rather than Google Play. + +### Ledger invariant confirmed (2026-07-31, benchmark client, in-app report) + +The acceptance criterion `location.ms ≤ app.fgms + 5 s × transitions` now checks +out against accumulated data: + +| | `location.ms` | `app.fgms` | ratio | +|---|---|---|---| +| release client 1.13.0, day 20663 (before) | 25,660,172 | 9,147 | **2,805×** | +| benchmark, day 20664 (permission granted mid-day) | 3m7.3s | 11m31.0s | 0.27× | +| benchmark, **day 20665** (granted all day) | **2m10.8s** | **1m56.9s** | **1.12×** | + +Day 20665 is the clean case: `location.ms` exceeds `app.fgms` by 13.9 s, which +requires ≥ 3 background transitions to fall inside the grace allowance — met by +the report navigation plus an `am start`. Day 20664 independently reconciles with +the `dumpsys` measurement: 2m33.5s of OS registration-held + 4 × 5 s grace = +~2m53.5s predicted, 3m7.3s actual, the residual being foreground use after the +measurement ended. **The ledger and the OS now agree**, where before they were +irreconcilable (10.7 h ledger vs 8h26m OS-active over three weeks). + +**Limitation:** this is not a within-package before/after. `location.ms` is +absent from days 20648–20663 because the benchmark client had location permission +*denied* until 2026-07-30; the "before" is no data, not inflated data. + +### The same data closes the battery question + +Over days 20659–20665 on this device: **5m18s** of location listening against +**596 pp** of background battery drain (~85 pp/day). Location cannot be a +meaningful contributor at that ratio — Finding 1 is settled, and not in the +direction the original analysis assumed. + +Three consumers visible in the same report, none of them location: + +- `service.alwayson.ms` ≈ **23.9 h/day** (148.9 h over 7 days) — an always-on + foreground service running essentially continuously. Largest structural + difference from a stock client; worth confirming it is deliberately enabled. +- **Finding 2, unchanged.** 3,732 relay-hours over 7 days. Day 20664 alone: + 9,831 successful dials against 25,133 failures = **71.9 %**, matching the + original report's 75 %. +- **Finding 4, now on cellular.** Day 20664 `net.other.mobile.bg.activems = + 52,392,613` — **14.6 h** of background mobile active time with **0 requests and + 0 bytes**. The three-moment `isForeground()` sampling, exactly as diagnosed, so + the fg/bg split in this report still cannot be trusted. + +Caveat: the benchmark client's round-the-clock always-on service makes its +battery figures non-comparable to a stock install. The relay and `net.other` +figures do match the release client's original report closely. + +Not verified by this run: **R1** (no `Loading` flash on return to foreground) is +a visual property and needs an observer at the screen; the unit test +`doesNotReemitLoadingWhenReturningToForegroundWithACachedFix` covers it and +mutation-fails correctly without the guard. The `location.ms ≤ app.fgms + 5 s × +transitions` invariant needs a day of accumulated ledger data. + +## Follow-ups (not in this change) + +- **`preciseGeohashStateFlow` is not actually building-level.** With only + `ACCESS_COARSE_LOCATION`, Android fuzzes every fix to roughly a 3 km grid, so + the 8-char geohash and the location chat channels built on it are far coarser + than they claim (`LocationState.kt:104-141`, `GeohashChatScreen.kt:165`, + `NewGeohashChatScreen.kt:309`). The profile is kept intact here so the intent + survives if the app ever requests `ACCESS_FINE_LOCATION`; whether to request + it, or to stop advertising building-level precision, is a separate decision. +- **`GeohashChatScreen.kt:161-163` is a one-way permission latch** — it calls + `setLocationPermission(true)` inside an `if (isGranted)` rather than passing + the boolean, as every other caller does (`LoggedInPage.kt:144`, + `LocationAsHash.kt:64`, `NewGeohashChatScreen.kt:285`, + `GeohashLocationPickerDialog.kt:270`). Once set, a revoked permission is never + reflected back into the shared `LocationState`. Small, in the blast radius, + and cheap. +- **An `Unavailable` `LocationResult`**, distinct from `LackPermission`, so a + device with no usable provider stops being told "No Location Permissions" when + it has them. Ten references across five files + (`DisplayLocationObserver.kt`, `FeedFilterSpinner.kt`, `HomeScreen.kt`, + `NewGeohashChatScreen.kt`, `LocationState.kt`). Belongs with the H1 fix + messaging — see the stated decision in §B. +- Recording the chosen provider as a ledger counter, so `location.ms` can be + read as a cost signal. +- Whether Products should still default to `TopFilter.AroundMe`. +- Finding 2 — the relay reconnect storm. From a742cec495303af26f314b4e8d6bdc3c591cb452 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 30 Jul 2026 09:50:52 +0200 Subject: [PATCH 020/227] feat(amethyst): register one location provider with a paired listening hook feat(amethyst): add location provider ladder feat(amethyst): add RefCountedSession for overlapping ledger holders --- .../amethyst/service/location/LocationFlow.kt | 123 +++++++++++--- .../location/LocationProviderLadder.kt | 75 +++++++++ .../resourceusage/RefCountedSession.kt | 67 ++++++++ .../service/location/LocationFlowTest.kt | 158 ++++++++++++++++++ .../location/LocationProviderLadderTest.kt | 82 +++++++++ .../resourceusage/RefCountedSessionTest.kt | 70 ++++++++ 6 files changed, 551 insertions(+), 24 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt index 87ab766cee..ad82448597 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt @@ -21,56 +21,131 @@ package com.vitorpamplona.amethyst.service.location import android.annotation.SuppressLint -import android.content.Context import android.location.Location import android.location.LocationListener import android.location.LocationManager +import android.os.Build import android.os.Looper -import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_DISTANCE -import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_TIME import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.launch +/** + * Wraps [LocationManager] update registration as a cold [Flow]. + * + * Registers on **one** provider, chosen by [LocationProviderLadder], rather than + * on every provider the device reports. The previous shotgun cost four + * simultaneous registrations — passive, network, fused and gps, the last at + * HIGH_ACCURACY — to produce a 5 km geohash. + * + * Takes a [LocationManager] rather than a `Context` so the registration + * behaviour is unit-testable; the caller does the `getSystemService` lookup. + * + * [onListening] is fired from inside the flow, after a registration succeeds and + * again from `awaitClose`, never as an `onStart`/`onCompletion` pair on the + * returned flow. The distinction matters: an `onStart` fires on collection even + * when nothing registered, so a device with no usable provider would accrue + * location time with no location running, and — because the ledger refcounts the + * two [LocationState] flows together — the unpaired close would steal the other + * flow's holder. + * + * The pair is kept honest from both ends. The acquire cannot fire without a + * registration, because a failure to register throws before reaching it. The + * release cannot be skipped, because everything after the acquire runs inside a + * `try`/`finally` rather than inside `awaitClose` — `send` suspends, so a + * collector that cancels mid-seed would otherwise unwind past an `awaitClose` + * that never ran. + */ class LocationFlow( - private val context: Context, + private val locationManager: LocationManager, + private val sdkInt: Int = Build.VERSION.SDK_INT, + private val hasFine: Boolean = false, ) { @SuppressLint("MissingPermission") fun get( - minTimeMs: Long = MIN_TIME, - minDistanceM: Float = MIN_DISTANCE, + minTimeMs: Long, + minDistanceM: Float, + onListening: ((Boolean) -> Unit)? = null, ): Flow = callbackFlow { - Log.i("LocationFlow", "Start") - val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - val locationCallback = LocationListener { location -> Log.d("LocationFlow") { "onLocationChanged $location" } launch { send(location) } } - locationManager.allProviders.forEach { - val location = locationManager.getLastKnownLocation(it) - Log.d("LocationFlow") { "Last Known location is $location" } - if (location != null) { - send(location) + // One binder call, reused for both the ladder filter and the seed. + val providers = locationManager.allProviders + + val candidates = LocationProviderLadder.chooseProviders(sdkInt, hasFine) { it in providers } + + var registered: String? = null + for (provider in candidates) { + try { + locationManager.requestLocationUpdates( + provider, + minTimeMs, + minDistanceM, + locationCallback, + Looper.getMainLooper(), + ) + registered = provider + break + } catch (e: SecurityException) { + Log.w("LocationFlow", "Provider $provider refused the update request", e) } - Log.d("LocationFlow", "Requesting Updates") - locationManager.requestLocationUpdates( - it, - minTimeMs, - minDistanceM, - locationCallback, - Looper.getMainLooper(), - ) } - awaitClose { - Log.i("LocationFlow", "Stop") + if (registered == null) { + throw SecurityException("No usable location provider. Candidates: $candidates") + } + + Log.i("LocationFlow") { "Listening on $registered every ${minTimeMs}ms / ${minDistanceM}m" } + onListening?.invoke(true) + + // Everything after the acquire runs under try/finally, not under + // awaitClose. `send` below suspends, so it is a cancellation point: + // if the collector cancels while the seed is mid-flight, the + // producer throws there and `awaitClose` is never entered. Cleanup + // parked inside awaitClose would then never run — the registration + // would leak and the refcount would stick at >= 1 for the life of + // the process, so location.ms would accrue forever with nothing + // listening. The finally covers normal close and + // cancellation-during-send alike. + try { + // Seeded after registration so the no-provider path throws + // without having emitted anything; seeding first would show the + // consumer Success -> LackPermission on a device with no + // compatible provider. + freshestLastKnownLocation(providers)?.let { + Log.d("LocationFlow") { "Last known location is $it" } + send(it) + } + + awaitClose { } + } finally { + Log.i("LocationFlow") { "Stopped listening on $registered" } locationManager.removeUpdates(locationCallback) + onListening?.invoke(false) } } + + /** + * The freshest cached fix across every provider. Permission-checked per + * provider like the update request is, so each lookup is guarded — on a + * device where a provider refuses us, the others should still seed. + */ + @SuppressLint("MissingPermission") + private fun freshestLastKnownLocation(providers: List): Location? = + providers + .mapNotNull { provider -> + try { + locationManager.getLastKnownLocation(provider) + } catch (e: SecurityException) { + Log.w("LocationFlow", "No permission to read the last known location of $provider", e) + null + } + }.maxByOrNull { it.time } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt new file mode 100644 index 0000000000..278d57f866 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.location + +import android.location.LocationManager +import android.os.Build + +/** + * Picks which location providers to try, in order. + * + * Deliberately selects on **provider existence**, never on + * [LocationManager.isProviderEnabled]. A registration on a disabled provider + * goes live by itself when the user enables location — including from the + * quick-settings shade without leaving the app, which is exactly what someone + * does after seeing an empty "Around Me" feed. An enabled-state guard evaluated + * once at subscription start would lose that. + * + * Below API 31, `gps`, `passive` and `fused` required `ACCESS_FINE_LOCATION`; + * only `network` accepted `ACCESS_COARSE_LOCATION`. Approximate location, which + * lets a coarse-only app request any provider and receive a fuzzed result, is an + * Android 12 change. Amethyst declares coarse only, so [hasFine] is always false + * in production — it is a parameter so the function is total over the permission + * axis and both sides of the API branch are testable, not because fine access is + * anticipated. + * + * Returns the ordered candidate list rather than a single choice so the caller + * can fall through to the next rung if a registration is refused. An empty list + * means no compatible provider exists. + */ +object LocationProviderLadder { + // Compile-time String constants, inlined by the compiler, so naming + // FUSED_PROVIDER (added in API 31) is safe on older runtimes. + private val FULL_LADDER = + listOf( + LocationManager.FUSED_PROVIDER, + LocationManager.NETWORK_PROVIDER, + LocationManager.GPS_PROVIDER, + LocationManager.PASSIVE_PROVIDER, + ) + + private val COARSE_ONLY_LEGACY_LADDER = listOf(LocationManager.NETWORK_PROVIDER) + + fun chooseProviders( + sdkInt: Int, + hasFine: Boolean, + exists: (String) -> Boolean, + ): List { + val ladder = + if (sdkInt >= Build.VERSION_CODES.S || hasFine) { + FULL_LADDER + } else { + COARSE_ONLY_LEGACY_LADDER + } + + return ladder.filter(exists) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt new file mode 100644 index 0000000000..f73e4996a4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.resourceusage + +/** + * Refcounts a boolean session so overlapping holders don't close each other's + * segment. [LocationState][com.vitorpamplona.amethyst.service.location.LocationState] + * exposes two independent location flows that can both be listening at once — + * the "Around Me" feed plus an open geohash chat — and a bare + * [SessionTimeIntegrator] would close the segment when either one stops. + * + * The count and the transition it drives are taken under one lock. An + * [java.util.concurrent.atomic.AtomicInteger] beside an unsynchronised call is + * not enough: two threads can leave the counter at 1 while the last + * `setActive(false)` lands after the `setActive(true)`, latching the session + * off with a holder still active. + * + * Takes the setter as a lambda rather than a [SessionTimeIntegrator] because + * that is all it needs — and because constructing a real integrator drags in a + * [ResourceUsageAccountant] and a store file to observe one boolean. + * + * Reports **transitions only**, not every call. A 1 -> 2 acquire would otherwise + * re-enter [SessionTimeIntegrator.setActive] with the session already open, + * splitting one segment into two. That happens to be arithmetically harmless + * (`account()` adds each piece, and the pieces are contiguous), and it does not + * inflate a `*.starts` counter either, because [SessionTimeIntegrator] already + * guards its starts increment on `prev == null`. Transition-only is simply the + * contract the name implies, and it keeps the class honest for a future caller + * that reacts to the callback rather than integrating it. + * + * Releases must be paired with acquires. This class cannot tell an unpaired + * release from a real one, so callers guarantee the pairing; see `LocationFlow`, + * which throws rather than reaching `awaitClose` when nothing registered. + */ +class RefCountedSession( + private val setSessionActive: (Boolean) -> Unit, +) { + private val lock = Any() + private var holders = 0 + + fun setActive(active: Boolean) { + synchronized(lock) { + val wasActive = holders > 0 + holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0) + val isActive = holders > 0 + if (isActive != wasActive) setSessionActive(isActive) + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt new file mode 100644 index 0000000000..0bfb1bc9c7 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.location + +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LocationFlowTest { + /** + * A LocationManager that reports [providers] and refuses [denied] with a + * SecurityException, mimicking the pre-API-31 fine-location requirement. + */ + private fun manager( + providers: List, + denied: Set = emptySet(), + ): LocationManager { + val lm = mockk(relaxed = true) + every { lm.allProviders } returns providers + every { lm.getLastKnownLocation(any()) } returns null + every { + lm.requestLocationUpdates(any(), any(), any(), any(), any()) + } answers { + val provider = firstArg() + if (provider in denied) throw SecurityException("denied: $provider") + } + return lm + } + + @Test + fun firesNeitherEdgeWhenNoProviderExists() = + runTest { + val edges = mutableListOf() + val flow = LocationFlow(manager(providers = emptyList()), sdkInt = 37).get(60_000L, 500f) { edges.add(it) } + + val failure = runCatching { flow.collect { } }.exceptionOrNull() + + assertTrue("expected SecurityException, got $failure", failure is SecurityException) + assertEquals(emptyList(), edges) + } + + @Test + fun firesNeitherEdgeWhenEveryRungIsDenied() = + runTest { + val edges = mutableListOf() + val lm = manager(providers = listOf("fused", "network"), denied = setOf("fused", "network")) + val flow = LocationFlow(lm, sdkInt = 37).get(60_000L, 500f) { edges.add(it) } + + val failure = runCatching { flow.collect { } }.exceptionOrNull() + + assertTrue("expected SecurityException, got $failure", failure is SecurityException) + assertEquals(emptyList(), edges) + } + + @Test + fun fallsThroughToTheNextRungWhenOneIsDenied() = + runTest { + val edges = mutableListOf() + val lm = manager(providers = listOf("fused", "network"), denied = setOf("fused")) + val job = launch { LocationFlow(lm, sdkInt = 37).get(60_000L, 500f) { edges.add(it) }.collect { } } + + runCurrent() + + assertEquals(listOf(true), edges) + verify { lm.requestLocationUpdates("network", 60_000L, 500f, any(), any()) } + + job.cancelAndJoin() + } + + @Test + fun pairsTheListeningEdgesAroundASuccessfulRegistration() = + runTest { + val edges = mutableListOf() + val lm = manager(providers = listOf("network")) + val job = launch { LocationFlow(lm, sdkInt = 30).get(60_000L, 500f) { edges.add(it) }.collect { } } + + runCurrent() + assertEquals(listOf(true), edges) + + job.cancelAndJoin() + + assertEquals(listOf(true, false), edges) + verify { lm.removeUpdates(any()) } + } + + @Test + fun releasesTheRegistrationWhenCancelledDuringTheSeed() = + runTest { + // `send` in the seed suspends, so a collector cancelling while the + // getLastKnownLocation sweep is in flight unwinds the producer + // there. Cleanup must still run, or the refcount sticks at >= 1 + // forever and the OS registration leaks. + val edges = mutableListOf() + val lm = mockk(relaxed = true) + every { lm.allProviders } returns listOf("network") + + lateinit var job: Job + every { lm.getLastKnownLocation(any()) } answers { + // Cancel from inside the sweep, so the subsequent send() throws. + job.cancel() + mockk { every { time } returns 1L } + } + + job = launch { LocationFlow(lm, sdkInt = 30).get(60_000L, 500f) { edges.add(it) }.collect { } } + runCurrent() + job.join() + + assertEquals("the acquire must be released even on cancellation", listOf(true, false), edges) + verify { lm.removeUpdates(any()) } + } + + @Test + fun registersOnExactlyOneProvider() = + runTest { + val lm = manager(providers = listOf("fused", "network", "gps", "passive")) + val job = launch { LocationFlow(lm, sdkInt = 37).get(60_000L, 500f).collect { } } + + runCurrent() + + verify(exactly = 1) { + lm.requestLocationUpdates(any(), any(), any(), any(), any()) + } + + job.cancelAndJoin() + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt new file mode 100644 index 0000000000..5928e9f5bd --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.location + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LocationProviderLadderTest { + private val all = setOf("fused", "network", "gps", "passive") + + @Test + fun modernDevicePrefersFusedThenFallsBackInOrder() { + assertEquals( + listOf("fused", "network", "gps", "passive"), + LocationProviderLadder.chooseProviders(sdkInt = 31, hasFine = false) { it in all }, + ) + } + + @Test + fun missingProvidersAreFilteredOutButOrderIsKept() { + val present = setOf("network", "passive") + + assertEquals( + listOf("network", "passive"), + LocationProviderLadder.chooseProviders(sdkInt = 37, hasFine = false) { it in present }, + ) + } + + @Test + fun coarseOnlyBelowApi31GetsNetworkOnly() { + // gps, passive and fused all required ACCESS_FINE_LOCATION before + // Android 12 (see Hypothesis H1 in the design spec). + assertEquals( + listOf("network"), + LocationProviderLadder.chooseProviders(sdkInt = 30, hasFine = false) { it in all }, + ) + } + + @Test + fun fineBelowApi31GetsTheFullLadder() { + assertEquals( + listOf("fused", "network", "gps", "passive"), + LocationProviderLadder.chooseProviders(sdkInt = 26, hasFine = true) { it in all }, + ) + } + + @Test + fun coarseOnlyBelowApi31WithNoNetworkProviderGetsNothing() { + val present = setOf("gps", "passive") + + assertEquals( + emptyList(), + LocationProviderLadder.chooseProviders(sdkInt = 28, hasFine = false) { it in present }, + ) + } + + @Test + fun noProvidersAtAllGetsNothing() { + assertEquals( + emptyList(), + LocationProviderLadder.chooseProviders(sdkInt = 37, hasFine = false) { false }, + ) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt new file mode 100644 index 0000000000..78a9d35680 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.resourceusage + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RefCountedSessionTest { + @Test + fun overlappingHoldersKeepTheSessionOpenAndReportOnlyTransitions() { + val calls = mutableListOf() + val session = RefCountedSession { calls.add(it) } + + session.setActive(true) // holders 1 — inactive -> active + session.setActive(true) // holders 2 — a second listener joins, no transition + session.setActive(false) // holders 1 — the first one leaves, still active + + assertEquals("only the 0 -> 1 edge is a transition", listOf(true), calls) + + session.setActive(false) // holders 0 — the last one leaves + + assertEquals(listOf(true, false), calls) + } + + @Test + fun unmatchedReleaseDoesNotDriveTheCountNegative() { + val calls = mutableListOf() + val session = RefCountedSession { calls.add(it) } + + session.setActive(false) + session.setActive(false) + + assertEquals("releasing an idle session is a no-op", emptyList(), calls) + + // If the count had gone to -2, one acquire would leave it at -1 and + // report inactive. It must open the session instead. + session.setActive(true) + + assertEquals(listOf(true), calls) + } + + @Test + fun aSingleHolderOpensAndClosesTheSession() { + val calls = mutableListOf() + val session = RefCountedSession { calls.add(it) } + + session.setActive(true) + session.setActive(false) + + assertEquals(listOf(true, false), calls) + } +} From 4234c4f45b8805f7a4440fa822a5014c8397e88a Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 30 Jul 2026 10:09:57 +0200 Subject: [PATCH 021/227] docs(location): cite the proven mechanism for LocationFlow's try/finally fix(test): close the seed-after-registration ordering gap in LocationFlow fix(test): replace non-discriminating LocationFlow cleanup test --- .../amethyst/service/location/LocationFlow.kt | 45 +++++++----- .../service/location/LocationFlowTest.kt | 73 ++++++++++++++----- 2 files changed, 83 insertions(+), 35 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt index ad82448597..3bc0f40ae2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt @@ -43,20 +43,25 @@ import kotlinx.coroutines.launch * Takes a [LocationManager] rather than a `Context` so the registration * behaviour is unit-testable; the caller does the `getSystemService` lookup. * - * [onListening] is fired from inside the flow, after a registration succeeds and - * again from `awaitClose`, never as an `onStart`/`onCompletion` pair on the - * returned flow. The distinction matters: an `onStart` fires on collection even - * when nothing registered, so a device with no usable provider would accrue - * location time with no location running, and — because the ledger refcounts the - * two [LocationState] flows together — the unpaired close would steal the other - * flow's holder. + * [onListening] is fired from inside the flow, after a registration succeeds, and + * released again from the `try`/`finally` that wraps everything after it, never + * as an `onStart`/`onCompletion` pair on the returned flow. The distinction + * matters: an `onStart` fires on collection even when nothing registered, so a + * device with no usable provider would accrue location time with no location + * running, and — because the ledger refcounts the two [LocationState] flows + * together — the unpaired close would steal the other flow's holder. * * The pair is kept honest from both ends. The acquire cannot fire without a * registration, because a failure to register throws before reaching it. The * release cannot be skipped, because everything after the acquire runs inside a - * `try`/`finally` rather than inside `awaitClose` — `send` suspends, so a - * collector that cancels mid-seed would otherwise unwind past an `awaitClose` - * that never ran. + * `try`/`finally` rather than inside `awaitClose` — [freshestLastKnownLocation] + * (the seed sweep below) can throw a non-cancellation exception and unwind + * before `awaitClose` is ever reached, and `try`/`finally` is what still runs + * the release on that path; see `releasesTheRegistrationWhenTheSeedThrows` in + * `LocationFlowTest`. (In principle a collector cancelling mid-seed would also + * unwind past `awaitClose` the same way, but that path could not be + * reproduced — `callbackFlow`'s `send` is buffered and returns without + * suspending, so it never observes the cancellation.) */ class LocationFlow( private val locationManager: LocationManager, @@ -106,14 +111,18 @@ class LocationFlow( onListening?.invoke(true) // Everything after the acquire runs under try/finally, not under - // awaitClose. `send` below suspends, so it is a cancellation point: - // if the collector cancels while the seed is mid-flight, the - // producer throws there and `awaitClose` is never entered. Cleanup - // parked inside awaitClose would then never run — the registration - // would leak and the refcount would stick at >= 1 for the life of - // the process, so location.ms would accrue forever with nothing - // listening. The finally covers normal close and - // cancellation-during-send alike. + // awaitClose. freshestLastKnownLocation() just below can throw a + // non-cancellation exception and unwind before awaitClose is ever + // reached — proven by releasesTheRegistrationWhenTheSeedThrows in + // LocationFlowTest, which fails if the release moves into + // awaitClose. Cleanup parked inside awaitClose would then never + // run — the registration would leak and the refcount would stick + // at >= 1 for the life of the process, so location.ms would accrue + // forever with nothing listening. (In principle a collector + // cancelling mid-seed would unwind the same way, but that path + // could not be reproduced: callbackFlow's send is buffered and + // returns without suspending, so it never observes the + // cancellation.) The finally covers both cases regardless. try { // Seeded after registration so the no-provider path throws // without having emitted anything; seeding first would show the diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt index 0bfb1bc9c7..5329691f00 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt @@ -27,7 +27,6 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch @@ -116,31 +115,71 @@ class LocationFlowTest { } @Test - fun releasesTheRegistrationWhenCancelledDuringTheSeed() = + fun releasesTheRegistrationWhenTheSeedThrows() = runTest { - // `send` in the seed suspends, so a collector cancelling while the - // getLastKnownLocation sweep is in flight unwinds the producer - // there. Cleanup must still run, or the refcount sticks at >= 1 - // forever and the OS registration leaks. + // Deterministic discriminator for the try/finally: make the seed + // sweep throw. A plain RuntimeException is used rather than + // SecurityException, because freshestLastKnownLocation() catches + // SecurityException internally per-provider and it would never + // propagate out of the try block at all. With cleanup living in + // try/finally, onListening(false)/removeUpdates still run even + // though the block exits via an exception; if cleanup lived in + // awaitClose instead, the throw would unwind past it before + // awaitClose is ever reached and the release would be skipped. val edges = mutableListOf() val lm = mockk(relaxed = true) every { lm.allProviders } returns listOf("network") + every { lm.getLastKnownLocation(any()) } throws RuntimeException("boom") - lateinit var job: Job - every { lm.getLastKnownLocation(any()) } answers { - // Cancel from inside the sweep, so the subsequent send() throws. - job.cancel() - mockk { every { time } returns 1L } - } + val failure = + runCatching { + LocationFlow(lm, sdkInt = 30).get(60_000L, 500f) { edges.add(it) }.collect { } + }.exceptionOrNull() - job = launch { LocationFlow(lm, sdkInt = 30).get(60_000L, 500f) { edges.add(it) }.collect { } } - runCurrent() - job.join() - - assertEquals("the acquire must be released even on cancellation", listOf(true, false), edges) + assertTrue("expected the seed's RuntimeException to surface, got $failure", failure is RuntimeException) + assertEquals("the acquire must be released even when the seed throws", listOf(true, false), edges) verify { lm.removeUpdates(any()) } } + @Test + fun emitsNothingBeforeThrowingWhenNoProviderRegisters() = + runTest { + // Discriminates the seed-after-registration ordering: every + // provider is denied, so the registration loop never succeeds and + // the flow must throw before ever reaching the seed. A cached fix + // is deliberately made available (non-null, with a real `time`) + // so that a seed-first implementation — which would emit it before + // discovering no provider registers — fails this test. + // + // The `values` assertion below is necessary but, on its own, is + // not sufficient to catch a seed-first regression here: a value + // `send`-ed into the callbackFlow channel immediately before the + // producer coroutine throws can be dropped by structured- + // concurrency teardown before this collector's suspended + // `receive` is ever resumed, so the collector may see zero + // values purely as a scheduling artifact, independent of + // ordering. The `getLastKnownLocation` verify is what actually + // discriminates: it fails deterministically whenever the seed is + // attempted at all, regardless of whether registration + // eventually succeeds — which is exactly what a seed-first + // implementation does and what the current try/finally-after- + // registration implementation must never do on this path. + val values = mutableListOf() + val lm = manager(providers = listOf("fused", "network"), denied = setOf("fused", "network")) + val cached = mockk() + every { cached.time } returns 1_000L + every { lm.getLastKnownLocation(any()) } returns cached + + val failure = + runCatching { + LocationFlow(lm, sdkInt = 37).get(60_000L, 500f).collect { values.add(it) } + }.exceptionOrNull() + + assertTrue("expected SecurityException, got $failure", failure is SecurityException) + assertEquals("no value should be emitted before the throw", emptyList(), values) + verify(exactly = 0) { lm.getLastKnownLocation(any()) } + } + @Test fun registersOnExactlyOneProvider() = runTest { From 78f0583b166c9917197518bc32fafaee38174ae6 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 30 Jul 2026 11:12:45 +0200 Subject: [PATCH 022/227] test(amethyst): document LocationStateTest deviations, close profile gap feat(amethyst): gate location listening on foreground --- .../service/location/LocationState.kt | 219 +++++++++---- .../service/location/LocationStateTest.kt | 306 ++++++++++++++++++ 2 files changed, 461 insertions(+), 64 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationStateTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index 98899953e5..12dcfa7e60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -21,32 +21,76 @@ package com.vitorpamplona.amethyst.service.location import android.content.Context +import android.location.Location +import android.location.LocationManager import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest +// `toGeoHash` is an extension on Location declared in LocationGeoHash.kt, same +// package, so it needs no import. + +/** + * Turns the device's location into geohashes, listening **only while the app is + * in the foreground**. + * + * The gate is not an optimisation of last resort: `Account` builds 30 + * `SharingStarted.Eagerly` top-nav filter states on the account scope, and + * `AccountSettings.defaultProductsFollowList` ships as `TopFilter.AroundMe`, so + * without it every user with location permission holds a registration for the + * life of the process. See `amethyst/plans/2026-07-29-location-foreground-gate.md`. + * + * Switching the *consumers* to `WhileSubscribed` is not an option: roughly 60 + * call sites read `account.live*FollowLists.value` synchronously rather than + * collecting, and would silently serve a stale or initial value. + */ class LocationState( context: Context, scope: CoroutineScope, - /** Resource-ledger hook: true while GPS/location updates are actively requested. */ + private val isForeground: StateFlow, + /** + * Resource-ledger hook: true while location updates are actively + * registered. Reaches the OS only through the default [locationSource], + * which hands it to [LocationFlow] — a caller that overrides + * [locationSource] (the tests do) is responsible for firing it, or not. + */ private val onListening: ((Boolean) -> Unit)? = null, + private val locationSource: (Long, Float) -> Flow = { minTimeMs, minDistanceM -> + LocationFlow(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager) + .get(minTimeMs, minDistanceM, onListening) + }, ) { companion object { - const val MIN_TIME: Long = 10000L - const val MIN_DISTANCE: Float = 100.0f + /** A 5 km cell takes 2.5 minutes to cross at 120 km/h; 60s/500m is ample. */ + const val COARSE_MIN_TIME: Long = 60_000L + const val COARSE_MIN_DISTANCE: Float = 500.0f + + /** Building-level geohashes need the tighter profile. */ + const val PRECISE_MIN_TIME: Long = 10_000L + const val PRECISE_MIN_DISTANCE: Float = 100.0f + + /** + * How long to keep listening after the last activity stops, so a + * one-second app switch doesn't destroy and rebuild the registration. + * Matches the `WhileSubscribed` window below, and is the same intent. + */ + const val BACKGROUND_GRACE_MS: Long = 5_000L } sealed class LocationResult { @@ -59,9 +103,15 @@ class LocationState( object Loading : LocationResult() } + private enum class Gate { NoPermission, Paused, Listen } + private var hasLocationPermission = MutableStateFlow(false) - private var latestLocation: LocationResult = LocationResult.Loading - private var latestPreciseLocation: LocationResult = LocationResult.Loading + + // Volatile: R1 below reads these to decide whether to emit Loading, from a + // different coroutine than the onEach that writes them. + @Volatile private var latestLocation: LocationResult = LocationResult.Loading + + @Volatile private var latestPreciseLocation: LocationResult = LocationResult.Loading fun setLocationPermission(newValue: Boolean) { if (newValue != hasLocationPermission.value) { @@ -69,36 +119,93 @@ class LocationState( } } + /** + * Foreground with an asymmetric delay: leaving the foreground waits out + * [BACKGROUND_GRACE_MS], returning to it is immediate. + * + * `debounce(5000)` would delay both edges, and the duration-selector + * overload that allows an asymmetric delay is `@FlowPreview`. + * `transformLatest` cancels the pending `delay` when foreground returns + * first, which is exactly the semantics wanted, with no preview opt-in. + * + * Known and harmless: [ForegroundTracker] starts at `false`, so on a + * process that starts backgrounded the first emission — and therefore the + * first gate verdict, including `LackPermission` — is delayed by + * [BACKGROUND_GRACE_MS]. Nothing renders while backgrounded, and a process + * that starts into the foreground emits immediately, because the activity's + * `onStart` cancels the pending delay. + */ @OptIn(ExperimentalCoroutinesApi::class) - val geohashStateFlow by lazy { - hasLocationPermission - .transformLatest { - if (it) { - emit(LocationResult.Loading) - val result = - LocationFlow(context) - .get(MIN_TIME, MIN_DISTANCE) - .onStart { onListening?.invoke(true) } - .onCompletion { onListening?.invoke(false) } - .map { - LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult - }.onEach { - latestLocation = it - }.catch { e -> - Log.w("GeohashStateFlow", "Exception in the flow", e) - latestLocation = LocationResult.LackPermission - emit(LocationResult.LackPermission) - } + private val settledForeground: Flow = + isForeground.transformLatest { foreground -> + if (!foreground) delay(BACKGROUND_GRACE_MS) + emit(foreground) + } - emitAll(result) - } else { - emit(LocationResult.LackPermission) + private val gate: Flow = + combine(hasLocationPermission, settledForeground) { permitted, foreground -> + when { + !permitted -> Gate.NoPermission + foreground -> Gate.Listen + else -> Gate.Paused + } + }.distinctUntilChanged() + + @OptIn(ExperimentalCoroutinesApi::class) + private fun geohashFlow( + tag: String, + charsCount: Int, + minTimeMs: Long, + minDistanceM: Float, + latest: () -> LocationResult, + setLatest: (LocationResult) -> Unit, + ): Flow = + gate.transformLatest { state -> + when (state) { + // Deliberately does NOT write to the cache. Today's code emits + // LackPermission without touching latestLocation, and wiping it + // here would cost a Loading emission — and so an empty-feed + // flash — on every permission flap, which is the regression R1 + // exists to prevent. Consumers already see LackPermission from + // the StateFlow; the cache is internal and only decides whether + // Loading is emitted. + Gate.NoPermission -> emit(LocationResult.LackPermission) + + // Emit nothing: stateIn keeps the last value, so every + // synchronous .value reader still sees the last known geohash + // while the OS registration is released. + Gate.Paused -> Unit + + Gate.Listen -> { + // Only when there is nothing cached. Emitting Loading on + // every foreground return would flash the "Around Me" feed + // empty, because AroundMeFeedFlow.convert maps anything + // that is not Success to an empty geotag set. + if (latest() !is LocationResult.Success) emit(LocationResult.Loading) + + emitAll( + locationSource(minTimeMs, minDistanceM) + .map { LocationResult.Success(it.toGeoHash(charsCount)) as LocationResult } + .onEach { setLatest(it) } + .catch { e -> + Log.w(tag, "Exception in the flow", e) + setLatest(LocationResult.LackPermission) + emit(LocationResult.LackPermission) + }, + ) } - }.stateIn( - scope, - SharingStarted.WhileSubscribed(5000), - latestLocation, - ) + } + } + + val geohashStateFlow: StateFlow by lazy { + geohashFlow( + tag = "GeohashStateFlow", + charsCount = GeohashPrecision.KM_5_X_5.digits, + minTimeMs = COARSE_MIN_TIME, + minDistanceM = COARSE_MIN_DISTANCE, + latest = { latestLocation }, + setLatest = { latestLocation = it }, + ).stateIn(scope, SharingStarted.WhileSubscribed(5000), latestLocation) } /** @@ -107,36 +214,20 @@ class LocationState( * to every coarser level (a geohash is a prefix code), so one fix yields the * whole region→building ladder. Kept separate so the coarser * [geohashStateFlow] the "around me" feed relies on is unchanged. + * + * Note that Amethyst declares only `ACCESS_COARSE_LOCATION`, so Android + * fuzzes every fix to roughly a 3 km grid and this is not in fact + * building-level today. The profile is kept so the intent survives if the + * app ever requests `ACCESS_FINE_LOCATION`. */ - @OptIn(ExperimentalCoroutinesApi::class) - val preciseGeohashStateFlow by lazy { - hasLocationPermission - .transformLatest { - if (it) { - emit(LocationResult.Loading) - val result = - LocationFlow(context) - .get(MIN_TIME, MIN_DISTANCE) - .onStart { onListening?.invoke(true) } - .onCompletion { onListening?.invoke(false) } - .map { - LocationResult.Success(it.toGeoHash(GeohashChannelLevel.BUILDING.chars)) as LocationResult - }.onEach { - latestPreciseLocation = it - }.catch { e -> - Log.w("GeohashStateFlow", "Exception in the precise flow", e) - latestPreciseLocation = LocationResult.LackPermission - emit(LocationResult.LackPermission) - } - - emitAll(result) - } else { - emit(LocationResult.LackPermission) - } - }.stateIn( - scope, - SharingStarted.WhileSubscribed(5000), - latestPreciseLocation, - ) + val preciseGeohashStateFlow: StateFlow by lazy { + geohashFlow( + tag = "PreciseGeohashStateFlow", + charsCount = GeohashChannelLevel.BUILDING.chars, + minTimeMs = PRECISE_MIN_TIME, + minDistanceM = PRECISE_MIN_DISTANCE, + latest = { latestPreciseLocation }, + setLatest = { latestPreciseLocation = it }, + ).stateIn(scope, SharingStarted.WhileSubscribed(5000), latestPreciseLocation) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationStateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationStateTest.kt new file mode 100644 index 0000000000..7c9e471bce --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationStateTest.kt @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.location + +import android.content.Context +import android.location.Location +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * All tests run on [UnconfinedTestDispatcher] rather than the `runTest {}` default + * ([StandardTestDispatcher]): this suite's `combine` + `transformLatest` + + * `stateIn(WhileSubscribed)` + `backgroundScope.launch { collect }` chain does not + * get driven to completion by `advanceUntilIdle()` alone under the standard + * dispatcher — the producer side never runs. Same root cause, same fix as + * `amethyst/src/test/.../ui/tor/TorManagerTest.kt`, which hits it for the same + * reason (see its `ioDispatcher = UnconfinedTestDispatcher(...)` comment). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class LocationStateTest { + private fun locationAt( + lat: Double, + lon: Double, + ): Location = + mockk { + every { latitude } returns lat + every { longitude } returns lon + } + + /** Counts subscriptions and completions of the underlying location source. */ + private class SourceProbe( + private val body: suspend FlowCollector.() -> Unit, + ) { + var subscriptions = 0 + private set + var completions = 0 + private set + + /** The (minTimeMs, minDistanceM) pair the source function was called with. */ + var requestedProfile: Pair? = null + private set + + val live: Int get() = subscriptions - completions + + fun source(): (Long, Float) -> Flow = + { minTimeMs, minDistanceM -> + requestedProfile = minTimeMs to minDistanceM + flow(body) + .onStart { subscriptions++ } + .onCompletion { completions++ } + } + } + + private fun neverEmits() = SourceProbe { awaitCancellation() } + + private fun emitsOnceThenHangs( + lat: Double, + lon: Double, + ) = SourceProbe { + // Yield before emitting so the fix arrives as a genuine suspension + // point (as a real LocationManager callback would) instead of a + // synchronous burst. Without this, under UnconfinedTestDispatcher a + // fix that lands in the same undispatched execution as the + // immediately-preceding Loading emission can be conflated away by + // the downstream StateFlow before a collector observes either value. + yield() + emit(locationAt(lat, lon)) + awaitCancellation() + } + + private fun stateWith( + scope: CoroutineScope, + foreground: MutableStateFlow, + probe: SourceProbe, + ) = LocationState( + context = mockk(relaxed = true), + scope = scope, + isForeground = foreground, + locationSource = probe.source(), + ) + + @Test + fun doesNotListenWhileBackgrounded() = + runTest(UnconfinedTestDispatcher()) { + val probe = neverEmits() + val foreground = MutableStateFlow(false) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + // Not a test artefact: `gate` is a `combine`, so it produces nothing + // until `settledForeground` first emits, and this test starts + // backgrounded — `settledForeground` waits out BACKGROUND_GRACE_MS + // before its first emission (ForegroundTracker starts at `false`). + // LocationState's own KDoc calls this "known and harmless"; without + // advancing past it here, the gate never resolves and the assertion + // below would pass vacuously regardless of the implementation. + advanceTimeBy(LocationState.BACKGROUND_GRACE_MS + 1) + advanceUntilIdle() + + assertEquals(0, probe.subscriptions) + } + + @Test + fun listensOnceWhileForegrounded() = + runTest(UnconfinedTestDispatcher()) { + val probe = neverEmits() + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + + assertEquals(1, probe.subscriptions) + assertEquals(1, probe.live) + } + + @Test + fun releasesTheSourceAfterTheGracePeriodAndKeepsTheLastFix() = + runTest(UnconfinedTestDispatcher()) { + val probe = emitsOnceThenHangs(56.048839, 12.721029) + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + + val fixWhileForeground = state.geohashStateFlow.value + assertTrue("expected a Success, got $fixWhileForeground", fixWhileForeground is LocationState.LocationResult.Success) + + foreground.value = false + // Real grace-period wait, not a test artefact: `settledForeground` + // delays BACKGROUND_GRACE_MS before it settles to `false`, so the + // gate has genuinely not transitioned to Paused until this elapses. + advanceTimeBy(LocationState.BACKGROUND_GRACE_MS + 1) + advanceUntilIdle() + + assertEquals("source must be released once backgrounded", 0, probe.live) + assertEquals( + "the last geohash must survive the release for the ~60 synchronous .value readers", + fixWhileForeground, + state.geohashStateFlow.value, + ) + } + + @Test + fun keepsListeningAcrossABackgroundEdgeShorterThanTheGracePeriod() = + runTest(UnconfinedTestDispatcher()) { + val probe = neverEmits() + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + assertEquals(1, probe.subscriptions) + + foreground.value = false + advanceTimeBy(LocationState.BACKGROUND_GRACE_MS / 2) + foreground.value = true + advanceUntilIdle() + + assertEquals("a brief app switch must not tear down the registration", 1, probe.subscriptions) + assertEquals(1, probe.live) + } + + @Test + fun doesNotReemitLoadingWhenReturningToForegroundWithACachedFix() = + runTest(UnconfinedTestDispatcher()) { + val probe = emitsOnceThenHangs(56.048839, 12.721029) + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + val seen = mutableListOf() + backgroundScope.launch { state.geohashStateFlow.collect { seen.add(it) } } + advanceUntilIdle() + + foreground.value = false + // Same real grace-period wait as above — needed so the gate actually + // reaches Paused before we flip back to foreground and look for a + // stray Loading. + advanceTimeBy(LocationState.BACKGROUND_GRACE_MS + 1) + advanceUntilIdle() + val afterBackground = seen.size + + foreground.value = true + advanceUntilIdle() + + assertTrue( + "returning to foreground must not flash Loading — AroundMeFeedFlow renders an empty feed for it. Saw: ${seen.drop(afterBackground)}", + seen.drop(afterBackground).none { it is LocationState.LocationResult.Loading }, + ) + } + + /** + * NOTE — does not discriminate the `Gate.Listen` Loading-guard it might sound + * like it covers: `geohashStateFlow` is seeded via + * `stateIn(..., latestLocation)`, and `latestLocation` defaults to `Loading` + * at construction, so *every* fresh collector on a brand-new [LocationState] + * sees `Loading` first purely from that seed — regardless of whether + * `Gate.Listen`'s own `if (latest() !is Success) emit(Loading)` line exists, + * is guarded, or is deleted outright. Verified: deleting that emit still + * leaves this test green. The guard's "only when nothing is cached" half IS + * covered, by [doesNotReemitLoadingWhenReturningToForegroundWithACachedFix]. + * The "emits Loading when nothing is cached" half is not protected by any + * test in this file. What this test does verify — honestly — is that a + * fresh [LocationState] surfaces `Loading` before any fix arrives. + */ + @Test + fun freshStateSurfacesLoadingBeforeAnyFix() = + runTest(UnconfinedTestDispatcher()) { + val probe = neverEmits() + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(true) + + val seen = mutableListOf() + backgroundScope.launch { state.geohashStateFlow.collect { seen.add(it) } } + advanceUntilIdle() + + assertTrue("expected Loading, saw $seen", seen.any { it is LocationState.LocationResult.Loading }) + } + + @Test + fun reportsLackPermissionRegardlessOfForeground() = + runTest(UnconfinedTestDispatcher()) { + val probe = neverEmits() + val foreground = MutableStateFlow(true) + val state = stateWith(backgroundScope, foreground, probe) + state.setLocationPermission(false) + + backgroundScope.launch { state.geohashStateFlow.collect { } } + advanceUntilIdle() + + assertEquals(LocationState.LocationResult.LackPermission, state.geohashStateFlow.value) + assertEquals(0, probe.subscriptions) + } + + @Test + fun requestsTheDocumentedPollingProfilePerFlow() = + runTest(UnconfinedTestDispatcher()) { + val foreground = MutableStateFlow(true) + + val coarseProbe = neverEmits() + val coarseState = stateWith(backgroundScope, foreground, coarseProbe) + coarseState.setLocationPermission(true) + backgroundScope.launch { coarseState.geohashStateFlow.collect { } } + + val preciseProbe = neverEmits() + val preciseState = stateWith(backgroundScope, foreground, preciseProbe) + preciseState.setLocationPermission(true) + backgroundScope.launch { preciseState.preciseGeohashStateFlow.collect { } } + + advanceUntilIdle() + + assertEquals( + "geohashStateFlow must poll at the coarse profile", + LocationState.COARSE_MIN_TIME to LocationState.COARSE_MIN_DISTANCE, + coarseProbe.requestedProfile, + ) + assertEquals( + "preciseGeohashStateFlow must poll at the precise profile", + LocationState.PRECISE_MIN_TIME to LocationState.PRECISE_MIN_DISTANCE, + preciseProbe.requestedProfile, + ) + } +} From f09eef1470b3892ae163677a07453058ccac0966 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 30 Jul 2026 11:37:05 +0200 Subject: [PATCH 023/227] feat(amethyst): wire the location foreground gate and refcounted meter --- .../com/vitorpamplona/amethyst/AppModules.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 6b87d3e391..1d652c8485 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -105,6 +105,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter import com.vitorpamplona.amethyst.service.resourceusage.MeteringNostrSigner import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator +import com.vitorpamplona.amethyst.service.resourceusage.RefCountedSession import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant @@ -246,10 +247,17 @@ class AppModules( OtsSharedPreferences(appContext, applicationIOScope) } - // App services that should be run as soon as there are subscribers to their flows + // App services that should be run as soon as there are subscribers to their + // flows. Location additionally releases its OS registration whenever no + // activity is started — see the foreground gate inside LocationState. val locationManager by lazy { Log.d("AppModules", "LocationManager Init") - LocationState(appContext, applicationIOScope, onListening = { locationSession.setActive(it) }) + LocationState( + appContext, + applicationIOScope, + isForeground = foregroundTracker.isForeground, + onListening = { locationRefCount.setActive(it) }, + ) } val connManager = ConnectivityManager(appContext, applicationIOScope) @@ -374,6 +382,11 @@ class AppModules( private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() } private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() } + // LocationState exposes two independent flows that can both be listening at + // once (the "Around Me" feed plus an open geohash chat). Refcounting keeps + // either one stopping from closing the other's segment. + private val locationRefCount = RefCountedSession(locationSession::setActive) + // Time-per-screen (route base names only — arguments never reach the // ledger). Fed by the navigation listener in AppNavigation; foreground // gating means backgrounding on a screen closes its segment. From 2360829e83fd6bbc9f8a0a5b568d90edb77e93f1 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 30 Jul 2026 18:10:24 +0200 Subject: [PATCH 024/227] Code review: - refactor(location): dedupe test fixtures and simplify the gate internals - test(location): assert the release, not just its ledger side-effect - test(location): cover the LocationState -> LocationFlow -> RefCountedSession seam --- .../amethyst/service/location/LocationFlow.kt | 79 +++--- .../location/LocationProviderLadder.kt | 19 +- .../service/location/LocationState.kt | 106 ++++---- .../resourceusage/RefCountedSession.kt | 17 +- .../service/location/LocationFlowTest.kt | 36 +-- .../location/LocationLedgerCompositionTest.kt | 243 ++++++++++++++++++ .../location/LocationProviderLadderTest.kt | 21 +- .../service/location/MockLocationManager.kt | 50 ++++ 8 files changed, 429 insertions(+), 142 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationLedgerCompositionTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/MockLocationManager.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt index 3bc0f40ae2..0fd483cf2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt @@ -60,13 +60,12 @@ import kotlinx.coroutines.launch * the release on that path; see `releasesTheRegistrationWhenTheSeedThrows` in * `LocationFlowTest`. (In principle a collector cancelling mid-seed would also * unwind past `awaitClose` the same way, but that path could not be - * reproduced — `callbackFlow`'s `send` is buffered and returns without - * suspending, so it never observes the cancellation.) + * reproduced — `callbackFlow`'s buffer is empty at this point, so the single + * seed send returns without suspending and never observes the cancellation.) */ class LocationFlow( private val locationManager: LocationManager, private val sdkInt: Int = Build.VERSION.SDK_INT, - private val hasFine: Boolean = false, ) { @SuppressLint("MissingPermission") fun get( @@ -84,51 +83,24 @@ class LocationFlow( // One binder call, reused for both the ladder filter and the seed. val providers = locationManager.allProviders - val candidates = LocationProviderLadder.chooseProviders(sdkInt, hasFine) { it in providers } + val candidates = LocationProviderLadder.chooseProviders(sdkInt) { it in providers } - var registered: String? = null - for (provider in candidates) { - try { - locationManager.requestLocationUpdates( - provider, - minTimeMs, - minDistanceM, - locationCallback, - Looper.getMainLooper(), - ) - registered = provider - break - } catch (e: SecurityException) { - Log.w("LocationFlow", "Provider $provider refused the update request", e) - } - } - - if (registered == null) { - throw SecurityException("No usable location provider. Candidates: $candidates") - } + val registered = + candidates.firstOrNull { requestUpdates(it, minTimeMs, minDistanceM, locationCallback) } + ?: throw SecurityException("No usable location provider. Candidates: $candidates") Log.i("LocationFlow") { "Listening on $registered every ${minTimeMs}ms / ${minDistanceM}m" } onListening?.invoke(true) - // Everything after the acquire runs under try/finally, not under - // awaitClose. freshestLastKnownLocation() just below can throw a - // non-cancellation exception and unwind before awaitClose is ever - // reached — proven by releasesTheRegistrationWhenTheSeedThrows in - // LocationFlowTest, which fails if the release moves into - // awaitClose. Cleanup parked inside awaitClose would then never - // run — the registration would leak and the refcount would stick - // at >= 1 for the life of the process, so location.ms would accrue - // forever with nothing listening. (In principle a collector - // cancelling mid-seed would unwind the same way, but that path - // could not be reproduced: callbackFlow's send is buffered and - // returns without suspending, so it never observes the - // cancellation.) The finally covers both cases regardless. + // Cleanup lives in this finally, not in awaitClose — see the class + // KDoc, and `releasesTheRegistrationWhenTheSeedThrows` in + // LocationFlowTest, which fails if it moves. try { // Seeded after registration so the no-provider path throws // without having emitted anything; seeding first would show the // consumer Success -> LackPermission on a device with no // compatible provider. - freshestLastKnownLocation(providers)?.let { + freshestLastKnownLocation(candidates)?.let { Log.d("LocationFlow") { "Last known location is $it" } send(it) } @@ -141,10 +113,35 @@ class LocationFlow( } } + /** True when the registration was accepted; false when the provider refused it. */ + @SuppressLint("MissingPermission") + private fun requestUpdates( + provider: String, + minTimeMs: Long, + minDistanceM: Float, + locationCallback: LocationListener, + ): Boolean = + try { + locationManager.requestLocationUpdates( + provider, + minTimeMs, + minDistanceM, + locationCallback, + Looper.getMainLooper(), + ) + true + } catch (e: SecurityException) { + Log.w("LocationFlow", "Provider $provider refused the update request", e) + false + } + /** - * The freshest cached fix across every provider. Permission-checked per - * provider like the update request is, so each lookup is guarded — on a - * device where a provider refuses us, the others should still seed. + * The freshest cached fix across the providers the ladder deemed usable. + * Sweeping every provider the device reports instead would, on the + * coarse-only legacy path, pay a guaranteed-to-throw binder call per + * fine-only provider on every flow start. Still guarded per provider, like + * the update request is — on a device where one refuses us, the others + * should still seed. */ @SuppressLint("MissingPermission") private fun freshestLastKnownLocation(providers: List): Location? = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt index 278d57f866..fb9ae6d8a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt @@ -36,10 +36,13 @@ import android.os.Build * Below API 31, `gps`, `passive` and `fused` required `ACCESS_FINE_LOCATION`; * only `network` accepted `ACCESS_COARSE_LOCATION`. Approximate location, which * lets a coarse-only app request any provider and receive a fuzzed result, is an - * Android 12 change. Amethyst declares coarse only, so [hasFine] is always false - * in production — it is a parameter so the function is total over the permission - * axis and both sides of the API branch are testable, not because fine access is - * anticipated. + * Android 12 change. Amethyst declares coarse only, so the legacy branch is + * unconditional below API 31. + * + * Adding `ACCESS_FINE_LOCATION` later would **not** widen this on its own — the + * branch below has no permission input, so pre-31 devices would keep getting + * `network` alone and silently lose the precision the new permission was granted + * for. Whoever adds it must widen the condition here too. * * Returns the ordered candidate list rather than a single choice so the caller * can fall through to the next rung if a registration is refused. An empty list @@ -60,15 +63,9 @@ object LocationProviderLadder { fun chooseProviders( sdkInt: Int, - hasFine: Boolean, exists: (String) -> Boolean, ): List { - val ladder = - if (sdkInt >= Build.VERSION_CODES.S || hasFine) { - FULL_LADDER - } else { - COARSE_ONLY_LEGACY_LADDER - } + val ladder = if (sdkInt >= Build.VERSION_CODES.S) FULL_LADDER else COARSE_ONLY_LEGACY_LADDER return ladder.filter(exists) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index 12dcfa7e60..82ad1bed00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -62,7 +62,7 @@ import kotlinx.coroutines.flow.transformLatest */ class LocationState( context: Context, - scope: CoroutineScope, + private val scope: CoroutineScope, private val isForeground: StateFlow, /** * Resource-ledger hook: true while location updates are actively @@ -88,9 +88,16 @@ class LocationState( /** * How long to keep listening after the last activity stops, so a * one-second app switch doesn't destroy and rebuild the registration. - * Matches the `WhileSubscribed` window below, and is the same intent. + * Same intent as [SUBSCRIPTION_STOP_TIMEOUT_MS], on the other axis. */ const val BACKGROUND_GRACE_MS: Long = 5_000L + + /** + * How long `stateIn` keeps the upstream alive after the last collector + * leaves, so a screen rotation or a tab switch doesn't rebuild the + * registration either. + */ + const val SUBSCRIPTION_STOP_TIMEOUT_MS: Long = 5_000L } sealed class LocationResult { @@ -107,11 +114,12 @@ class LocationState( private var hasLocationPermission = MutableStateFlow(false) - // Volatile: R1 below reads these to decide whether to emit Loading, from a - // different coroutine than the onEach that writes them. - @Volatile private var latestLocation: LocationResult = LocationResult.Loading + // Read by R1 below to decide whether to emit Loading, from a different + // coroutine than the onEach that writes it — hence a StateFlow rather than + // a plain field. + private val latestLocation = MutableStateFlow(LocationResult.Loading) - @Volatile private var latestPreciseLocation: LocationResult = LocationResult.Loading + private val latestPreciseLocation = MutableStateFlow(LocationResult.Loading) fun setLocationPermission(newValue: Boolean) { if (newValue != hasLocationPermission.value) { @@ -152,60 +160,59 @@ class LocationState( }.distinctUntilChanged() @OptIn(ExperimentalCoroutinesApi::class) - private fun geohashFlow( + private fun buildGeohashStateFlow( tag: String, charsCount: Int, minTimeMs: Long, minDistanceM: Float, - latest: () -> LocationResult, - setLatest: (LocationResult) -> Unit, - ): Flow = - gate.transformLatest { state -> - when (state) { - // Deliberately does NOT write to the cache. Today's code emits - // LackPermission without touching latestLocation, and wiping it - // here would cost a Loading emission — and so an empty-feed - // flash — on every permission flap, which is the regression R1 - // exists to prevent. Consumers already see LackPermission from - // the StateFlow; the cache is internal and only decides whether - // Loading is emitted. - Gate.NoPermission -> emit(LocationResult.LackPermission) + cache: MutableStateFlow, + ): StateFlow = + gate + .transformLatest { state -> + when (state) { + // Deliberately does NOT write to the cache. Today's code emits + // LackPermission without touching the cache, and wiping it + // here would cost a Loading emission — and so an empty-feed + // flash — on every permission flap, which is the regression R1 + // exists to prevent. Consumers already see LackPermission from + // the StateFlow; the cache is internal and only decides whether + // Loading is emitted. + Gate.NoPermission -> emit(LocationResult.LackPermission) - // Emit nothing: stateIn keeps the last value, so every - // synchronous .value reader still sees the last known geohash - // while the OS registration is released. - Gate.Paused -> Unit + // Emit nothing: stateIn keeps the last value, so every + // synchronous .value reader still sees the last known geohash + // while the OS registration is released. + Gate.Paused -> Unit - Gate.Listen -> { - // Only when there is nothing cached. Emitting Loading on - // every foreground return would flash the "Around Me" feed - // empty, because AroundMeFeedFlow.convert maps anything - // that is not Success to an empty geotag set. - if (latest() !is LocationResult.Success) emit(LocationResult.Loading) + Gate.Listen -> { + // Only when there is nothing cached. Emitting Loading on + // every foreground return would flash the "Around Me" feed + // empty, because AroundMeFeedFlow.convert maps anything + // that is not Success to an empty geotag set. + if (cache.value !is LocationResult.Success) emit(LocationResult.Loading) - emitAll( - locationSource(minTimeMs, minDistanceM) - .map { LocationResult.Success(it.toGeoHash(charsCount)) as LocationResult } - .onEach { setLatest(it) } - .catch { e -> - Log.w(tag, "Exception in the flow", e) - setLatest(LocationResult.LackPermission) - emit(LocationResult.LackPermission) - }, - ) + emitAll( + locationSource(minTimeMs, minDistanceM) + .map { LocationResult.Success(it.toGeoHash(charsCount)) as LocationResult } + .onEach { cache.value = it } + .catch { e -> + Log.w(tag, "Exception in the flow", e) + cache.value = LocationResult.LackPermission + emit(LocationResult.LackPermission) + }, + ) + } } - } - } + }.stateIn(scope, SharingStarted.WhileSubscribed(SUBSCRIPTION_STOP_TIMEOUT_MS), cache.value) val geohashStateFlow: StateFlow by lazy { - geohashFlow( + buildGeohashStateFlow( tag = "GeohashStateFlow", charsCount = GeohashPrecision.KM_5_X_5.digits, minTimeMs = COARSE_MIN_TIME, minDistanceM = COARSE_MIN_DISTANCE, - latest = { latestLocation }, - setLatest = { latestLocation = it }, - ).stateIn(scope, SharingStarted.WhileSubscribed(5000), latestLocation) + cache = latestLocation, + ) } /** @@ -221,13 +228,12 @@ class LocationState( * app ever requests `ACCESS_FINE_LOCATION`. */ val preciseGeohashStateFlow: StateFlow by lazy { - geohashFlow( + buildGeohashStateFlow( tag = "PreciseGeohashStateFlow", charsCount = GeohashChannelLevel.BUILDING.chars, minTimeMs = PRECISE_MIN_TIME, minDistanceM = PRECISE_MIN_DISTANCE, - latest = { latestPreciseLocation }, - setLatest = { latestPreciseLocation = it }, - ).stateIn(scope, SharingStarted.WhileSubscribed(5000), latestPreciseLocation) + cache = latestPreciseLocation, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt index f73e4996a4..4ef51cc3cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt @@ -34,17 +34,14 @@ package com.vitorpamplona.amethyst.service.resourceusage * off with a holder still active. * * Takes the setter as a lambda rather than a [SessionTimeIntegrator] because - * that is all it needs — and because constructing a real integrator drags in a - * [ResourceUsageAccountant] and a store file to observe one boolean. + * that is all it needs, and so tests need no accountant or store file. * - * Reports **transitions only**, not every call. A 1 -> 2 acquire would otherwise - * re-enter [SessionTimeIntegrator.setActive] with the session already open, - * splitting one segment into two. That happens to be arithmetically harmless - * (`account()` adds each piece, and the pieces are contiguous), and it does not - * inflate a `*.starts` counter either, because [SessionTimeIntegrator] already - * guards its starts increment on `prev == null`. Transition-only is simply the - * contract the name implies, and it keeps the class honest for a future caller - * that reacts to the callback rather than integrating it. + * Reports **transitions only**, not every call — the contract the name implies, + * and what keeps the class honest for a future caller that reacts to the + * callback rather than integrating it. (For [SessionTimeIntegrator] specifically + * a 1 -> 2 re-entry would have been harmless: it splits one segment into two + * contiguous pieces that `account()` re-adds, and its starts increment is + * already guarded on `prev == null`.) * * Releases must be paired with acquires. This class cannot tell an unpaired * release from a real one, so callers guarantee the pairing; see `LocationFlow`, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt index 5329691f00..153a011f3f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt @@ -38,25 +38,10 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class LocationFlowTest { - /** - * A LocationManager that reports [providers] and refuses [denied] with a - * SecurityException, mimicking the pre-API-31 fine-location requirement. - */ private fun manager( providers: List, denied: Set = emptySet(), - ): LocationManager { - val lm = mockk(relaxed = true) - every { lm.allProviders } returns providers - every { lm.getLastKnownLocation(any()) } returns null - every { - lm.requestLocationUpdates(any(), any(), any(), any(), any()) - } answers { - val provider = firstArg() - if (provider in denied) throw SecurityException("denied: $provider") - } - return lm - } + ) = mockLocationManager(providers, denied) @Test fun firesNeitherEdgeWhenNoProviderExists() = @@ -180,6 +165,25 @@ class LocationFlowTest { verify(exactly = 0) { lm.getLastKnownLocation(any()) } } + @Test + fun seedsOnlyFromTheProvidersTheLadderDeemedUsable() = + runTest { + // On the coarse-only legacy path the ladder narrows to `network`, + // and gps/passive/fused would each throw SecurityException. Sweeping + // every provider the device reports would pay those guaranteed + // throws — stack trace plus an eagerly-interpolated Log.w — on every + // flow start, i.e. on every foreground return. + val lm = manager(providers = listOf("fused", "network", "gps", "passive")) + val job = launch { LocationFlow(lm, sdkInt = 30).get(60_000L, 500f).collect { } } + + runCurrent() + + verify(exactly = 1) { lm.getLastKnownLocation("network") } + verify(exactly = 0) { lm.getLastKnownLocation(neq("network")) } + + job.cancelAndJoin() + } + @Test fun registersOnExactlyOneProvider() = runTest { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationLedgerCompositionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationLedgerCompositionTest.kt new file mode 100644 index 0000000000..3f8b0eaf31 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationLedgerCompositionTest.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.location + +import android.content.Context +import android.location.LocationListener +import android.location.LocationManager +import com.vitorpamplona.amethyst.service.resourceusage.RefCountedSession +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Wires the *real* [LocationState] -> [LocationFlow] -> [RefCountedSession] + * seam together, exercising [LocationState]'s **default** `locationSource` + * rather than overriding it the way [LocationStateTest] and [LocationFlowTest] + * do. Each of those files mocks the other layer out, so neither can catch a + * regression in the composition itself — e.g. a refcount that gets pinned + * open (or closed) because `onListening` fires in an order or multiplicity + * the two independent [LocationState] flows didn't anticipate. That failure + * mode is unrecoverable at runtime: a pinned-open refcount means + * `location.ms` accrues in the resource-usage ledger forever, with nothing + * actually listening. + * + * Same dispatcher note as [LocationStateTest]: `runTest {}`'s default + * `StandardTestDispatcher` does not drive this `combine` + `transformLatest` + + * `stateIn(WhileSubscribed)` + `backgroundScope.launch { collect }` chain to + * completion via `advanceUntilIdle()` alone, so every test here runs on + * `UnconfinedTestDispatcher`. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class LocationLedgerCompositionTest { + /** A [LocationManager] that reports two providers and never denies registration. */ + private fun locationManager(): LocationManager = mockLocationManager(providers = listOf("fused", "network")) + + /** A [Context] whose `LOCATION_SERVICE` lookup returns [locationManager]. */ + private fun contextWithLocationService(locationManager: LocationManager): Context { + val context = mockk(relaxed = true) + every { context.getSystemService(Context.LOCATION_SERVICE) } returns locationManager + return context + } + + /** + * Wires a real [LocationState] to a real [RefCountedSession], standing in + * for the resource-usage ledger session. Deliberately does NOT override + * `locationSource` — that is the whole point of this file. + * + * Exposes [locationManager] so a test can `verify` against it directly — + * e.g. that exactly one of two concurrent registrations was torn down — + * rather than inferring release from the ledger alone, which cannot tell + * "released" from "never released" on its own (see + * `bothFlowsActiveThenOneStopsKeepsTheLedgerOpen`). + */ + private class Harness( + scope: CoroutineScope, + context: Context, + val locationManager: LocationManager, + ) { + /** Stand-in for `SessionTimeIntegrator.setActive`, i.e. the ledger. */ + val ledger = mutableListOf() + private val refCount = RefCountedSession { ledger.add(it) } + val foreground = MutableStateFlow(true) + val state = + LocationState( + context = context, + scope = scope, + isForeground = foreground, + onListening = { refCount.setActive(it) }, + ) + } + + private fun harness(scope: CoroutineScope): Harness { + val locationManager = locationManager() + return Harness(scope, contextWithLocationService(locationManager), locationManager) + } + + /** + * The interleaving [RefCountedSession] exists for. Two independent + * [LocationState] flows both register with the OS; the ledger must open + * once, not twice. Stopping one of the two must NOT close the ledger, + * because the other is still listening — this half is exactly what a + * per-flow (rather than refcounted) `onListening` -> ledger wiring would + * get wrong, and what a per-layer test (mocking `locationSource`, or + * driving `RefCountedSession` directly with synthetic booleans) cannot + * see, because it never lets two real [LocationFlow] registrations race + * each other through the shared hook. + * + * Asserts its premise, not just its consequence. The ledger staying at + * `[true]` after the precise flow stops is also what you'd see if that + * flow's registration never released at all — `onListening` deleted from + * [LocationFlow]'s `finally`, cleanup moved somewhere unreached, or the + * [LocationState.SUBSCRIPTION_STOP_TIMEOUT_MS] teardown simply hadn't + * elapsed. The + * `removeUpdates` verify below rules out the OS-registration side of + * that; cancelling the coarse flow too and checking the full `[true, + * false]` sequence at the end rules out the `onListening` side, since + * `removeUpdates` alone fires unconditionally in the `finally` and would + * not by itself notice `onListening` going missing. + */ + @Test + fun bothFlowsActiveThenOneStopsKeepsTheLedgerOpen() = + runTest(UnconfinedTestDispatcher()) { + val harness = harness(backgroundScope) + harness.state.setLocationPermission(true) + + val preciseJob = backgroundScope.launch { harness.state.preciseGeohashStateFlow.collect { } } + val coarseJob = backgroundScope.launch { harness.state.geohashStateFlow.collect { } } + advanceUntilIdle() + + assertEquals( + "both flows registering with the OS must open the ledger exactly once, not twice", + listOf(true), + harness.ledger, + ) + + preciseJob.cancelAndJoin() + // The cancelled collector alone doesn't tear down the upstream + // registration — WhileSubscribed keeps it alive for a grace + // period first. Advance past it so the release below is + // actually observable, not just "not yet due". + advanceTimeBy(LocationState.SUBSCRIPTION_STOP_TIMEOUT_MS + 1) + advanceUntilIdle() + + // Exactly one of the two OS registrations should have been torn + // down at this point — the precise one — while the coarse one is + // still live. + verify(exactly = 1) { harness.locationManager.removeUpdates(any()) } + + assertEquals( + "the coarse flow is still listening; stopping the precise one alone must not close the ledger", + listOf(true), + harness.ledger, + ) + + // Stop the coarse flow too, so the ledger closing here proves + // the release path genuinely works end-to-end for this test's + // own harness — not merely that the assertions above never + // exercised it. + coarseJob.cancelAndJoin() + advanceTimeBy(LocationState.SUBSCRIPTION_STOP_TIMEOUT_MS + 1) + advanceUntilIdle() + + verify(exactly = 2) { harness.locationManager.removeUpdates(any()) } + + assertEquals( + "both flows stopping must close the ledger exactly once, proving the precise flow's earlier release was real", + listOf(true, false), + harness.ledger, + ) + } + + /** + * The full foreground -> background cycle with both flows registered. + * The ledger must see exactly `[true, false]`: one open when the first + * flow registers (the second joining must not re-open it), one close + * once BOTH flows have released (not one close per flow, and not before + * the background grace period each flow independently waits out). + */ + @Test + fun fullCycleWithBothFlowsOpensAndClosesTheLedgerExactlyOnce() = + runTest(UnconfinedTestDispatcher()) { + val harness = harness(backgroundScope) + harness.state.setLocationPermission(true) + + backgroundScope.launch { harness.state.geohashStateFlow.collect { } } + backgroundScope.launch { harness.state.preciseGeohashStateFlow.collect { } } + advanceUntilIdle() + + assertEquals(listOf(true), harness.ledger) + + harness.foreground.value = false + // Real grace-period wait, not a test artefact: settledForeground + // delays BACKGROUND_GRACE_MS before the gate reaches Paused for + // either flow. See LocationStateTest for the worked example this + // follows. + advanceTimeBy(LocationState.BACKGROUND_GRACE_MS + 1) + advanceUntilIdle() + + assertEquals( + "backgrounding must close the ledger exactly once, not once per flow, and not before the grace period", + listOf(true, false), + harness.ledger, + ) + } + + /** + * After a full open/close cycle, returning to the foreground must reopen + * the ledger exactly once — not once per flow, and not a second time on + * top of a close that never actually happened. + */ + @Test + fun returningToForegroundReopensTheLedgerExactlyOnce() = + runTest(UnconfinedTestDispatcher()) { + val harness = harness(backgroundScope) + harness.state.setLocationPermission(true) + + backgroundScope.launch { harness.state.geohashStateFlow.collect { } } + backgroundScope.launch { harness.state.preciseGeohashStateFlow.collect { } } + advanceUntilIdle() + + harness.foreground.value = false + advanceTimeBy(LocationState.BACKGROUND_GRACE_MS + 1) + advanceUntilIdle() + + harness.foreground.value = true + advanceUntilIdle() + + assertEquals( + "a return to foreground after a full close must reopen the ledger exactly once", + listOf(true, false, true), + harness.ledger, + ) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt index 5928e9f5bd..9f804817ca 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt @@ -30,7 +30,7 @@ class LocationProviderLadderTest { fun modernDevicePrefersFusedThenFallsBackInOrder() { assertEquals( listOf("fused", "network", "gps", "passive"), - LocationProviderLadder.chooseProviders(sdkInt = 31, hasFine = false) { it in all }, + LocationProviderLadder.chooseProviders(sdkInt = 31) { it in all }, ) } @@ -40,25 +40,18 @@ class LocationProviderLadderTest { assertEquals( listOf("network", "passive"), - LocationProviderLadder.chooseProviders(sdkInt = 37, hasFine = false) { it in present }, + LocationProviderLadder.chooseProviders(sdkInt = 37) { it in present }, ) } @Test fun coarseOnlyBelowApi31GetsNetworkOnly() { // gps, passive and fused all required ACCESS_FINE_LOCATION before - // Android 12 (see Hypothesis H1 in the design spec). + // Android 12 (see Hypothesis H1 in the design spec), and Amethyst + // declares ACCESS_COARSE_LOCATION only. assertEquals( listOf("network"), - LocationProviderLadder.chooseProviders(sdkInt = 30, hasFine = false) { it in all }, - ) - } - - @Test - fun fineBelowApi31GetsTheFullLadder() { - assertEquals( - listOf("fused", "network", "gps", "passive"), - LocationProviderLadder.chooseProviders(sdkInt = 26, hasFine = true) { it in all }, + LocationProviderLadder.chooseProviders(sdkInt = 30) { it in all }, ) } @@ -68,7 +61,7 @@ class LocationProviderLadderTest { assertEquals( emptyList(), - LocationProviderLadder.chooseProviders(sdkInt = 28, hasFine = false) { it in present }, + LocationProviderLadder.chooseProviders(sdkInt = 28) { it in present }, ) } @@ -76,7 +69,7 @@ class LocationProviderLadderTest { fun noProvidersAtAllGetsNothing() { assertEquals( emptyList(), - LocationProviderLadder.chooseProviders(sdkInt = 37, hasFine = false) { false }, + LocationProviderLadder.chooseProviders(sdkInt = 37) { false }, ) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/MockLocationManager.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/MockLocationManager.kt new file mode 100644 index 0000000000..95dba139b1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/MockLocationManager.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.location + +import android.location.LocationListener +import android.location.LocationManager +import io.mockk.every +import io.mockk.mockk + +/** + * A [LocationManager] that reports [providers], has no cached fix, and refuses + * [denied] with a `SecurityException` — mimicking the pre-API-31 fine-location + * requirement. + * + * Shared by [LocationFlowTest] and [LocationLedgerCompositionTest] so the mock's + * surface tracks the binder calls [LocationFlow] actually makes in one place. + */ +internal fun mockLocationManager( + providers: List, + denied: Set = emptySet(), +): LocationManager { + val lm = mockk(relaxed = true) + every { lm.allProviders } returns providers + every { lm.getLastKnownLocation(any()) } returns null + every { + lm.requestLocationUpdates(any(), any(), any(), any(), any()) + } answers { + val provider = firstArg() + if (provider in denied) throw SecurityException("denied: $provider") + } + return lm +} From f2895b2d297ac689fcd9007b7cc515580adf2d57 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 09:31:17 -0400 Subject: [PATCH 025/227] fix(relays): count filters once per filter, not once per entity it names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Active Subscriptions screen reported two different things as "filters". The header counted filters; a purpose card summed its per-entity rows. Those only agree when every filter names exactly one entity — and the busiest ones name many, because batching is the whole point of them: one `#e` filter per relay carrying every followed chat, one `#d` filter per host relay carrying every joined group. So six chats on six relays read as 144 filters where 24 were on the wire, and "8% of all" divided the inflated number by the real one. The screen exists to answer "why do I have this many subscriptions", and it was overstating its loudest purposes by exactly their batching factor — the opposite of the job. A purpose now tallies each filter once as it is scanned, before the fan-out. The per-entity rows stay, because "which chats is this relay serving" is worth seeing, but they are a breakdown rather than a total: the field is renamed to namedInFilters and says in its docs that it is not summable. The aggregation moves out of the ViewModel into a pure aggregateSubscriptions() so the invariant is testable without a relay pool. AggregateSubscriptionsTest pins it on the reported shape, and was mutation-checked: restoring the old `entityRows.sumOf { … }` fails three of its four cases, the fourth being the relay count, which never depended on it. On device, Public Chat goes from 144 filters over 6 relays to 18 over the same 6, and Relay Groups from 116 to 96. No filter changed; only the arithmetic. Co-Authored-By: Claude Opus 5 (1M context) --- .../ActiveSubscriptionsViewModel.kt | 209 +++++++++++------- .../AggregateSubscriptionsTest.kt | 115 ++++++++++ 2 files changed, 244 insertions(+), 80 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt index 1074c360a0..3cd5f1fe55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayF import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -64,7 +65,15 @@ data class SubscriptionEntityRow( val scope: IFeedTopNavPerRelayFilter?, val detail: String?, val relays: List, - val filterCount: Int, + /** + * How many in-flight filters name this entity. + * + * **Not summable across entities.** One batched filter names every chat its relay serves, so it + * counts once for each of them — summing these to get a purpose's total is how "6 chats on 6 + * relays" once read as 144 filters when 24 were on the wire. [SubscriptionPurposeRow.filterCount] + * counts filters, and is the only number that belongs next to a total. + */ + val namedInFilters: Int, ) /** @@ -83,11 +92,25 @@ private data class EntityKey( @Immutable data class SubscriptionPurposeRow( val purpose: SubPurpose, + /** Filters actually in flight for this purpose — the same unit as [ActiveSubscriptionsState.totalFilters]. */ val filterCount: Int, val relays: List, val entities: List, ) +/** + * One purpose's tally for one account, accumulated in a single pass. + * + * [filters] is incremented once per filter; [entities] records the same filter against every entity + * it names. Keeping both means the card can report a real filter count while still breaking down + * which chats or communities that filter is for. + */ +private class PurposeTally { + var filters = 0 + val relays = mutableSetOf() + val entities = mutableMapOf>() +} + @Immutable data class SubscriptionAccountRow( /** Null groups everything not yet attributed to an account. Shown last, never hidden. */ @@ -126,88 +149,114 @@ class ActiveSubscriptionsViewModel : ViewModel() { private suspend fun snapshot(): ActiveSubscriptionsState = withContext(Dispatchers.Default) { val client = Amethyst.instance.client - - // account -> purpose -> entity -> relays / count - val byAccount = mutableMapOf>>>() - val detailOf = mutableMapOf, String?>() - val scopeOf = mutableMapOf, IFeedTopNavPerRelayFilter>() - var total = 0 - var untagged = 0 - val allRelays = mutableSetOf() - - client.connectedRelaysFlow().value.forEach { relay -> - client.activeRequests(relay).values.flatten().forEach { filter -> - total++ - val explained = filter as? ExplainedFilter - if (explained == null) { - untagged++ - return@forEach - } - allRelays.add(relay) - // Discovery filters name no entity — they go looking for things rather than - // serving known ones — but they do carry the selection they search within, which - // is what keeps them from collapsing into one nameless row per purpose. - val scopeKey = explained.scope?.let { it::class.simpleName } - // A batched filter serves several entities at once — relay-group state is one #d - // filter per host relay carrying every joined group on it — so it contributes a - // row to each of them rather than collapsing to "All". - val entities: List = explained.entityIds?.takeIf { it.isNotEmpty() } ?: listOf(null) - entities.forEach { entityId -> - val key = EntityKey(entityId, scopeKey) - byAccount - .getOrPut(explained.accountPubKey) { mutableMapOf() } - .getOrPut(explained.purpose) { mutableMapOf() } - .getOrPut(key) { mutableListOf() } - .add(relay) - detailOf[explained.purpose to key] = explained.purposeDetail - explained.scope?.let { scopeOf.getOrPut(explained.purpose to key) { it } } - } - } - } - - val accounts = - byAccount - .map { (account, purposes) -> - val purposeRows = - purposes - .map { (purpose, entities) -> - val entityRows = - entities - .map { (key, relays) -> - SubscriptionEntityRow( - entityId = key.entityId, - scope = scopeOf[purpose to key], - detail = detailOf[purpose to key], - relays = relays.distinct().sortedBy { it.url }, - filterCount = relays.size, - ) - }.sortedByDescending { it.filterCount } - SubscriptionPurposeRow( - purpose = purpose, - filterCount = entityRows.sumOf { it.filterCount }, - relays = entityRows.flatMap { it.relays }.distinct(), - entities = entityRows, - ) - }.sortedByDescending { it.filterCount } - SubscriptionAccountRow( - accountPubKey = account, - filterCount = purposeRows.sumOf { it.filterCount }, - relays = purposeRows.flatMap { it.relays }.distinct(), - purposes = purposeRows, - ) - } - // unattributed group last, so it reads as a remainder rather than a headline - .sortedWith(compareBy { it.accountPubKey == null }.thenByDescending { it.filterCount }) - - ActiveSubscriptionsState( - accounts = accounts, - totalFilters = total, - totalRelays = allRelays.size, - untaggedFilters = untagged, + aggregateSubscriptions( + client.connectedRelaysFlow().value.associateWith { relay -> + client.activeRequests(relay).values.flatten() + }, ) } companion object { - const val REFRESH_MS = 2_000L + private const val REFRESH_MS = 2000L } } + +/** + * Folds the in-flight filters into the screen's rows. + * + * Pure and separate from the ViewModel so the invariant it exists to keep — every tagged filter + * counted **exactly once**, so a purpose's count shares a unit with the total it is drawn against — + * is testable without a relay pool. It did not hold before: purposes summed their per-entity rows, + * and a batched filter naming six chats counted six times. + */ +fun aggregateSubscriptions(filtersByRelay: Map>): ActiveSubscriptionsState { + // account -> purpose -> tally (real filter count + relays + per-entity breakdown) + val byAccount = mutableMapOf>() + val detailOf = mutableMapOf, String?>() + val scopeOf = mutableMapOf, IFeedTopNavPerRelayFilter>() + var total = 0 + var untagged = 0 + val allRelays = mutableSetOf() + + filtersByRelay.forEach { (relay, filters) -> + filters.forEach { filter -> + total++ + val explained = filter as? ExplainedFilter + if (explained == null) { + untagged++ + return@forEach + } + allRelays.add(relay) + // Discovery filters name no entity — they go looking for things rather than + // serving known ones — but they do carry the selection they search within, which + // is what keeps them from collapsing into one nameless row per purpose. + val scopeKey = explained.scope?.let { it::class.simpleName } + + val tally = + byAccount + .getOrPut(explained.accountPubKey) { mutableMapOf() } + .getOrPut(explained.purpose) { PurposeTally() } + + // The filter counts ONCE, here — before it is fanned out below. This is the + // number that shares a unit with `total`, so a card's share of the whole is a + // comparison of like with like. + tally.filters++ + tally.relays.add(relay) + + // A batched filter serves several entities at once — relay-group state is one #d + // filter per host relay carrying every joined group on it — so it contributes a + // row to each of them rather than collapsing to "All". These rows are a + // breakdown, never a total: see [SubscriptionEntityRow.namedInFilters]. + val entities: List = explained.entityIds?.takeIf { it.isNotEmpty() } ?: listOf(null) + entities.forEach { entityId -> + val key = EntityKey(entityId, scopeKey) + tally.entities.getOrPut(key) { mutableListOf() }.add(relay) + detailOf[explained.purpose to key] = explained.purposeDetail + explained.scope?.let { scopeOf.getOrPut(explained.purpose to key) { it } } + } + } + } + + val accounts = + byAccount + .map { (account, purposes) -> + val purposeRows = + purposes + .map { (purpose, tally) -> + val entityRows = + tally.entities + .map { (key, relays) -> + SubscriptionEntityRow( + entityId = key.entityId, + scope = scopeOf[purpose to key], + detail = detailOf[purpose to key], + relays = relays.distinct().sortedBy { it.url }, + namedInFilters = relays.size, + ) + }.sortedByDescending { it.namedInFilters } + SubscriptionPurposeRow( + purpose = purpose, + // The tally, NOT the sum of the entity rows — a batched filter + // appears in one row per entity it names. + filterCount = tally.filters, + relays = tally.relays.sortedBy { it.url }, + entities = entityRows, + ) + }.sortedByDescending { it.filterCount } + SubscriptionAccountRow( + accountPubKey = account, + filterCount = purposeRows.sumOf { it.filterCount }, + relays = purposeRows.flatMap { it.relays }.distinct(), + purposes = purposeRows, + ) + } + // unattributed group last, so it reads as a remainder rather than a headline + .sortedWith(compareBy { it.accountPubKey == null }.thenByDescending { it.filterCount }) + + return ActiveSubscriptionsState( + accounts = accounts, + totalFilters = total, + totalRelays = allRelays.size, + untaggedFilters = untagged, + ) +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt new file mode 100644 index 0000000000..88f32a424c --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions + +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The unit invariant behind the Active Subscriptions screen: a purpose's filter count and the + * screen's total must be the *same thing counted the same way*, because the card draws one as a + * share of the other. + * + * It was broken by batching. A relay-group state filter carries every joined group on its host + * relay, and a public-chat filter carries every followed chat — so summing the per-entity rows + * counted one filter once per entity it named. Six chats over six relays reported 144 filters where + * 24 were on the wire, and the resulting "8% of all" divided an inflated number by a real one. + */ +class AggregateSubscriptionsTest { + private val account = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b" + private val chats = (1..6).map { "cafe$it".padEnd(64, '0') } + + private fun relay(n: Int) = NormalizedRelayUrl("wss://relay$n.example/") + + /** One batched filter naming every chat the relay serves — what the real builders emit. */ + private fun batched(kind: Int) = + ExplainedFilter( + purpose = SubPurpose.PUBLIC_CHATS, + entityIds = chats, + kinds = listOf(kind), + tags = mapOf("e" to chats), + accountPubKey = account, + ) + + /** Six relays, each carrying four batched filters that each name all six chats. */ + private fun sixChatsOnSixRelays(): Map> = (1..6).associate { r -> relay(r) to listOf(batched(40), batched(41), batched(42), batched(43)) } + + @Test + fun `a batched filter counts once, not once per entity it names`() { + val state = aggregateSubscriptions(sixChatsOnSixRelays()) + + // 6 relays x 4 filters = 24 actually in flight. The bug reported 144 (24 x 6 entities). + assertEquals(24, state.totalFilters) + + val purpose = + state.accounts + .single() + .purposes + .single() + assertEquals(24, purpose.filterCount) + + // The per-entity breakdown is still there, and still says each chat is named by 24 filters. + assertEquals(6, purpose.entities.size) + purpose.entities.forEach { assertEquals(24, it.namedInFilters) } + } + + @Test + fun `purpose counts sum to the total, so a share of the whole is a like-for-like ratio`() { + val state = aggregateSubscriptions(sixChatsOnSixRelays()) + + val summed = state.accounts.sumOf { acct -> acct.purposes.sumOf { it.filterCount } } + assertEquals(state.totalFilters, summed) + assertEquals(state.totalFilters, state.accounts.sumOf { it.filterCount }) + } + + @Test + fun `relays are counted distinctly, not once per filter that reaches them`() { + val state = aggregateSubscriptions(sixChatsOnSixRelays()) + + assertEquals(6, state.totalRelays) + assertEquals( + 6, + state.accounts + .single() + .purposes + .single() + .relays.size, + ) + } + + @Test + fun `untagged filters are reported but never attributed to a purpose`() { + val state = + aggregateSubscriptions( + mapOf(relay(1) to listOf(batched(42), Filter(kinds = listOf(1)))), + ) + + assertEquals(2, state.totalFilters) + assertEquals(1, state.untaggedFilters) + // Only the tagged one reaches a card, so the cards no longer add up to the total here — + // which is exactly what the untagged line under the header exists to explain. + assertEquals(1, state.accounts.sumOf { it.filterCount }) + } +} From 50e61901f772480cc0582a8389dad8743bcb1052 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 13:38:04 +0000 Subject: [PATCH 026/227] fix: read profile json with the same lenient parser that renders it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contactMetaData() decodes kind-0 content with the lenient JsonMapper, but contactMetadataJson() ran the strict default parser. Profiles that sit in that gap (bare keys, unquoted values) rendered fine yet read back as null, so updateFromPast() started from an empty map and silently dropped every field Amethyst does not edit itself the next time the owner touched their profile — the exact thing its "tries to not delete any existing attribute that we do not work with" contract promises not to do. Both accessors now use JsonMapper.jsonInstance. The lenient reader tags unquoted tokens as strings, so re-encoding still emits valid JSON. Also pins the behaviour of two malformed kind-0s seen in the wild — a JavaScript object literal and a value truncated by stray quotes. Neither is recoverable by any parser; the test records that they are dropped with a warning and never throw. --- .../nip01Core/metadata/MetadataEvent.kt | 6 +- .../metadata/MetadataEventWildProfilesTest.kt | 78 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index acc92802f0..0e31221c47 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -93,7 +93,11 @@ class MetadataEvent( EMPTY_METADATA_JSON } else { try { - Json.parseToJsonElement(content) as JsonObject + // Must use the same lenient parser as contactMetaData(). A profile + // Amethyst can render but not re-read as JSON makes updateFromPast + // fall back to an empty map, silently wiping every field we do not + // manage ourselves the next time this user edits their profile. + JsonMapper.jsonInstance.parseToJsonElement(content) as JsonObject } catch (e: Exception) { if (e is CancellationException) throw e Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEventWildProfilesTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEventWildProfilesTest.kt index 811e20b096..e7c7a7a82e 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEventWildProfilesTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEventWildProfilesTest.kt @@ -22,6 +22,10 @@ package com.vitorpamplona.quartz.nip01Core.metadata import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -151,4 +155,78 @@ class MetadataEventWildProfilesTest { assertNull(meta.birthday, "$label: an ambiguous string birthday is dropped, not fatal") } } + + /** + * `{name: 'lmn_account_1', about: 'A test account'}` — a JavaScript object + * literal, not JSON: bare keys *and* single-quoted values. There is no parser, + * lenient or not, that reads this as the author meant it, so the profile is + * dropped with a warning. What matters is that the event itself still decodes + * and nothing downstream throws. + */ + @Test + fun javascriptObjectLiteralContentIsDroppedNotFatal() { + val json = + """ + {"id":"b0d86331add8bafc8067d2648df268d020d8c8d27593d236429ae58a558d9891","pubkey":"50d09ecac499e0aa07d266135075fb6ae1d7fce739fe32a3dc52ad117eac6373","created_at":1716362070,"kind":0,"tags":[],"content":"{name: 'lmn_account_1', about: 'A test account'}","sig":"9c008cf3d93e6ec1ae55a07d61d9a4a53e8fe3ddf8a32875503fa5622c6d629814a940a9ed34e0f9f9cf7e713b025eb771431e4b4618eaeed374280a62996b7c"} + """.trimIndent() + + bothMappers(json) { label, event -> + assertEquals("50d09ecac499e0aa07d266135075fb6ae1d7fce739fe32a3dc52ad117eac6373", event.pubKey, label) + assertNull(event.contactMetaData(), "$label: a js object literal has no readable profile") + assertNull(event.contactMetadataJson(), label) + assertEquals("", event.indexableContent(), "$label: nothing to index, but no throw") + } + } + + /** + * `{"name":"test0","display_name":test0""}` — well-formed up to `display_name`, + * then stray quotes truncate it. Same outcome: dropped, never fatal. A partial + * "read the prefix" recovery is deliberately not attempted — a value that was + * cut off mid-write is not a value we can vouch for. + */ + @Test + fun strayQuoteContentIsDroppedNotFatal() { + val json = + """ + {"id":"e77763a8c547cd120e99d245fc7cbef2212124ef4644f28b086db08230f1065d","pubkey":"961587af1a99984a22cda1f14d020098bc47d687874d5269219305cec12daaeb","created_at":1693387734,"kind":0,"tags":[],"content":"{\"name\":\"test0\",\"display_name\":test0\"\"}","sig":"50bea1dff881a3fae0c7cd795b5faba55fd16e6477826f05010c8cf1a36edfd26d728ebefaa1b62914a3162b4859a2bb8b16f8d7b6c8548f88ac09228927b5ad"} + """.trimIndent() + + bothMappers(json) { label, event -> + assertEquals("961587af1a99984a22cda1f14d020098bc47d687874d5269219305cec12daaeb", event.pubKey, label) + assertNull(event.contactMetaData(), "$label: a truncated value has no readable profile") + assertNull(event.contactMetadataJson(), label) + assertEquals("", event.indexableContent(), "$label: nothing to index, but no throw") + } + } + + /** + * Bare keys — accepted by the lenient reader [contactMetaData] uses, so the + * profile renders. [contactMetadataJson] must accept exactly the same input: + * it used to run the *strict* parser, return null, and make [updateFromPast] + * start from an empty map — silently deleting every field Amethyst does not + * edit itself the first time this user touched their profile. + */ + @Test + fun leniencyGapProfileKeepsForeignFieldsOnEdit() { + val json = + """ + {"id":"b0d86331add8bafc8067d2648df268d020d8c8d27593d236429ae58a558d9891","pubkey":"50d09ecac499e0aa07d266135075fb6ae1d7fce739fe32a3dc52ad117eac6373","created_at":1716362070,"kind":0,"tags":[],"content":"{name:\"bob\",about:\"hi\",custom_field:\"keepme\"}","sig":"9c008cf3d93e6ec1ae55a07d61d9a4a53e8fe3ddf8a32875503fa5622c6d629814a940a9ed34e0f9f9cf7e713b025eb771431e4b4618eaeed374280a62996b7c"} + """.trimIndent() + + bothMappers(json) { label, event -> + val meta = event.contactMetaData() + assertIs(meta, label) + assertEquals("bob", meta.name, label) + + val asJson = event.contactMetadataJson() + assertIs(asJson, "$label: both accessors must agree on what parses") + assertEquals("keepme", asJson["custom_field"]?.jsonPrimitive?.content, label) + + val updated = MetadataEvent.updateFromPast(latest = event, name = "alice", createdAt = 1716362080) + val updatedJson = Json.parseToJsonElement(updated.content).jsonObject + assertEquals("alice", updatedJson["name"]?.jsonPrimitive?.content, label) + assertEquals("hi", updatedJson["about"]?.jsonPrimitive?.content, "$label: untouched fields survive") + assertEquals("keepme", updatedJson["custom_field"]?.jsonPrimitive?.content, "$label: foreign fields survive") + } + } } From 61611bdc48db8cb670964a5242dc2a7ccd705a51 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 09:56:01 -0400 Subject: [PATCH 027/227] feat(subscriptions): every account pulls while a screen is up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Keep this account active in the background" gated subscriptions everywhere, not just in the background. An account you had not opted in for showed no notifications and no DMs even with the app open in front of you — you had to switch to it and wait for its subscriptions to mount from scratch. The setting's name only ever promised something about being away. So the rule now matches the name. While any activity is STARTED, every loaded account pulls its own notifications, DMs and gift wraps: the user can switch accounts at any moment and expects the one they land on to be current, and this costs nothing once the app is away because it ends with the screen. When the app goes away, the set narrows to the accounts that opted in — which is the only thing the flag decides now. The service layers stay where they were, gated on the master switch AND somebody having opted in. A foreground-only account must never start a foreground service that outlives the screen that wanted it, so those two questions are answered from one snapshot of accounts + flags rather than two. The registry loses "Background" from its name along with the assumption: it mounts exactly the set it is handed and decides nothing, so the rule lives in one place. Verified on emulator-5554 with 4 loaded accounts, 1 opted in. Foreground: 4 mounted, and the account that had no section on the subscriptions screen at all now shows Notifications 8 filters / 2 relays plus DM Inbox. On HOME: 3 mounted (-1), releasing the one that never opted in. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/vitorpamplona/amethyst/AppModules.kt | 7 +- .../AlwaysOnNotificationServiceManager.kt | 164 +++++++++++------- .../account/AccountFilterAssembler.kt | 2 +- ...stry.kt => AccountSubscriptionRegistry.kt} | 53 +++--- 4 files changed, 129 insertions(+), 97 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/{BackgroundAccountSubscriptionRegistry.kt => AccountSubscriptionRegistry.kt} (62%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index bb895e81b8..8098fed6e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -95,7 +95,7 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoor import com.vitorpamplona.amethyst.service.relayClient.diagnostics.BootRelayDiagnostics import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.BackgroundAccountSubscriptionRegistry +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountSubscriptionRegistry import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger @@ -963,7 +963,7 @@ class AppModules( // account-level subscriptions (notifications, DMs, gift wraps, wallet), with no // AccountViewModel behind it. Driven by alwaysOnNotificationServiceManager below, // which already tracks which accounts opted in. - val backgroundAccountSubscriptions = BackgroundAccountSubscriptionRegistry(sources.account) + val accountSubscriptions = AccountSubscriptionRegistry(sources.account) // Manages always-on notification service lifecycle. Preloads every saved // writable account while enabled so GiftWraps for non-active accounts still @@ -974,7 +974,8 @@ class AppModules( scope = applicationIOScope, accountsCache = accountsCache, localPreferences = LocalPreferences, - backgroundSubscriptions = backgroundAccountSubscriptions, + subscriptions = accountSubscriptions, + isForeground = foregroundTracker.isForeground, activePubKeyProvider = { sessionManager.loggedInAccount()?.pubKey }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt index 039398c66f..d2bcd9d240 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt @@ -22,14 +22,17 @@ package com.vitorpamplona.amethyst.service.notifications import android.content.Context import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.BackgroundAccountSubscriptionRegistry +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountSubscriptionRegistry import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -46,32 +49,45 @@ import kotlinx.coroutines.launch * L4 - BootCompletedReceiver (restart on boot) * L5 - ServiceWatchdogManager (AlarmManager, 5-min health check) * - * Two switches gate the system: + * It also decides **which accounts pull from relays**, which is a different question from + * whether the service runs, and the two are deliberately not gated the same way. * - * - The **global master** ([LocalPreferences.notificationServiceEnabledFlow], the - * "Background notification service" toggle / Quick Settings tile). When off, every - * layer is torn down and nothing restarts, regardless of any account's setting — - * this is the battery-saver "airplane mode". Persisted, so an explicit off survives - * restarts and crashes. - * - The **per-account participation** flag ([com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService], - * "Keep this account active in the background") **or** its NIP-46 signer toggle - * ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]). While the master is - * on, the service runs as long as **at least one** writable account has either flag on — the - * background signer relies on the same foreground service to keep answering requests. + * ## Who subscribes * - * While the master is on, every saved writable account is kept loaded in - * [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps - * addressed to any of them (delivered via open relay subscriptions) get unwrapped by - * the owning account's `newNotesPreProcessor`. Without this, wraps for non-active - * accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no - * subscriber able to decrypt them. + * - **While a screen is up: every loaded account.** The user can switch accounts at any moment + * and expects the one they land on to be current, so all of them keep their own notifications, + * DMs and gift wraps live. This costs nothing once the app is away — it ends with the screen. + * - **While the app is away: only the accounts that opted in**, via + * [com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService] ("Keep this + * account active in the background") or their NIP-46 signer toggle + * ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]). + * + * That is what the setting's name promises, and for a while it did not hold: participation gated + * subscriptions everywhere, so an account you had not opted in for showed no notifications even + * with the app open in front of you. + * + * ## Whether the service runs + * + * The five layers are a background concern, so they stay gated on **both** the global master + * ([LocalPreferences.notificationServiceEnabledFlow], the "Background notification service" + * toggle / Quick Settings tile — the battery-saver "airplane mode", persisted so an explicit off + * survives restarts) **and** at least one account having opted in. A foreground-only account must + * never start a foreground service that outlives the screen that wanted it. + * + * Every saved writable account is kept loaded in [AccountCacheState] whenever either condition + * holds, so (a) participation flags are observable and (b) GiftWraps addressed to any of them get + * unwrapped by the owning account's `newNotesPreProcessor`. Without this, wraps for non-active + * accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no subscriber able to + * decrypt them. */ class AlwaysOnNotificationServiceManager( private val context: Context, private val scope: CoroutineScope, private val accountsCache: AccountCacheState, private val localPreferences: LocalPreferences, - private val backgroundSubscriptions: BackgroundAccountSubscriptionRegistry, + private val subscriptions: AccountSubscriptionRegistry, + /** True while any activity is STARTED — see [com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker]. */ + private val isForeground: StateFlow, private val activePubKeyProvider: () -> HexKey?, ) { companion object { @@ -100,59 +116,75 @@ class AlwaysOnNotificationServiceManager( wasEnabled = false watchJob = scope.launch { - localPreferences.notificationServiceEnabledFlow().collectLatest { masterEnabled -> - if (!masterEnabled) { - // Global airplane mode: suppress every layer regardless of - // per-account participation, and stop keeping accounts loaded. - if (wasEnabled) { - disableServiceLayers() - wasEnabled = false - } - stopMultiAccountPreload() - return@collectLatest - } - - // Master on: keep every writable account loaded so its participation - // flag is observable and its gift wraps can decrypt, then run the - // service only while at least one account is participating. An account - // participates when its always-on setting OR its NIP-46 signer toggle is - // on — the background signer needs the same foreground service alive. - startMultiAccountPreload() - accountsCache.accounts - .flatMapLatest { accounts -> - val flags = - accounts.values.map { account -> - account.settings.alwaysOnNotificationService - .combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> - if (alwaysOn || signer) account else null - } - } - if (flags.isEmpty()) { - flowOf(emptyList()) - } else { - combine(flags) { values -> values.filterNotNull() } - } - }.distinctUntilChanged() - .collectLatest { participating -> - // Give every participating account its own account-level - // subscriptions (notifications, DMs, gift wraps, wallet). - // Until this existed, only the account on screen pulled - // anything and the rest waited on a push that may never - // come. - backgroundSubscriptions.sync(participating) - - if (participating.isNotEmpty()) { - wasEnabled = true - enableServiceLayers() - } else if (wasEnabled) { + localPreferences + .notificationServiceEnabledFlow() + .combine(isForeground) { masterEnabled, foreground -> masterEnabled to foreground } + .collectLatest { (masterEnabled, foreground) -> + if (!masterEnabled && !foreground) { + // Nothing wants the accounts: the master is off and no screen is up. + // Suppress every layer and stop keeping accounts loaded. + if (wasEnabled) { disableServiceLayers() wasEnabled = false } + stopMultiAccountPreload() + return@collectLatest } - } + + // Keep every writable account loaded — in the foreground so they can all + // pull, and with the master on so participation flags are observable and + // gift wraps can decrypt. + startMultiAccountPreload() + + accountsAndParticipants() + .distinctUntilChanged() + .collectLatest { (all, participating) -> + // The rule the "keep this account active in the background" setting + // actually describes: while a screen is up, EVERY loaded account + // pulls its own notifications, DMs and gift wraps, because the user + // can switch to any of them and expects them current. The setting + // only decides which ones keep doing it once the app is away. + subscriptions.sync(if (foreground) all else participating) + + // The service layers are a background concern, so they stay tied to + // the master switch and to somebody having opted in. A foreground-only + // account must not start a foreground service that outlives the screen. + if (masterEnabled && participating.isNotEmpty()) { + wasEnabled = true + enableServiceLayers() + } else if (wasEnabled) { + disableServiceLayers() + wasEnabled = false + } + } + } } } + /** + * Every loaded account paired with the subset that opted into running in the background. + * + * Both come from one flow because they change together and the two decisions below — who + * subscribes, and whether the service runs — must never be made from different snapshots. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun accountsAndParticipants(): Flow, List>> = + accountsCache.accounts.flatMapLatest { accounts -> + val all = accounts.values.toList() + val flags = + all.map { account -> + account.settings.alwaysOnNotificationService + .combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> + if (alwaysOn || signer) account else null + } + } + if (flags.isEmpty()) { + flowOf(all to emptyList()) + } else { + combine(flags) { values -> all to values.filterNotNull() } + } + } + fun stop() { watchJob?.cancel() watchJob = null @@ -229,7 +261,7 @@ class AlwaysOnNotificationServiceManager( private fun stopMultiAccountPreload() { preloadJob?.cancel() preloadJob = null - backgroundSubscriptions.clear() + subscriptions.clear() // remove this because we don't know which other accounts might be getting used. // val active = activePubKeyProvider() // if (active != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index e26fdede41..60c502975a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -133,7 +133,7 @@ class AccountFilterAssembler( /** * The accounts whose always-on subscriptions are mounted right now, from * either mount path: a screen's [AccountFilterAssemblerSubscription] or the - * headless [BackgroundAccountSubscriptionRegistry]. + * headless [AccountSubscriptionRegistry]. * * This is the answer to "does this account currently pull from relays?", so * account-scoped loaders that live outside this assembler — the Cashu wallet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/BackgroundAccountSubscriptionRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountSubscriptionRegistry.kt similarity index 62% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/BackgroundAccountSubscriptionRegistry.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountSubscriptionRegistry.kt index dad23b3e85..96aabf0ef3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/BackgroundAccountSubscriptionRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountSubscriptionRegistry.kt @@ -25,51 +25,50 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log /** - * Mounts the always-on account loaders — notifications, DMs, gift wraps, drafts, - * metadata — for accounts that have no screen. + * Mounts the account-level loaders — notifications, DMs, gift wraps, drafts, + * metadata — for accounts that have no screen of their own. * * Every other mount of [AccountFilterAssembler] comes from a composable holding an * `AccountViewModel`, which means it only ever covers the account the user is - * looking at. Accounts the user asked to "keep active in the background" had no - * such mount: they were merely loaded into memory so pushed gift wraps could be - * decrypted by their owner, and everything else about them depended on a push - * message arriving. On a device with no push (no Play Services, no UnifiedPush - * distributor, Pokey not installed) those accounts pulled nothing at all. + * looking at. Every other logged-in account was merely resident in memory so pushed + * gift wraps could be decrypted by their owner; everything else about them depended + * on a push message arriving. On a device with no push (no Play Services, no + * UnifiedPush distributor, Pokey not installed) they pulled nothing at all. * - * This registry is the pull side of that promise. It holds one - * [AccountQueryState] per participating account and drives - * [AccountFilterAssembler.subscribe] / [AccountFilterAssembler.unsubscribe] - * directly — those are plain functions, not composables, so no UI has to exist. + * This registry is the pull side. It holds one [AccountQueryState] per account it is + * given and drives [AccountFilterAssembler.subscribe] / + * [AccountFilterAssembler.unsubscribe] directly — those are plain functions, not + * composables, so no UI has to exist. * * The keys carry no feed states (see [AccountQueryState.feedContentStates]) and no - * `otherAccounts` — populating the account switcher's avatars is a screen's job, - * and these accounts have no screen. + * `otherAccounts` — populating the account switcher's avatars is a screen's job, and + * these accounts have no screen. * - * Ownership of the participating set lives in + * It deliberately decides nothing about *which* accounts those are: it mounts exactly + * the set it is handed, so the foreground/background rule lives in one place, in * [com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager], - * which already watches the two flags that define it, and calls [sync] on every - * change. + * which already watches the switches that define it and calls [sync] on every change. */ -class BackgroundAccountSubscriptionRegistry( +class AccountSubscriptionRegistry( private val assembler: AccountFilterAssembler, ) { companion object { - private const val TAG = "BackgroundAccountSubs" + private const val TAG = "AccountSubscriptions" } private val mounted = mutableMapOf() /** - * Makes the mounted set match [participants] exactly: subscribes accounts that - * just opted in, unsubscribes accounts that opted out or were unloaded, and - * leaves untouched the ones already mounted. + * Makes the mounted set match [accounts] exactly: subscribes the ones that just + * joined it, unsubscribes the ones that left or were unloaded, and leaves the + * rest untouched. * - * Idempotent, so callers can hand it the same set on every emission of the - * flags flow without churning subscriptions. + * Idempotent, so callers can hand it the same set on every emission of the flows + * behind it without churning subscriptions. */ @Synchronized - fun sync(participants: Collection) { - val wanted = participants.associateBy { it.userProfile().pubkeyHex } + fun sync(accounts: Collection) { + val wanted = accounts.associateBy { it.userProfile().pubkeyHex } // Unmount accounts that dropped out, and accounts whose Account object was // replaced (re-login rebuilds it) — the stale instance holds the old @@ -91,11 +90,11 @@ class BackgroundAccountSubscriptionRegistry( } if (added > 0 || stale.isNotEmpty()) { - Log.d(TAG) { "Background account subscriptions: ${mounted.size} mounted (+$added, -${stale.size})" } + Log.d(TAG) { "Account subscriptions: ${mounted.size} mounted (+$added, -${stale.size})" } } } - /** Unmounts everything. Used when the master switch goes off, and on logout. */ + /** Unmounts everything. Used when the app goes away with the master off, and on logout. */ @Synchronized fun clear() = sync(emptyList()) } From d93f4db451e84079b6151c083130d4a634cf688a Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 31 Jul 2026 15:55:28 +0200 Subject: [PATCH 028/227] docs(location): record the clean-install smoke run, closing R1 --- .../2026-07-29-location-foreground-gate.md | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/amethyst/plans/2026-07-29-location-foreground-gate.md b/amethyst/plans/2026-07-29-location-foreground-gate.md index 5c844bc117..20272b5e3f 100644 --- a/amethyst/plans/2026-07-29-location-foreground-gate.md +++ b/amethyst/plans/2026-07-29-location-foreground-gate.md @@ -739,11 +739,46 @@ Caveat: the benchmark client's round-the-clock always-on service makes its battery figures non-comparable to a stock install. The relay and `net.other` figures do match the release client's original report closely. -Not verified by this run: **R1** (no `Loading` flash on return to foreground) is -a visual property and needs an observer at the screen; the unit test -`doesNotReemitLoadingWhenReturningToForegroundWithACachedFix` covers it and -mutation-fails correctly without the guard. The `location.ms ≤ app.fgms + 5 s × -transitions` invariant needs a day of accumulated ledger data. +### Smoke test on a clean install (2026-07-31, benchmark client) + +Uninstalled and reinstalled from branch HEAD so the account, ledger and +permission grant all started empty — which is what makes the first-grant and +empty-cache paths reachable. UID changed 10805 → 10806, cleanly separating the +new data. + +- **R1 — no `Loading` flash on return to foreground: PASS.** Observed directly: + granted the permission, set a feed to Around Me, backgrounded for 10 s, + reopened — the geohash was present immediately with no blank feed. This was the + last open acceptance criterion on the branch and the only one no test could + cover. `dumpsys` shows why it works: the fix is delivered 1–6 ms after each + re-registration. +- **Precise profile live and distinct: PASS.** Geohash screens produce + `@+10s0ms BALANCED, minUpdateDistance=100.0`, and the aggregate's `min/max + interval` moved from `60s/60s` to `10s/60s`. Both profiles are real in + production, not just in the unit tests. +- **Refcount under concurrent flows: PASS.** The coarse registration `766C58A4` + came up at 15:49:10 and survived **four complete precise-flow cycles** + untouched (15:49:41–46, 15:50:47–52, 15:52:07–15:53:05, 15:53:17–22), with + both live simultaneously during the first. Opening and closing geohash chats + never closes the "Around Me" registration — `RefCountedSession` doing on a real + device what `LocationLedgerCompositionTest` asserts. +- **No hang in the location picker.** Every precise registration received a fix + within ~1 ms; none stuck open. That path does + `preciseGeohashStateFlow.first { it is Success }`, which would hang rather than + crash if the gate failed to yield. +- **Aggregate:** total 6m7.553s / active 6m7.401s (152 ms apart) / foreground + 5m49.441s. Foreground trails total by 18.1 s across ~7 background transitions, + ≈ 2.6 s each — the grace period, visible at a scale where it is easy to check. + +**Measurement note:** counting listeners with `grep -c` on the package name is +unreliable — the `service: ProviderRequest[…]` summary line also names the +package when it is the only requester, inflating the count by one. List the rows +and exclude that line instead. A count of zero is unambiguous either way. + +Still not verified: nothing on the gate itself. The `location.ms ≤ app.fgms + +5 s × transitions` invariant was separately confirmed from accumulated ledger +data (see above); the clean install reset that counter, so it will need another +day of use to re-check at scale. ## Follow-ups (not in this change) From 51e388d2a1ccb09051b2983a2c625fbc4c15e3b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 14:54:13 +0000 Subject: [PATCH 029/227] fix: bounds-check ServiceType parsing of NIP-85 service tags ServiceType.parse destructured the result of split(":", limit = 2) into two components, so any value without a colon (e.g. the "client" of a ["client", "nostria"] tag) threw IndexOutOfBoundsException instead of returning null. It also accepted "30382:" as an empty service type. ServiceType.isOfKind had the same class of bug: it read serviceType[kind.length] after startsWith, which is out of bounds when the value equals the kind exactly ("30382" vs "30382"). Parse the separator by index and check the length before indexing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112ysMEzSaezH9mXFteNt13 --- .../list/tags/ServiceType.kt | 9 ++- .../ServiceTypeParserTest.kt | 80 +++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ServiceTypeParserTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/tags/ServiceType.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/tags/ServiceType.kt index dafea6d8cc..a894cb1c75 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/tags/ServiceType.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/tags/ServiceType.kt @@ -33,15 +33,16 @@ data class ServiceType( ) = "$kind:$type" fun parse(serviceType: String): ServiceType? { - val (kindStr, type) = serviceType.split(":", limit = 2) - val kind = kindStr.toIntOrNull() ?: return null - return ServiceType(kind, type) + val divider = serviceType.indexOf(':') + if (divider < 0 || divider == serviceType.length - 1) return null + val kind = serviceType.substring(0, divider).toIntOrNull() ?: return null + return ServiceType(kind, serviceType.substring(divider + 1)) } fun isOfKind( serviceType: String, kind: String, - ) = serviceType.startsWith(kind) && serviceType[kind.length] == ':' + ) = serviceType.length > kind.length && serviceType.startsWith(kind) && serviceType[kind.length] == ':' } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ServiceTypeParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ServiceTypeParserTest.kt new file mode 100644 index 0000000000..ced9e6b6a9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ServiceTypeParserTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.nip85TrustedAssertions + +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ServiceTypeParserTest { + @Test + fun parseValid() { + assertEquals(ServiceType(30382, "rank"), ServiceType.parse("30382:rank")) + } + + @Test + fun parseKeepsColonsInTheType() { + assertEquals(ServiceType(30382, "rank:extra"), ServiceType.parse("30382:rank:extra")) + } + + @Test + fun parseWithoutSeparator() { + // a `["client", "nostria"]` tag ends up here. Must not crash. + assertNull(ServiceType.parse("client")) + } + + @Test + fun parseEmpty() { + assertNull(ServiceType.parse("")) + } + + @Test + fun parseWithoutKind() { + assertNull(ServiceType.parse(":rank")) + } + + @Test + fun parseWithoutType() { + assertNull(ServiceType.parse("30382:")) + } + + @Test + fun parseNonNumericKind() { + assertNull(ServiceType.parse("client:nostria")) + } + + @Test + fun isOfKindMatches() { + assertTrue(ServiceType.isOfKind("30382:rank", "30382")) + } + + @Test + fun isOfKindOnPrefixWithoutSeparator() { + // "30382" starts with "30382" but has no `:` at that position. Must not crash. + assertFalse(ServiceType.isOfKind("30382", "30382")) + assertFalse(ServiceType.isOfKind("", "")) + assertFalse(ServiceType.isOfKind("303820:rank", "30382")) + assertFalse(ServiceType.isOfKind("30383:rank", "30382")) + } +} From 0f4580ab91c72e94bd3328677f05c731fbd8ee9c Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:58:45 +0000 Subject: [PATCH 030/227] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-hi-rIN/strings.xml | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index d6a4b6334f..f8fc98c291 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1319,7 +1319,7 @@ सत्यापित कर्ता मूल्य के लिए मूल्य अपक्रम इस पुटप्रसार का कोई भुगतान सम्भाव्य मूल्य प्राप्तकर्ता नहीं। - एक नोस्टर धनकोष संयोजन जोडें कुंचिकाप्रेषण (कडी) प्राप्तकर्ताओं को भेजने के लिए। + एक नोस्टर धनकोष संयोजन जोडें कुंचिकाप्रेषण (जालबिन्दु) प्राप्तकर्ताओं को भेजने के लिए। साट्स प्रवाह %1$d साट्स प्रति मिनुट स्वचालित रूप से मूल्य भेजता है जब आप सुन रहे हैं। @@ -1395,10 +1395,10 @@ प्राप्तकर्ता हटाएँ नाम (विकल्पात्मक) लैटनिंग॰ पता - कडी (कुंचिकाप्रेषण) + जालबिन्दु (कुंचिकाप्रेषण) लैटनिंग॰ पता name@example.com - कडी ख्याप्यकुंचिका + जालबिन्दु ख्याप्यकुंचिका 02abc… (३३ अष्टक षोडशांक) भार शुल्क @@ -2140,11 +2140,15 @@ लेखीय टीकाएँ पुनःप्रकाशन टिप्पणियाँ तथा प्रत्युत्तर + चित्र + चलचित्र + लघु चलचित्र लेख समूहसम्पाद्य पृष्ठ प्रमुखताएँ मतदान वर्गीकृत विज्ञापन + अनेकत्रावरोहण ध्वनि सन्देश वर्तमान गतिविधियाँ अस्थायी चर्चाएँ @@ -2217,8 +2221,10 @@ स्थल विशेष पत्र स्थल के अनुचर ही देखेंगे। आपके सामान्य अनुचर नहीं देखेंगे। विषयसूचक विशेष पत्र + बाहरी विषयवस्तु जालस्थान पर टिप्पणी बाहरी संसाधन पर टिप्पणी + जालवीक्षक मे खोलें %1$d मिन॰ पठन %1$d अभिलेख @@ -2350,6 +2356,7 @@ समूह बनाएँ नयी प्रणाली + नया मंच निजी प्रणाली प्रणाली सूची में छिपाया गया तथा आमन्त्रण सीमित। निष्क्रिय अर्थात इस पुनःप्रसारक पर कोई भी इसे खोज सकेगा तथा जुड सकेगा। मंच प्रणाली @@ -2392,6 +2399,8 @@ क्या %1$s को मिटा दें। यह इस समूह को तथा इसके इतिहास को हटा देगा सब के लिए। इसे पूर्ववत नहीं किया जा सकता। प्रणाली मिटाएँ क्या %1$s को मिटा दें। यह इस प्रणाली को तथा इसके सन्देशों को हटा देगा सब के लिए। इसे पूर्ववत नहीं किया जा सकता। + प्रणाली संग्रहण + प्रणाली संग्रह अवस्था हटाएँ सूत्र सन्देश टाँकें सन्देश से टाँका हटाएँ @@ -2467,7 +2476,7 @@ पुनःप्रसारक पता वीक्षण यह पुनःप्रसारक अभिगम्य नहीं टोर॰ पर - %1$s से कोई प्रत्युत्तर नहीं टोर॰ द्वारा। इसका जालनिवास सम्भाव्यतः टोर॰ निर्गमन स्थानों को अवरुद्ध कर रहा है। क्या इसके स्थान पर स्पष्टजाल पर संयोजन करें। + %1$s से कोई प्रत्युत्तर नहीं टोर॰ द्वारा। इसका जालनिवास सम्भाव्यतः टोर॰ निर्गमन जालबिन्दुओं को अवरुद्ध कर रहा है। क्या इसके स्थान पर स्पष्टजाल पर संयोजन करें। स्पष्टजाल का उपयोग करें पुनःप्रसारक जिन पर आप हैं लोकप्रिय पुनःप्रसारक @@ -2535,6 +2544,9 @@ को भेजें… नया सन्देश नया उद्दीप्तव्य + नया चित्र + नया लघु चलचित्र + नया चलचित्र नया उद्दीप्तव्य उद्दीप्त लेख क्या था प्रमुख आपके लिए। @@ -3207,7 +3219,7 @@ %1$s ने दृश्यमानता को %2$s कर दिया %1$s ने %2$s पश्चात सन्देश अदृश्यकरण सक्षम किया %1$s ने सन्देश अदृश्यकरण अक्षम किया - %1$s ने इस प्रणाली को संग्रहित किया + %1$s ने इस प्रणाली को संगृहीत किया %1$s ने इस प्रणाली को पुनःस्थापित किया %1$s ने इस प्रणाली को बनाया %1$s ने इस प्रणाली को मिटाया @@ -3297,6 +3309,7 @@ आप इस पुनःप्रसारक के किसी भी प्रणाली के सदस्य प्रतीत नहीं होते अब तक। पहले वीक्षक में कार्यशाला आमन्त्रण स्वीकार करें। तत्पश्चात पुनःप्रयास करें। प्रणालियाँ मंच + संगृहीत कार्य मध्य… सदस्य जोडें इस कार्यशाला में लोगों को जोडें @@ -3560,10 +3573,10 @@ ब्लोस्सम॰ प्रसारसंगणक जोडें सब मिटाएँ क्या आप निश्चित रूप से सभी पाण्डुलिपियाँ मिटाना चाहते हैं। - उग्रप्रवाह अभिलेख + अनेकत्रावरोहण अभिलेख अवरोहण अभिलेख खोलने में असफल - कोई उग्रप्रवाह क्रमक स्थापित नहीं अभिलेख खोलने तथा अवरोहण करने के लिए। + कोई अनेकत्रावरोहण क्रमक स्थापित नहीं अभिलेख खोलने तथा अवरोहण करने के लिए। अभिलेखविभेदक युक्त जालनिर्देशक बनाने के लिए पर्याप्त जानकारी नहीं है घटना में मेरे सूचियाँ सूचनावली छानने के लिए सूची चुनें @@ -3936,8 +3949,8 @@ उपयोगकर्ता स्थिति टीकाएँ सम्पादन - उग्रप्रवाह - उग्रप्रवाह टिप्पणियाँ + अनेकत्रावरोहण + अनेकत्रावरोहण टिप्पणियाँ विश्वसनीय पुनःप्रसारक विश्वसनीय प्रदाता चलचित्र (अनु) From 24decefd4bc240f3ea14a55f3d2c61228722945f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 11:09:20 -0400 Subject: [PATCH 031/227] feat(relays): merge the notification tail across accounts into one REQ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every account-level loader is a PerUserEoseManager, which opens one subscription per user. With four accounts open that multiplies, and shared relays run out of room: nos.lol (strfry, `max_subscriptions: 20`) answered `ERROR: too many concurrent REQs` — 0 times before this branch, 7 with three accounts subscribed, 13 with four. The refusal arrives as a NOTICE, which carries no subscription id and never reaches RelayReqRefusals (wired only to CLOSED), and "too many concurrent REQs" matches none of its markers. So the relay drops those REQs while we still believe they are live: they never EOSE and never deliver. Notifications are `#p`-scoped, so a relay serving several accounts can be asked about all of them in one filter naming every pubkey — same query, wider tag. The manager moves to SingleSubEoseManager: one REQ per relay instead of one per (account, relay), with the `since` floor taken per relay as the OLDEST of the participating accounts' floors so widening for one can never cut another short. Gift wraps deliberately do NOT merge. They are unsolicited and opaque to the relay, so it cannot rate-limit them per recipient; a merged query would let one spammed account eat the shared `limit` and starve every other account's DMs. Each account keeps its own budget there. A merged filter serves several accounts, so accountPubKey becomes accountPubKeys. The subscriptions screen shows such a filter under each account it serves — "why is this relay busy for me" has to be answerable per account — which makes the per-account counts a breakdown of a shared filter rather than a partition of the total. attributedFilters is that sum, and is what a card's share is drawn against now; dividing by the wire total would read as 100% for each of two accounts sharing one filter. Measured, and it is only part of the answer: nos.lol carries 24-26 subscriptions against its cap of 20, and this removes 3 of them. The rest are the other per-account managers — account metadata alone is 7 filters in a subscription per account, and gift wraps another. Merging metadata (it is `authors`-keyed and merges the same way) is the next lever; this commit does not get us under the cap on its own. Co-Authored-By: Claude Opus 5 (1M context) --- .../AccountFollowsLoaderSubAssembler.kt | 2 +- ...NotificationsEoseFromInboxRelaysManager.kt | 208 ++++++++++-------- .../FilterNotificationsToPubkey.kt | 42 ++-- .../FilterRepliesAndReactionsToAddresses.kt | 8 +- .../FilterRepliesAndReactionsToNotes.kt | 6 +- .../loaders/UserOutboxFinderSubAssembler.kt | 4 +- .../user/watchers/FilterReportsToKey.kt | 2 +- .../user/watchers/FilterUserMetadataForKey.kt | 4 +- .../ActiveSubscriptionsScreen.kt | 10 +- .../ActiveSubscriptionsViewModel.kt | 55 +++-- .../AggregateSubscriptionsTest.kt | 45 +++- .../actions/ConcordSubscriptionPlanner.kt | 6 +- .../assemblers/CashuWalletFilterAssembler.kt | 4 +- .../assemblers/ContactCardFilters.kt | 4 +- .../assemblers/MetadataFilterAssembler.kt | 2 +- .../subscriptions/ExplainedFilter.kt | 29 ++- .../subscriptions/ExplainedFilterTest.kt | 4 +- 17 files changed, 270 insertions(+), 165 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 6c1f63faed..08ec932e0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -181,7 +181,7 @@ class AccountFollowsLoaderSubAssembler( purpose = SubPurpose.RELAY_LISTS, kinds = listOf(AdvertisedRelayListEvent.KIND), authors = users.sorted(), - accountPubKey = soleAccountPubKey, + accountPubKeys = listOfNotNull(soleAccountPubKey), ), ) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index be972b722f..6d1c1fdb74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -34,107 +35,142 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch +/** + * The live notification tail, for **every** logged-in account, in one subscription. + * + * Notifications are `#p`-scoped, so a relay that serves several of the user's accounts can be asked + * about all of them in one filter naming every pubkey — the query is identical in shape, only wider. + * That is what keeps this within a relay's `max_subscriptions`: one REQ per relay instead of one per + * (account, relay). With four accounts open, the per-account form pushed strfry relays past their + * 20-subscription cap and they answered `ERROR: too many concurrent REQs` — a NOTICE, carrying no + * subscription id, so the client could not even tell which REQ had been dropped. Those filters stayed + * "live" in our books and silently never delivered. + * + * Gift wraps deliberately do **not** merge this way. They are unsolicited and their content is opaque + * to the relay, so it cannot rate-limit them per recipient; a merged query would let one spammed + * account consume the shared `limit` and starve every other account's DMs. Each account keeps its own + * budget there — see [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager]. + */ class AccountNotificationsEoseFromInboxRelaysManager( client: INostrClient, allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun user(key: AccountQueryState) = key.account.userProfile() +) : SingleSubEoseManager(client, allKeys) { + override fun distinct(key: AccountQueryState) = key.account.userProfile() /** - * Downloads most notifications from the user's own inbox relays. - * But also connects to all the follows relays to check for new notifications that are not in the user's - * own inbox. + * One filter set per inbox relay, naming every account that reads from it. + * + * The `since` floor is per relay rather than per account, because the merged filter is per relay: + * the **oldest** of the participating accounts' floors wins, so widening the query for one account + * can never cut another one short. */ override fun updateFilter( - key: AccountQueryState, + keys: List, since: SincePerRelayMap?, ): List { - // A cold-start floor, NOT paging — backward paging lives in - // [AccountNotificationsHistoryEoseManager]. Read only when a relay has no EOSE yet; once it - // does, the EOSE time wins and this is never consulted. - // - // `since` means *newer than*, so this floors the query at the depth the feed already reaches - // rather than asking all-time again. It stays null until the feed holds a full page — and a - // background account has no feed at all (no screen ever mounted one), which is why the key - // carries none and this reads null there. - val pagingBoundary = key.feedContentStates?.notifications?.lastNoteCreatedAtIfFilled() - - val inbox = - key.account.notificationRelays.flow.value.flatMap { - // No `since` floor on the first fetch. These filters are scoped by `#p` to my own - // key and carry a relay-side `limit`, so an all-time query costs one index scan and - // returns at most `limit` events, newest first — exactly what Home does (it passes - // `since ?: boundary`, i.e. null on a cold start). - // - // This used to fall back to `oneWeekAgo()`, which silently emptied the tab for - // anyone whose last mention was older than a week: EOSE `since` is in-memory only, - // so EVERY cold start re-pinned the window to 7 days, and the paging boundary above - // could never rescue it — it only arms once the feed holds a full page, and the feed - // could not fill because the query only ever asked for a week. A fresh install of an - // established account hit the same deadlock. - val notificationSince = since?.get(it)?.time ?: pagingBoundary - - filterSummaryNotificationsToPubkey( - relay = it, - pubkey = user(key).pubkeyHex, - since = notificationSince, - ) + - filterNotificationsToPubkey( - relay = it, - pubkey = user(key).pubkeyHex, - since = notificationSince, - ) + val accountsPerRelay = mutableMapOf>() + keys.forEach { key -> + key.account.notificationRelays.flow.value.forEach { relay -> + accountsPerRelay.getOrPut(relay) { mutableListOf() }.add(key) } + } - // NIP-29 group activity (reactions/replies to my messages) is deliberately NOT requested here. - // It lives on the group's host relay and used to be one `#h` filter per relay carrying every - // joined group id — but this subscription also carries the inbox filters above, which have no - // `#h` at all, and `block/buzz` downgrades any subscription with a channel-less (or multi- - // channel) filter to "global", a class that by design never receives channel-scoped events. So - // those filters answered the stored query at EOSE and then went deaf until the next launch. - // - // Group and Buzz-DM activity now rides the per-channel subscriptions that are already scoped to - // exactly one channel — RelayGroupJoinedChatTailSubAssembler and BuzzDmJoinedChatTailSubAssembler, - // both mounted app-wide from LoggedInPage, so coverage is unchanged and delivery is live. - return inbox + return accountsPerRelay.flatMap { (relay, accounts) -> + val pubkeys = accounts.map { it.account.userProfile().pubkeyHex } + + // A cold-start floor, NOT paging — backward paging lives in + // [AccountNotificationsHistoryEoseManager]. Read only when a relay has no EOSE yet; once it + // does, the EOSE time wins and this is never consulted. + // + // `since` means *newer than*, so this floors the query at the depth the feed already reaches + // rather than asking all-time again. It stays null until the feed holds a full page — and a + // background account has no feed at all (no screen ever mounted one), which is why the key + // carries none and this reads null there. Null for ANY account on the relay means no floor + // at all, since a floor derived from one account's feed would truncate the others'. + val floors = accounts.map { it.feedContentStates?.notifications?.lastNoteCreatedAtIfFilled() } + val pagingBoundary = if (floors.any { it == null }) null else floors.filterNotNull().min() + + // No `since` floor on the first fetch. These filters are scoped by `#p` to my own + // keys and carry a relay-side `limit`, so an all-time query costs one index scan and + // returns at most `limit` events, newest first — exactly what Home does (it passes + // `since ?: boundary`, i.e. null on a cold start). + // + // This used to fall back to `oneWeekAgo()`, which silently emptied the tab for + // anyone whose last mention was older than a week: EOSE `since` is in-memory only, + // so EVERY cold start re-pinned the window to 7 days, and the paging boundary above + // could never rescue it — it only arms once the feed holds a full page, and the feed + // could not fill because the query only ever asked for a week. A fresh install of an + // established account hit the same deadlock. + val notificationSince = since?.get(relay)?.time ?: pagingBoundary + + // NIP-29 group activity (reactions/replies to my messages) is deliberately NOT requested + // here. It lives on the group's host relay and used to be one `#h` filter per relay carrying + // every joined group id — but this subscription also carries the inbox filters below, which + // have no `#h` at all, and `block/buzz` downgrades any subscription with a channel-less (or + // multi-channel) filter to "global", a class that by design never receives channel-scoped + // events. So those filters answered the stored query at EOSE and then went deaf until the + // next launch. + // + // Group and Buzz-DM activity now rides the per-channel subscriptions that are already scoped + // to exactly one channel — RelayGroupJoinedChatTailSubAssembler and + // BuzzDmJoinedChatTailSubAssembler, both mounted app-wide from LoggedInPage, so coverage is + // unchanged and delivery is live. + filterSummaryNotificationsToPubkeys(relay = relay, pubkeys = pubkeys, since = notificationSince) + + filterNotificationsToPubkeys(relay = relay, pubkeys = pubkeys, since = notificationSince) + } } - val userJobMap = mutableMapOf>() + /** + * Per-account watchers, rebuilt as accounts come and go. + * + * There is only one subscription now, so these cannot hang off a per-key `newSub`. They are keyed + * by user and reconciled here: an account that leaves has its jobs cancelled, and one that arrives + * gets its own. Re-entrancy is safe — the watchers call `invalidateFilters()`, which lands back + * here and finds every account already watched. + */ + private val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) - override fun newSub(key: AccountQueryState): Subscription { - val user = user(key) - userJobMap[user]?.forEach { it.cancel() } - userJobMap[user] = - listOf( - key.account.scope.launch(Dispatchers.IO) { - key.account.notificationRelays.flow.sample(1000).collectLatest { - invalidateFilters() - } - }, - // No group/Buzz-DM watchers here any more: those filters moved to the per-channel - // subscriptions, which mount and unmount with the channel itself. - ) + - // Only a screen can fill a feed, so there is nothing to watch for a - // background account. - listOfNotNull( - key.feedContentStates?.let { feeds -> - key.account.scope.launch(Dispatchers.IO) { - feeds.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { - invalidateFilters() - } - } - }, - ) + override fun updateSubscriptions(keys: Set) { + val wanted = keys.associateBy { it.account.userProfile() } - return super.newSub(key) + (userJobMap.keys - wanted.keys).toList().forEach { user -> + userJobMap.remove(user)?.forEach { it.cancel() } + } + + wanted.forEach { (user, key) -> + if (user !in userJobMap) { + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.IO) { + key.account.notificationRelays.flow.sample(1000).collectLatest { + invalidateFilters() + } + }, + ) + + // Only a screen can fill a feed, so there is nothing to watch for a + // background account. + listOfNotNull( + key.feedContentStates?.let { feeds -> + key.account.scope.launch(Dispatchers.IO) { + feeds.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + } + }, + ) + } + } + + super.updateSubscriptions(keys) } - override fun endSub( - key: User, - subId: String, - ) { - super.endSub(key, subId) - userJobMap[key]?.forEach { it.cancel() } + override fun destroy() { + userJobMap.values.forEach { jobs -> jobs.forEach { it.cancel() } } + userJobMap.clear() + super.destroy() } + + /** Unused here, but kept so callers reading a key's owner do not reach into the account. */ + fun pubkeyOf(key: AccountQueryState): HexKey = key.account.userProfile().pubkeyHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 0b211a0634..899e24ec45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -157,7 +157,7 @@ fun filterNotificationsHistoryToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = AllNotificationKinds, tags = mapOf("p" to listOf(pubkey)), limit = limit, @@ -186,7 +186,7 @@ fun filterGroupNotificationsHistoryToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = GroupNotificationKinds, tags = mapOf("p" to listOf(pubkey), "h" to groupIds), limit = limit, @@ -196,12 +196,12 @@ fun filterGroupNotificationsHistoryToPubkey( ) } -fun filterSummaryNotificationsToPubkey( +fun filterSummaryNotificationsToPubkeys( relay: NormalizedRelayUrl, - pubkey: HexKey?, + pubkeys: List, since: Long?, ): List { - if (pubkey.isNullOrEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( @@ -209,9 +209,9 @@ fun filterSummaryNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = pubkeys, kinds = SummaryKinds, - tags = mapOf("p" to listOf(pubkey)), + tags = mapOf("p" to pubkeys), limit = 2000, since = since, ), @@ -219,12 +219,12 @@ fun filterSummaryNotificationsToPubkey( ) } -fun filterNotificationsToPubkey( +fun filterNotificationsToPubkeys( relay: NormalizedRelayUrl, - pubkey: HexKey?, + pubkeys: List, since: Long?, ): List { - if (pubkey.isNullOrEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( @@ -232,9 +232,9 @@ fun filterNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = pubkeys, kinds = NotificationsPerKeyKinds, - tags = mapOf("p" to listOf(pubkey)), + tags = mapOf("p" to pubkeys), limit = 500, since = since, ), @@ -244,9 +244,9 @@ fun filterNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = pubkeys, kinds = NotificationsPerKeyKinds2, - tags = mapOf("p" to listOf(pubkey)), + tags = mapOf("p" to pubkeys), limit = 200, since = since, ), @@ -256,9 +256,9 @@ fun filterNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = pubkeys, kinds = NotificationsPerKeyKinds3, - tags = mapOf("p" to listOf(pubkey)), + tags = mapOf("p" to pubkeys), limit = 10, since = since, ), @@ -286,7 +286,7 @@ fun filterGroupNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = GroupNotificationKinds, tags = mapOf("p" to listOf(pubkey), "h" to groupIds), limit = 200, @@ -309,7 +309,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = SummaryKinds, tags = mapOf("p" to listOf(pubkey)), limit = 20, @@ -321,7 +321,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = NotificationsPerKeyKinds, tags = mapOf("p" to listOf(pubkey)), limit = 20, @@ -333,7 +333,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), limit = 10, @@ -345,7 +345,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = NotificationsPerKeyKinds3, tags = mapOf("p" to listOf(pubkey)), limit = 2, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index 87f42c8d1c..cce973f915 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -96,7 +96,7 @@ fun filterRepliesAndReactionsToAddresses( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = RepliesAndReactionsToAddressesKinds1, tags = mapOf("a" to sortedList), since = since, @@ -109,7 +109,7 @@ fun filterRepliesAndReactionsToAddresses( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = PostsAndChatMessagesToAddresses, tags = mapOf("a" to sortedList), since = since, @@ -122,7 +122,7 @@ fun filterRepliesAndReactionsToAddresses( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = DeletionKindList, tags = mapOf("a" to sortedList), since = since, @@ -135,7 +135,7 @@ fun filterRepliesAndReactionsToAddresses( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = TextNoteKindList, tags = mapOf("q" to sortedList), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 3763929013..64510793f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -103,7 +103,7 @@ fun filterRepliesAndReactionsToNotes( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = RepliesAndReactionsKinds, tags = mapOf("e" to sortedList), since = since, @@ -116,7 +116,7 @@ fun filterRepliesAndReactionsToNotes( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = RepliesAndReactionsKinds2, tags = mapOf("e" to sortedList), since = since, @@ -128,7 +128,7 @@ fun filterRepliesAndReactionsToNotes( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = listOf(TextNoteEvent.KIND, CommentEvent.KIND), tags = mapOf("q" to sortedList), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt index 1e535e8e97..7743fb7c7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt @@ -135,7 +135,7 @@ class UserOutboxFinderSubAssembler( kinds = relayListKinds, authors = sortedUsers, purpose = SubPurpose.RELAY_LISTS, - accountPubKey = soleAccountPubKey, + accountPubKeys = listOfNotNull(soleAccountPubKey), ), ) } else { @@ -172,7 +172,7 @@ class UserOutboxFinderSubAssembler( authors = sortedAbandoned, purpose = SubPurpose.RELAY_LISTS, purposeDetail = "outbox discovery for users whose relay list we lost", - accountPubKey = soleAccountPubKey, + accountPubKeys = listOfNotNull(soleAccountPubKey), ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt index ba3022af91..a369d824e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt @@ -44,7 +44,7 @@ fun filterReportsToKeysFromTrusted( purpose = SubPurpose.MODERATION, kinds = ReportKindList, authors = trustedAccounts, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), tags = mapOf("p" to targets.sorted()), since = since, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index 488b4b1297..e52167f763 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -115,7 +115,7 @@ fun filterUserMetadataForKey( filter = ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = UserMetadataForKeyKinds, authors = firstTimers.sorted(), ), @@ -129,7 +129,7 @@ fun filterUserMetadataForKey( filter = ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = UserMetadataForKeyKinds, authors = updates.sorted(), since = minimumTime, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt index 52dc8b0c68..722c6b9389 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt @@ -128,7 +128,7 @@ fun ActiveSubscriptionsScreen( item(key = "acct-${account.accountPubKey ?: "none"}") { AccountHeader(account) } items(account.purposes, key = { "${account.accountPubKey}-${it.purpose.name}" }) { purpose -> - PurposeCard(purpose, state.totalFilters, accountViewModel, nav) + PurposeCard(purpose, state.attributedFilters, accountViewModel, nav) } } } @@ -194,7 +194,11 @@ private fun AccountHeader(account: SubscriptionAccountRow) { @Composable private fun PurposeCard( row: SubscriptionPurposeRow, - totalFilters: Int, + /** + * The sum of the per-account counts, not the wire total: a merged filter serving four accounts + * contributes to four cards, so dividing by the wire total would overstate every one of them. + */ + attributedFilters: Int, accountViewModel: AccountViewModel, nav: INav, ) { @@ -233,7 +237,7 @@ private fun PurposeCard( // Share of ALL subscriptions, not of the biggest one: measuring against the biggest // makes one bar permanently full and says nothing about how much of the app's traffic a // purpose actually accounts for. - val share = if (totalFilters > 0) row.filterCount / totalFilters.toFloat() else 0f + val share = if (attributedFilters > 0) row.filterCount / attributedFilters.toFloat() else 0f ShareBar(fraction = share, color = accent) Spacer(Modifier.height(8.dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt index 3cd5f1fe55..2a34e09b2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt @@ -127,6 +127,15 @@ data class ActiveSubscriptionsState( val totalRelays: Int = 0, /** Filters in flight that carry no purpose — assemblers not yet migrated. Honesty, not a bug. */ val untaggedFilters: Int = 0, + /** + * The sum of the per-account counts, which is what a card's share must be drawn against. + * + * It exceeds [totalFilters] when a filter serves several accounts at once — one merged + * notifications REQ naming four pubkeys is one filter on the wire but four accounts' worth of + * explanation. Dividing a per-account count by [totalFilters] would compare the two units and + * overstate every card, which is the mistake the per-entity rows already taught once. + */ + val attributedFilters: Int = 0, ) { /** The largest purpose, so every card can draw its share against a common scale. */ val busiestPurposeFilters: Int = accounts.flatMap { it.purposes }.maxOfOrNull { it.filterCount } ?: 0 @@ -192,27 +201,34 @@ fun aggregateSubscriptions(filtersByRelay: Map> // is what keeps them from collapsing into one nameless row per purpose. val scopeKey = explained.scope?.let { it::class.simpleName } - val tally = - byAccount - .getOrPut(explained.accountPubKey) { mutableMapOf() } - .getOrPut(explained.purpose) { PurposeTally() } + // A merged filter serves several accounts at once — notifications are `#p`-scoped, so + // every account reading a relay is asked for in one filter naming all of them. It shows + // under each of those accounts, because "why is this relay busy for me" has to be + // answerable per account. That makes the per-account counts a breakdown of a shared + // filter rather than a partition of the total, which is what [attributedFilters] exists + // to keep straight. + val accounts: List = explained.accountPubKeys?.takeIf { it.isNotEmpty() } ?: listOf(null) + accounts.forEach { account -> + val tally = + byAccount + .getOrPut(account) { mutableMapOf() } + .getOrPut(explained.purpose) { PurposeTally() } - // The filter counts ONCE, here — before it is fanned out below. This is the - // number that shares a unit with `total`, so a card's share of the whole is a - // comparison of like with like. - tally.filters++ - tally.relays.add(relay) + // Once per account this filter serves, never once per entity it names. + tally.filters++ + tally.relays.add(relay) - // A batched filter serves several entities at once — relay-group state is one #d - // filter per host relay carrying every joined group on it — so it contributes a - // row to each of them rather than collapsing to "All". These rows are a - // breakdown, never a total: see [SubscriptionEntityRow.namedInFilters]. - val entities: List = explained.entityIds?.takeIf { it.isNotEmpty() } ?: listOf(null) - entities.forEach { entityId -> - val key = EntityKey(entityId, scopeKey) - tally.entities.getOrPut(key) { mutableListOf() }.add(relay) - detailOf[explained.purpose to key] = explained.purposeDetail - explained.scope?.let { scopeOf.getOrPut(explained.purpose to key) { it } } + // A batched filter serves several entities at once — relay-group state is one #d + // filter per host relay carrying every joined group on it — so it contributes a + // row to each of them rather than collapsing to "All". These rows are a + // breakdown, never a total: see [SubscriptionEntityRow.namedInFilters]. + val entities: List = explained.entityIds?.takeIf { it.isNotEmpty() } ?: listOf(null) + entities.forEach { entityId -> + val key = EntityKey(entityId, scopeKey) + tally.entities.getOrPut(key) { mutableListOf() }.add(relay) + detailOf[explained.purpose to key] = explained.purposeDetail + explained.scope?.let { scopeOf.getOrPut(explained.purpose to key) { it } } + } } } } @@ -258,5 +274,6 @@ fun aggregateSubscriptions(filtersByRelay: Map> totalFilters = total, totalRelays = allRelays.size, untaggedFilters = untagged, + attributedFilters = accounts.sumOf { it.filterCount }, ) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt index 88f32a424c..87b3beca2f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt @@ -50,7 +50,7 @@ class AggregateSubscriptionsTest { entityIds = chats, kinds = listOf(kind), tags = mapOf("e" to chats), - accountPubKey = account, + accountPubKeys = listOfNotNull(account), ) /** Six relays, each carrying four batched filters that each name all six chats. */ @@ -99,6 +99,49 @@ class AggregateSubscriptionsTest { ) } + /** The merged notifications filter: one REQ per relay naming every account that reads it. */ + private fun mergedNotifications(vararg accounts: String) = + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, + accountPubKeys = accounts.toList(), + kinds = listOf(1), + tags = mapOf("p" to accounts.toList()), + ) + + @Test + fun `a merged filter explains itself to every account it serves`() { + val a = "aa".repeat(32) + val b = "bb".repeat(32) + val c = "cc".repeat(32) + val state = aggregateSubscriptions(mapOf(relay(1) to listOf(mergedNotifications(a, b, c)))) + + // One filter on the wire... + assertEquals(1, state.totalFilters) + // ...but all three accounts can see why their relay is busy. + assertEquals(3, state.accounts.size) + state.accounts.forEach { assertEquals(1, it.filterCount) } + } + + @Test + fun `a card's share is drawn against the attributed total, not the wire total`() { + val a = "aa".repeat(32) + val b = "bb".repeat(32) + val state = aggregateSubscriptions(mapOf(relay(1) to listOf(mergedNotifications(a, b)))) + + // Dividing the per-account count by totalFilters would read as 100% for each of two + // accounts. attributedFilters is the unit those counts actually belong to. + assertEquals(1, state.totalFilters) + assertEquals(2, state.attributedFilters) + assertEquals(state.attributedFilters, state.accounts.sumOf { it.filterCount }) + } + + @Test + fun `unmerged filters keep attributed and wire totals identical`() { + val state = aggregateSubscriptions(sixChatsOnSixRelays()) + + assertEquals(state.totalFilters, state.attributedFilters) + } + @Test fun `untagged filters are reported but never attributed to a purpose`() { val state = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 74ee3ca92b..52b4436475 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -183,7 +183,7 @@ object ConcordSubscriptionPlanner { purpose = SubPurpose.COMMUNITY_CHATS, purposeDetail = "concord community planes", entityIds = listOf(entry.id), - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = authors.toList(), // -1 so the last-read message (created_at == lastRead) is itself returned: @@ -196,7 +196,7 @@ object ConcordSubscriptionPlanner { purpose = SubPurpose.COMMUNITY_CHATS, purposeDetail = "concord community planes", entityIds = listOf(entry.id), - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = authors.toList(), limit = previewLimit, @@ -288,7 +288,7 @@ object ConcordSubscriptionPlanner { purpose = SubPurpose.COMMUNITY_CHATS, purposeDetail = "concord live planes", entityIds = communitiesByRelay[relay]?.sorted(), - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), // Stored plane wraps (1059) plus ephemeral ones (21059) — the latter carry the // live-only typing heartbeats a relay broadcasts but never stores. kinds = listOf(ConcordStreamEnvelope.KIND_WRAP, ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt index 1831b0353e..78fb9a191d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt @@ -138,7 +138,7 @@ private class CashuWalletSubAssembler( MintRecommendationEvent.KIND, ), authors = listOf(pubkey), - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), ) val inboundNutzapsFilter = @@ -146,7 +146,7 @@ private class CashuWalletSubAssembler( purpose = SubPurpose.NUTZAP_INBOX, kinds = listOf(NutzapEvent.KIND), tags = mapOf("p" to listOf(pubkey)), - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), ) // Own NIP-60 events are read from the user's outbox; inbound nutzaps diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt index 3c654bf55b..b7bfc89949 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt @@ -48,7 +48,7 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( filter = ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = ContactCardKindList, authors = trustedAccounts, // kind:30382 addresses the target user in the d-tag @@ -75,7 +75,7 @@ fun filterContactCardsByAuthorInTheRelay( ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, // This variant fetches an account's OWN contact card, so the author is the owner. - accountPubKey = author, + accountPubKeys = listOfNotNull(author), kinds = ContactCardKindList, authors = listOf(author), limit = limit, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt index 5ba35efcdc..12bb81893d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt @@ -85,7 +85,7 @@ class MetadataFilterAssembler( authors = pubkeyList, limit = pubkeyList.size, purpose = SubPurpose.PROFILE_METADATA, - accountPubKey = soleAccountPubKey, + accountPubKeys = listOfNotNull(soleAccountPubKey), ) // Apply since times per relay diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt index 7c46aea5ac..68d943bd36 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt @@ -82,15 +82,20 @@ class ExplainedFilter( */ val entityIds: List? = null, /** - * Which logged-in account asked for this. Several accounts are commonly active at once and they + * Which logged-in accounts asked for this. Several accounts are commonly active at once and they * do not share relay sets, so "why is this relay connected" is only answerable per account — * without it, one account's communities look like another's. * - * A pubkey rather than an account reference: filters outlive the objects that created them and - * are held by the relay pool for the session, so holding an `Account` here would keep a logged-out + * A **list**, because a filter can legitimately serve several accounts at once. Notifications are + * `#p`-scoped, so every account reading the same relay can be asked for in one filter naming all + * of them — which is what keeps a relay under its `max_subscriptions` cap when the app holds four + * accounts open. Filters that serve one account carry a single-element list. + * + * Pubkeys rather than account references: filters outlive the objects that created them and are + * held by the relay pool for the session, so holding an `Account` here would keep a logged-out * account alive. */ - val accountPubKey: HexKey? = null, + val accountPubKeys: List? = null, /** * The top-nav selection that produced this filter — Global, the user's follows, a hashtag, a * geohash, a community. @@ -120,7 +125,7 @@ class ExplainedFilter( until: Long?, limit: Int?, search: String?, - ) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityIds, accountPubKey, scope) + ) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityIds, accountPubKeys, scope) companion object { /** Tags [filter] with a [purpose], preserving every protocol field. */ @@ -129,7 +134,7 @@ class ExplainedFilter( purpose: SubPurpose, detail: String? = null, entityIds: List? = null, - accountPubKey: HexKey? = null, + accountPubKeys: List? = null, scope: IFeedTopNavPerRelayFilter? = null, ) = ExplainedFilter( filter.ids, @@ -144,7 +149,7 @@ class ExplainedFilter( purpose, detail, entityIds, - accountPubKey, + accountPubKeys, scope, ) } @@ -168,9 +173,9 @@ fun Collection.purposeEntities(): Set = val explained = filter as? ExplainedFilter ?: return@flatMapTo emptyList() val ids = explained.entityIds if (ids.isNullOrEmpty()) { - listOf(PurposeEntity(explained.purpose, null, explained.accountPubKey, explained.purposeDetail)) + listOf(PurposeEntity(explained.purpose, null, explained.accountPubKeys?.firstOrNull(), explained.purposeDetail)) } else { - ids.map { PurposeEntity(explained.purpose, it, explained.accountPubKey, explained.purposeDetail) } + ids.map { PurposeEntity(explained.purpose, it, explained.accountPubKeys?.firstOrNull(), explained.purposeDetail) } } } @@ -192,10 +197,10 @@ data class PurposeEntity( fun List.attributedTo(accountPubKey: HexKey): List = map { relayFilter -> val filter = relayFilter.filter - if (filter is ExplainedFilter && filter.accountPubKey == null) { + if (filter is ExplainedFilter && filter.accountPubKeys.isNullOrEmpty()) { RelayBasedFilter( relay = relayFilter.relay, - filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, accountPubKey, filter.scope), + filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, listOf(accountPubKey), filter.scope), ) } else { relayFilter @@ -219,7 +224,7 @@ fun List.scopedTo(feedSettings: IFeedTopNavPerRelayFilterSet): if (filter is ExplainedFilter && scope != null) { RelayBasedFilter( relay = relayFilter.relay, - filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, filter.accountPubKey, scope), + filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, filter.accountPubKeys, scope), ) } else { relayFilter diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt index 61baa6bc71..a983b300e2 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt @@ -58,7 +58,7 @@ class ExplainedFilterTest { purpose = SubPurpose.NOTIFICATIONS, purposeDetail = "inbox relays for the active account", entityIds = listOf("cafe0000000000000000000000000000000000000000000000000000000000ff"), - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), scope = HashtagTopNavPerRelayFilter(setOf("askednostr")), ) @@ -115,7 +115,7 @@ class ExplainedFilterTest { assertEquals("inbox relays for the active account", (advanced as ExplainedFilter).purposeDetail) // entityIds/accountPubKey/scope ride the same path and would vanish just as silently assertEquals(listOf("cafe0000000000000000000000000000000000000000000000000000000000ff"), advanced.entityIds) - assertEquals(pubkey, advanced.accountPubKey) + assertEquals(listOf(pubkey), advanced.accountPubKeys) assertEquals(setOf("askednostr"), (advanced.scope as? HashtagTopNavPerRelayFilter)?.hashtags) assertEquals(1_785_379_272L, advanced.since) // and the protocol fields came along untouched From fe2d7ef045c4db987198501935fb162e4824edd8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 11:56:03 -0400 Subject: [PATCH 032/227] feat(relays): merge account metadata across accounts into one REQ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Account metadata was the largest single contributor to blowing a relay's subscription cap: seven filters in a subscription, repeated once per logged-in account. Every one of them is `authors`-keyed, so a relay that several accounts read from can be asked about all of them by widening `authors` — the same shape of merge the notification tail just took, and for the same reason. The per-account `limit`s are summed rather than shared. These are mostly replaceable events, so the limit is a safety bound rather than a page size, and scaling it by the number of accounts leaves each one exactly the headroom it had alone. That is the difference from gift wraps, which stay per-account because their `limit` IS a page size over unsolicited content. Measured on emulator-5554, four accounts, cold start, counting nos.lol's `ERROR: too many concurrent REQs`: 13 before either merge, 11 after notifications, 8 after this. The subscription count on that relay went from 24-26 to 23, against its cap of 20. So the per-account multiplication is no longer the driver, and the remaining 23 are not account-level at all — they are the feed, channel and finder assemblers. Cutting further means looking there, not at more merging. Co-Authored-By: Claude Opus 5 (1M context) --- .../metadata/AccountMetadataEoseManager.kt | 102 +++++++++++------- .../FilterAccountInfoAndListsFromKey.kt | 18 ++-- .../FilterBookmarksAndReportsFromKey.kt | 6 +- .../metadata/FilterFollowsAndMutesFromKey.kt | 8 +- .../metadata/FilterLastPostsFromKey.kt | 8 +- .../assemblers/ContactCardFilters.kt | 6 +- 6 files changed, 89 insertions(+), 59 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt index db90fd8b7c..7c1c753072 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt @@ -20,66 +20,96 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +/** + * Each account's own profile, lists and recent posts — for **every** logged-in account, in one + * subscription. + * + * Every filter here is `authors`-keyed, so a relay that several accounts read from can be asked + * about all of them at once by widening `authors` rather than opening a REQ per account. This was + * the largest single contributor to blowing a relay's `max_subscriptions`: seven filters in a + * subscription, repeated once per account. + * + * The per-account `limit`s are summed rather than shared. These are mostly replaceable events, so + * the limit is a safety bound rather than a page size, and scaling it by the number of accounts + * keeps each one exactly the headroom it had alone. + */ class AccountMetadataEoseManager( client: INostrClient, allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun user(key: AccountQueryState) = key.account.userProfile() +) : SingleSubEoseManager(client, allKeys) { + override fun distinct(key: AccountQueryState) = key.account.userProfile() fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow override fun updateFilter( - key: AccountQueryState, + keys: List, since: SincePerRelayMap?, - ): List = - relayFlow(key).value.flatMap { - val since = since?.get(it)?.time - listOf( - filterAccountInfoAndListsFromKey(it, user(key).pubkeyHex, since), - filterFollowsAndMutesFromKey(it, user(key).pubkeyHex, since), - filterBookmarksAndReportsFromKey(it, user(key).pubkeyHex, since), - filterLastPostsFromKey(it, user(key).pubkeyHex, since ?: TimeUtils.oneMonthAgo()), - filterBasicAccountInfoFromKeys(it, key.otherAccounts.minus(key.account.userProfile().pubkeyHex).toList(), since), - ).flatten() + ): List { + val accountsPerRelay = mutableMapOf>() + keys.forEach { key -> + relayFlow(key).value.forEach { relay -> + accountsPerRelay.getOrPut(relay) { mutableListOf() }.add(key) + } } - val userJobMap = mutableMapOf>() + return accountsPerRelay.flatMap { (relay, accounts) -> + val pubkeys = accounts.map { it.account.userProfile().pubkeyHex } + val relaySince = since?.get(relay)?.time + + // The account-switcher avatars: other logged-in accounts this screen wants to name. + // Screens supply them; the background registry does not, so this is usually empty. + val otherAccounts = accounts.flatMapTo(mutableSetOf()) { it.otherAccounts }.minus(pubkeys.toSet()) - @OptIn(FlowPreview::class) - override fun newSub(key: AccountQueryState): Subscription { - val user = user(key) - userJobMap[user]?.forEach { it.cancel() } - userJobMap[user] = listOf( - key.account.scope.launch(Dispatchers.IO) { - relayFlow(key).collectLatest { - invalidateFilters() - } - }, - ) - - return super.newSub(key) + filterAccountInfoAndListsFromKey(relay, pubkeys, relaySince), + filterFollowsAndMutesFromKey(relay, pubkeys, relaySince), + filterBookmarksAndReportsFromKey(relay, pubkeys, relaySince), + filterLastPostsFromKey(relay, pubkeys, relaySince ?: TimeUtils.oneMonthAgo()), + filterBasicAccountInfoFromKeys(relay, otherAccounts.toList(), relaySince), + ).flatten() + } } - override fun endSub( - key: User, - subId: String, - ) { - super.endSub(key, subId) - userJobMap[key]?.forEach { it.cancel() } + /** Per-account relay watchers, reconciled as accounts come and go. See the notifications manager. */ + private val userJobMap = mutableMapOf>() + + override fun updateSubscriptions(keys: Set) { + val wanted = keys.associateBy { it.account.userProfile() } + + (userJobMap.keys - wanted.keys).toList().forEach { user -> + userJobMap.remove(user)?.forEach { it.cancel() } + } + + wanted.forEach { (user, key) -> + if (user !in userJobMap) { + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.IO) { + relayFlow(key).collectLatest { invalidateFilters() } + }, + ) + } + } + + super.updateSubscriptions(keys) + } + + override fun destroy() { + userJobMap.values.forEach { jobs -> jobs.forEach { it.cancel() } } + userJobMap.clear() + super.destroy() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 35d9288085..9b9fbf6020 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -109,10 +109,10 @@ val AmethystMetadataTagMapFilter = mapOf("d" to listOf(APP_SPECIFIC_DATA_D_TAG)) fun filterAccountInfoAndListsFromKey( relay: NormalizedRelayUrl, - pubkey: HexKey, + pubkeys: List, since: Long?, ): List { - if (pubkey.isEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( @@ -121,8 +121,8 @@ fun filterAccountInfoAndListsFromKey( ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, kinds = AccountInfoAndListsFromKeyKinds, - authors = listOf(pubkey), - limit = 20, + authors = pubkeys, + limit = 20 * pubkeys.size, since = since, ), ), @@ -132,8 +132,8 @@ fun filterAccountInfoAndListsFromKey( ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, kinds = AccountInfoAndListsFromKeyKinds2, - authors = listOf(pubkey), - limit = 80, + authors = pubkeys, + limit = 80 * pubkeys.size, since = since, ), ), @@ -141,7 +141,7 @@ fun filterAccountInfoAndListsFromKey( // Addressable — one card per target user — hence its own larger-limit filter. filterContactCardsByAuthorInTheRelay( relay = relay, - author = pubkey, + authors = pubkeys, since = since, ), RelayBasedFilter( @@ -150,9 +150,9 @@ fun filterAccountInfoAndListsFromKey( ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, kinds = AmethystMetadataKinds, - authors = listOf(pubkey), + authors = pubkeys, tags = AmethystMetadataTagMapFilter, - limit = 1, + limit = 1 * pubkeys.size, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt index edde979296..4b3af157bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -44,10 +44,10 @@ val ReportsAndBookmarksFromKeyKinds = fun filterBookmarksAndReportsFromKey( relay: NormalizedRelayUrl, - pubkey: HexKey?, + pubkeys: List, since: Long?, ): List { - if (pubkey.isNullOrEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( @@ -56,7 +56,7 @@ fun filterBookmarksAndReportsFromKey( ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, kinds = ReportsAndBookmarksFromKeyKinds, - authors = listOf(pubkey), + authors = pubkeys, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt index ce53c3f448..c4a4bc0541 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt @@ -48,10 +48,10 @@ val FollowAndMutesFromKeyKinds = fun filterFollowsAndMutesFromKey( relay: NormalizedRelayUrl, - pubkey: HexKey, + pubkeys: List, since: Long?, ): List { - if (pubkey.isEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( @@ -60,8 +60,8 @@ fun filterFollowsAndMutesFromKey( ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, kinds = FollowAndMutesFromKeyKinds, - authors = listOf(pubkey), - limit = 100, + authors = pubkeys, + limit = 100 * pubkeys.size, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt index e0a534ae4e..6ec9021e2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt @@ -28,10 +28,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterLastPostsFromKey( relay: NormalizedRelayUrl, - pubkey: HexKey, + pubkeys: List, since: Long?, ): List { - if (pubkey.isEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( @@ -39,8 +39,8 @@ fun filterLastPostsFromKey( filter = ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, - authors = listOf(pubkey), - limit = 100, + authors = pubkeys, + limit = 100 * pubkeys.size, since = since, ), ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt index b7bfc89949..f8d8c3a973 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt @@ -65,7 +65,7 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( */ fun filterContactCardsByAuthorInTheRelay( relay: NormalizedRelayUrl, - author: HexKey, + authors: List, since: Long?, limit: Int = 500, ): RelayBasedFilter = @@ -75,9 +75,9 @@ fun filterContactCardsByAuthorInTheRelay( ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, // This variant fetches an account's OWN contact card, so the author is the owner. - accountPubKeys = listOfNotNull(author), + accountPubKeys = authors, kinds = ContactCardKindList, - authors = listOf(author), + authors = authors, limit = limit, since = since, ), From 15f8b7713ea69fe7faa12f84a5f71b11adc5682c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 13:48:23 -0400 Subject: [PATCH 033/227] fix(relays): re-attribute account data after the metadata merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Your Account's data" fell into "Not attributed" one commit ago. None of the metadata builders ever named their own account — they relied on PerUserEoseManager stamping `attributedTo(pk)` over whatever they returned. Moving the manager to SingleSubEoseManager took that away, because that base class cannot attribute: one of its filters may serve several accounts, so there is no single pubkey to stamp. So the builders name themselves now, which is where the knowledge actually is. Every filter in this package fetches an account's OWN profile, lists, bookmarks and recent posts, so `authors` and the accounts asking are the same list. The one exception is filterBasicAccountInfoFromKeys, which fetches OTHER people's profiles for the account-switcher avatars: there the authors are precisely not the requester, so it takes `requestedBy` and is attributed to that instead. Its parameter carries the reason, since it reads like an inconsistency otherwise. Notifications were unaffected — those builders already set accountPubKeys themselves, which is why only this purpose lost its account. Verified on emulator-5554: "Your Account's data" appears under all four accounts again, and the unattributed count is back to 8 filters. Co-Authored-By: Claude Opus 5 (1M context) --- .../account/metadata/AccountMetadataEoseManager.kt | 2 +- .../account/metadata/FilterAccountInfoAndListsFromKey.kt | 3 +++ .../account/metadata/FilterBasicAccountInfoFromKeys.kt | 8 ++++++++ .../account/metadata/FilterBookmarksAndReportsFromKey.kt | 1 + .../account/metadata/FilterFollowsAndMutesFromKey.kt | 1 + .../reqCommand/account/metadata/FilterLastPostsFromKey.kt | 1 + 6 files changed, 15 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt index 7c1c753072..8c1f2f2410 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt @@ -78,7 +78,7 @@ class AccountMetadataEoseManager( filterFollowsAndMutesFromKey(relay, pubkeys, relaySince), filterBookmarksAndReportsFromKey(relay, pubkeys, relaySince), filterLastPostsFromKey(relay, pubkeys, relaySince ?: TimeUtils.oneMonthAgo()), - filterBasicAccountInfoFromKeys(relay, otherAccounts.toList(), relaySince), + filterBasicAccountInfoFromKeys(relay, otherAccounts.toList(), relaySince, pubkeys), ).flatten() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 9b9fbf6020..025025af62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -120,6 +120,7 @@ fun filterAccountInfoAndListsFromKey( filter = ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = pubkeys, kinds = AccountInfoAndListsFromKeyKinds, authors = pubkeys, limit = 20 * pubkeys.size, @@ -131,6 +132,7 @@ fun filterAccountInfoAndListsFromKey( filter = ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = pubkeys, kinds = AccountInfoAndListsFromKeyKinds2, authors = pubkeys, limit = 80 * pubkeys.size, @@ -149,6 +151,7 @@ fun filterAccountInfoAndListsFromKey( filter = ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = pubkeys, kinds = AmethystMetadataKinds, authors = pubkeys, tags = AmethystMetadataTagMapFilter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index b9985891fc..d265402ffc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -79,6 +79,12 @@ fun filterBasicAccountInfoFromKeys( relay: NormalizedRelayUrl, otherAccounts: List?, since: Long?, + /** + * Who wants these profiles. The `authors` here are OTHER people — the account-switcher's + * avatars — so unlike every other builder in this package the authors are NOT the accounts + * asking, and attribution has to be passed in or the row reads as unattributed. + */ + requestedBy: List, ): List { if (otherAccounts.isNullOrEmpty()) return emptyList() @@ -88,6 +94,7 @@ fun filterBasicAccountInfoFromKeys( filter = ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = requestedBy, kinds = BasicAccountInfoKinds, authors = otherAccounts.toList(), limit = otherAccounts.size * BasicAccountInfoKinds.size, @@ -99,6 +106,7 @@ fun filterBasicAccountInfoFromKeys( filter = ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = requestedBy, kinds = BasicAccountInfoKinds2, authors = otherAccounts.toList(), limit = otherAccounts.size * BasicAccountInfoKinds2.size, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt index 4b3af157bb..69b00aeea8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -55,6 +55,7 @@ fun filterBookmarksAndReportsFromKey( filter = ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = pubkeys, kinds = ReportsAndBookmarksFromKeyKinds, authors = pubkeys, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt index c4a4bc0541..c3e957a1bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterFollowsAndMutesFromKey.kt @@ -59,6 +59,7 @@ fun filterFollowsAndMutesFromKey( filter = ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = pubkeys, kinds = FollowAndMutesFromKeyKinds, authors = pubkeys, limit = 100 * pubkeys.size, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt index 6ec9021e2c..4f9ca34a34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterLastPostsFromKey.kt @@ -39,6 +39,7 @@ fun filterLastPostsFromKey( filter = ExplainedFilter( purpose = SubPurpose.ACCOUNT_DATA, + accountPubKeys = pubkeys, authors = pubkeys, limit = 100 * pubkeys.size, since = since, From e1404f4d6c01efc4581559f4d87c3035399877ea Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 14:05:26 -0400 Subject: [PATCH 034/227] fix(relays): drop "Your" from the account data label The row sits under an account header that already names whose it is, and with several accounts on screen "Your Account's data" repeated under someone else's name reads as a contradiction. Co-Authored-By: Claude Opus 5 (1M context) --- amethyst/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3a7bb51880..6e66ca78c5 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2093,7 +2093,7 @@ their profile screen. Naming the job avoids an active misreading. --> Relay List Finder Observing Profiles - Your Account\'s data + Account\'s data Home Feed Relay Groups From d9e01c85044c10f72665f38a9347e8e9bc3a9356 Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 31 Jul 2026 21:42:30 +0200 Subject: [PATCH 035/227] i18n: translate missing amethyst strings for cs, de, sv and pt-BR Fills the on-disk gaps in the four target locales: channel archive/unarchive, the new-forum title, the External Content screen title, the archived channel section header, the three share targets (picture / short / video) and the URL preview's open-in-browser action. Wording follows each locale's existing siblings rather than being translated fresh -- url_preview_open_in_browser reuses the string already in git_repo_open_in_browser, share_target_as_picture follows new_picture, and share_target_as_short_video follows home_content_type_shorts. relay_group_section_archived heads a channel list, so cs/sv/pt take the plural adjective. Keys whose translation would be byte-identical to the English source are left out on purpose: Crowdin strips source-identical entries on export and Android already falls back to values/strings.xml at runtime. That covers the "workflow" loanword in cs/de, and Highlights / Podcasts / Reposts / Torrents / Videos / Name / Backlog in de, plus Podcasts / Torrents / Backlog in pt-BR. The commons composeResources tree was already complete for all four locales. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL --- amethyst/src/main/res/values-cs/strings.xml | 9 +++++++++ amethyst/src/main/res/values-de-rDE/strings.xml | 9 +++++++++ amethyst/src/main/res/values-pt-rBR/strings.xml | 9 +++++++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 9 +++++++++ 4 files changed, 36 insertions(+) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 21594a1f84..36092ba04d 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -4792,4 +4792,13 @@ URL avataru (volitelné) Publikování… Publikovat personu + Archivovat kanál + Zrušit archivaci kanálu + Nové fórum + Externí obsah + Archivované + Nový obrázek + Nové krátké video + Nové video + Otevřít v prohlížeči diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 4435dd9629..8bf354d3b0 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -4616,4 +4616,13 @@ Avatar-URL (optional) Wird veröffentlicht… Persona veröffentlichen + Kanal archivieren + Kanal aus dem Archiv nehmen + Neues Forum + Externe Inhalte + Archiviert + Neues Bild + Neuer Kurzfilm + Neues Video + Im Browser öffnen diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 26efddbc9d..ac4d92395c 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -4621,4 +4621,13 @@ URL do avatar (opcional) Publicando… Publicar persona + Arquivar canal + Desarquivar canal + Novo fórum + Conteúdo externo + Arquivados + Nova Foto + Novo Vídeo Curto + Novo Vídeo + Abrir no navegador diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 0adb501ce0..44e9929e4b 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -4624,4 +4624,13 @@ Avatar-URL (valfritt) Publicerar… Publicera persona + Arkivera kanal + Avarkivera kanal + Nytt forum + Externt innehåll + Arkiverade + Ny bild + Ny kortfilm + Ny video + Öppna i webbläsare From d7d1b052fefe462fe5fa048372e30a53338f394a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 15:54:29 -0400 Subject: [PATCH 036/227] fix(relays): file the account's own contact cards under account data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Observing Profiles" showed up for accounts that had no screen at all, which is the one thing that purpose is explained as never being: "profiles of the people currently on screen". ContactCardFilters has two builders sharing SubPurpose.PROFILE_METADATA, and only one of them observes anything. filterContactCardsToTargetKeysFromTrustedAccounts… fetches kind:30382 cards ABOUT the users on screen — that one is right. filterContactCardsByAuthorInTheRelay fetches the account's OWN nicknames in the login-time bulk download, from the account's own relays, with nobody on screen; its own KDoc said as much while the purpose said otherwise. It rides filterAccountInfoAndListsFromKey, so every logged-in account carried one of these per relay — Dr Martha Liz's two relays showed as "Observing Profiles · 2 filters · 2 relays" for an account nobody was looking at. It is account data, and now says so. Verified on emulator-5554: only the account on screen still reports Observing Profiles; the other three carry Account's data alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../relayClient/assemblers/ContactCardFilters.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt index f8d8c3a973..87df5fba9c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt @@ -59,9 +59,14 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( } /** - * Every kind:30382 card *written by* [author] — the account's own nicknames — - * for the bulk download at login from the account's relays. Addressable events: + * Every kind:30382 card *written by* [authors] — the accounts' own nicknames — + * for the bulk download at login from the accounts' own relays. Addressable events: * one card per target user, hence the larger limit. + * + * [SubPurpose.ACCOUNT_DATA], not [SubPurpose.PROFILE_METADATA] like its sibling above: nobody has to + * be on screen for this to run. It is part of the login-time account load, so filing it under + * "Observing Profiles" — explained as *"profiles of the people currently on screen"* — made every + * logged-in account look like it was watching somebody. */ fun filterContactCardsByAuthorInTheRelay( relay: NormalizedRelayUrl, @@ -73,8 +78,8 @@ fun filterContactCardsByAuthorInTheRelay( relay = relay, filter = ExplainedFilter( - purpose = SubPurpose.PROFILE_METADATA, - // This variant fetches an account's OWN contact card, so the author is the owner. + purpose = SubPurpose.ACCOUNT_DATA, + // This variant fetches the accounts' OWN contact cards, so the authors are the owners. accountPubKeys = authors, kinds = ContactCardKindList, authors = authors, From e94b8eaf3b62bc25ada2cdda6c7b1da0c80d5128 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 20:00:07 +0000 Subject: [PATCH 037/227] fix(nip44): content-address shared-key cache and use constant-time MAC checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hardening fixes in the NIP-44/NIP-04 encryption primitives: 1. SharedKeyCache keyed conversation/shared secrets by a 32-bit polynomial hashCode of (privateKey || pubKey). Distinct peers whose bytes hash to the same Int collided on one LRU slot, so get() could return one peer's key for a message meant for another — a silent wrong-key encrypt/decrypt. The hash collides independently of the private key, so a pubkey aliasing a victim's contact is grindable. Now keyed on the full (priv || pub) byte content. Added SharedKeyCacheTest, which fails on the old hashCode key and passes now. 2. NIP-44 MAC verification (Hkdf.fastExpand and Nip44v2.checkHMacAad) used ByteArray.contentEquals, which short-circuits on the first mismatching byte and leaks a timing side channel on the HMAC tag. Switched to a shared equalsConstantTime helper (utils/ConstantTime.kt), matching the constant-time tag checks already used in ChaCha20Poly1305/XChaCha20Poly1305/MLS. Also corrects the X25519 KDoc, which claimed the JVM/Android actual delegates to java.security XDH; it is in fact the same pure-Kotlin Montgomery ladder as the other targets. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PY7kpiTcFiDshYqBsQ5KVC --- .../quartz/marmot/mls/crypto/X25519.kt | 11 ++- .../quartz/nip44Encryption/Nip44v2.kt | 3 +- .../quartz/nip44Encryption/SharedKeyCache.kt | 27 +++--- .../quartz/nip44Encryption/crypto/Hkdf.kt | 3 +- .../quartz/utils/ConstantTime.kt | 43 ++++++++++ .../nip44Encryption/SharedKeyCacheTest.kt | 86 +++++++++++++++++++ 6 files changed, 156 insertions(+), 17 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/ConstantTime.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCacheTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.kt index 702fb3d8b4..ea6f18ae98 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.kt @@ -25,9 +25,14 @@ package com.vitorpamplona.quartz.marmot.mls.crypto * * Used in TreeKEM for path secret encryption via HPKE. * - * Platform-specific implementations: - * - JVM/Android: java.security XDH (Java 11+, Android API 31+) - * - Native: expect/actual with platform crypto + * All platform actuals (jvmAndroid, apple, linux) are the SAME pure-Kotlin + * Montgomery-ladder implementation over [Curve25519Field] (a TweetNaCl port) — + * they intentionally do NOT delegate to a platform provider such as + * `java.security` XDH, because Android's KeyStore/JCA integration for raw X25519 + * keys is unreliable across API levels. `dh()` rejects an all-zero shared secret + * per RFC 7748 §6.1; note the ladder relies on branch-uniform (mask-based) + * selection but, being pure Kotlin on the JVM/ART, carries no hardware + * constant-time guarantee. */ expect object X25519 { /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt index 7dffa7b13a..98baa80452 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20 import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.equalsConstantTime import kotlinx.coroutines.CancellationException import kotlin.io.encoding.Base64 import kotlin.math.floor @@ -104,7 +105,7 @@ class Nip44v2 { ) { val calculatedMac = hmacAad(messageKey.hmacKey, decoded.ciphertext, decoded.nonce) - check(calculatedMac.contentEquals(decoded.mac)) { + check(calculatedMac.equalsConstantTime(decoded.mac)) { "Invalid Mac: Calculated ${calculatedMac.toHexKey()}, decoded: ${decoded.mac.toHexKey()}" } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt index 3d510ca11e..d613695448 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt @@ -21,34 +21,37 @@ package com.vitorpamplona.quartz.nip44Encryption import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.core.toHexKey class SharedKeyCache { - private val sharedKeyCache = LruCache(200) + // Keyed by the full (privateKey || pubKey) content, NOT by a 32-bit hashCode. + // A hashCode key silently collides: two distinct peers whose (priv, pub) bytes + // hash to the same Int would share a cache slot, so `get` could return one + // peer's conversation key for a message meant for the other — a silent + // wrong-key encrypt/decrypt. The polynomial hash that was used here collides + // independently of the private key, so an attacker could grind a pubkey that + // aliases a victim's contact. Keying on the raw bytes removes the collision. + private val sharedKeyCache = LruCache(200) fun clearCache() { sharedKeyCache.evictAll() } - fun combinedHashCode( - a: ByteArray, - b: ByteArray, - ): Int { - var result = 1 - for (element in a) result = 31 * result + element - for (element in b) result = 31 * result + element - return result - } + private fun cacheKey( + privateKey: ByteArray, + pubKey: ByteArray, + ): String = privateKey.toHexKey() + pubKey.toHexKey() fun get( privateKey: ByteArray, pubKey: ByteArray, - ): ByteArray? = sharedKeyCache[combinedHashCode(privateKey, pubKey)] + ): ByteArray? = sharedKeyCache[cacheKey(privateKey, pubKey)] fun add( privateKey: ByteArray, pubKey: ByteArray, secret: ByteArray, ) { - sharedKeyCache.put(combinedHashCode(privateKey, pubKey), secret) + sharedKeyCache.put(cacheKey(privateKey, pubKey), secret) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt index 9ede033861..e117245b2f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip44Encryption.crypto import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.equalsConstantTime import com.vitorpamplona.quartz.utils.mac.MacInstance class Hkdf( @@ -194,7 +195,7 @@ class Hkdf( mac.update(ciphertext) val calculatedMac = mac.doFinal() - check(calculatedMac.contentEquals(ciphertextMac)) { + check(calculatedMac.equalsConstantTime(ciphertextMac)) { "Invalid Mac: Calculated ${calculatedMac.toHexKey()}, decoded: ${ciphertextMac.toHexKey()}" } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/ConstantTime.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/ConstantTime.kt new file mode 100644 index 0000000000..b9b0ccb757 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/ConstantTime.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +/** + * Constant-time byte-array equality for MAC / authentication-tag comparison. + * + * Unlike [ByteArray.contentEquals], this does not short-circuit on the first + * differing byte, so its running time does not depend on how many leading bytes + * of a candidate MAC/tag are correct. Comparing MACs with an early-exit check + * exposes a timing side channel that can, in principle, let an attacker recover a + * valid tag byte-by-byte and forge messages (the classic Keyczar/XBox-360 class + * of bug). Use this for every secret-dependent equality check (HMAC tags, Poly1305 + * tags, reset tokens); ordinary non-secret comparisons can keep `contentEquals`. + * + * The length check leaks only the (public, fixed) tag length, not its contents. + */ +fun ByteArray.equalsConstantTime(other: ByteArray): Boolean { + if (this.size != other.size) return false + var diff = 0 + for (i in indices) { + diff = diff or (this[i].toInt() xor other[i].toInt()) + } + return diff == 0 +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCacheTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCacheTest.kt new file mode 100644 index 0000000000..e0e97e64e4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCacheTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip44Encryption + +import kotlin.test.Test +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SharedKeyCacheTest { + /** + * The cache maps (privateKey, pubKey) to a shared/conversation key. If two + * DISTINCT peer pubkeys can map to the same cache slot, the cache returns one + * peer's secret for a message intended for the other — a silent wrong-key + * encryption/decryption (confidentiality break). + * + * `pubA` and `pubB` below are engineered to collide under a 31-multiplier + * polynomial hash of their bytes: bumping byte[31] by +31 and byte[30] by -1 + * leaves `31 * acc + byte` unchanged (31^1 * (-1) + 31^0 * (+31) == 0). They + * are otherwise different keys, so a correct cache MUST treat them as distinct. + */ + @Test + fun collidingPubKeysMustNotShareCacheEntry() { + val cache = SharedKeyCache() + val priv = ByteArray(32) { 1 } + + val pubA = + ByteArray(32).also { + it[30] = 0x10 + it[31] = 0x00 + } + val pubB = + ByteArray(32).also { + it[30] = 0x0F + it[31] = 0x1F + } + + // Sanity: these are genuinely different peer public keys. + assertTrue(!pubA.contentEquals(pubB), "test setup: pubkeys must differ") + + val secretForA = ByteArray(32) { 0xAA.toByte() } + cache.add(priv, pubA, secretForA) + + // The secret cached for peer A must never be handed back for peer B. + assertNull( + cache.get(priv, pubB), + "cache returned peer A's shared secret when asked for peer B (hash collision leaks the wrong key)", + ) + + // The legitimate lookup must still work. + assertTrue(secretForA.contentEquals(cache.get(priv, pubA)!!)) + } + + @Test + fun distinctPeersGetDistinctEntries() { + val cache = SharedKeyCache() + val priv = ByteArray(32) { 7 } + val pub1 = ByteArray(32) { 2 } + val pub2 = ByteArray(32) { 3 } + val s1 = ByteArray(32) { 0x11 } + val s2 = ByteArray(32) { 0x22 } + + cache.add(priv, pub1, s1) + cache.add(priv, pub2, s2) + + assertTrue(s1.contentEquals(cache.get(priv, pub1)!!)) + assertTrue(s2.contentEquals(cache.get(priv, pub2)!!)) + } +} From a59bc6d82d4b495945aaca25dba9c8ce3f654be4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 20:13:59 +0000 Subject: [PATCH 038/227] refactor(nip44): use a content-addressed key object for the shared-key cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hex-string cache key with a small CacheKey holding the raw (privateKey, pubKey) references — no byte copy and no ~400-byte hex String per lookup. The precomputed Int hash is only a bucket selector; equals() does the authoritative full-content comparison, so collisions share a bucket and are disambiguated rather than returning the wrong peer's secret. Same value-type immutability contract already relied on by X25519KeyPair as a map key. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PY7kpiTcFiDshYqBsQ5KVC --- .../quartz/nip44Encryption/SharedKeyCache.kt | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt index d613695448..a74614dda6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt @@ -21,37 +21,58 @@ package com.vitorpamplona.quartz.nip44Encryption import androidx.collection.LruCache -import com.vitorpamplona.quartz.nip01Core.core.toHexKey class SharedKeyCache { - // Keyed by the full (privateKey || pubKey) content, NOT by a 32-bit hashCode. - // A hashCode key silently collides: two distinct peers whose (priv, pub) bytes - // hash to the same Int would share a cache slot, so `get` could return one - // peer's conversation key for a message meant for the other — a silent - // wrong-key encrypt/decrypt. The polynomial hash that was used here collides - // independently of the private key, so an attacker could grind a pubkey that - // aliases a victim's contact. Keying on the raw bytes removes the collision. - private val sharedKeyCache = LruCache(200) + // Keyed by the full (privateKey, pubKey) content via [CacheKey], NOT by a bare + // 32-bit hashCode. Using a hashCode as the whole map key silently collides: + // two distinct peers whose (priv, pub) bytes hash to the same Int would share a + // slot, so `get` could return one peer's conversation key for a message meant + // for the other — a silent wrong-key encrypt/decrypt (and the polynomial hash + // that was used collides independently of the private key, so a pubkey aliasing + // a victim's contact is grindable). [CacheKey] keeps the cheap Int hash only as + // a bucket selector and disambiguates collisions with a full contentEquals, so + // it stays correct while avoiding the per-lookup allocation of a hex String key. + private val sharedKeyCache = LruCache(200) fun clearCache() { sharedKeyCache.evictAll() } - private fun cacheKey( - privateKey: ByteArray, - pubKey: ByteArray, - ): String = privateKey.toHexKey() + pubKey.toHexKey() - fun get( privateKey: ByteArray, pubKey: ByteArray, - ): ByteArray? = sharedKeyCache[cacheKey(privateKey, pubKey)] + ): ByteArray? = sharedKeyCache[CacheKey(privateKey, pubKey)] fun add( privateKey: ByteArray, pubKey: ByteArray, secret: ByteArray, ) { - sharedKeyCache.put(cacheKey(privateKey, pubKey), secret) + sharedKeyCache.put(CacheKey(privateKey, pubKey), secret) + } + + /** + * Content-addressed cache key holding the raw key references (no byte copy, no + * hex string). The precomputed [hash] is only a bucket selector — [equals] does + * the authoritative full-content comparison, so hash collisions can never return + * the wrong peer's secret. Callers must treat the passed arrays as immutable + * (the same value-type contract [com.vitorpamplona.quartz.marmot.mls.crypto.X25519KeyPair] + * relies on when used as a map key). + */ + private class CacheKey( + val privateKey: ByteArray, + val pubKey: ByteArray, + ) { + private val hash = privateKey.contentHashCode() * 31 + pubKey.contentHashCode() + + override fun hashCode(): Int = hash + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CacheKey) return false + return hash == other.hash && + privateKey.contentEquals(other.privateKey) && + pubKey.contentEquals(other.pubKey) + } } } From 7fb99842a834c507dea91b7b453c728d52be9513 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 16:59:04 -0400 Subject: [PATCH 039/227] fix(relays): FOLLOW_LISTS does not run in the background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every producer of FOLLOW_LISTS is a browse filter on the Follow Sets feed — FilterFollowSetsBy{Authors,Community,AllCommunities,Geohash,Hashtag} and Global, all under discover/nip51FollowSets. Nothing account-level assigns it: the account's own follows ride ACCOUNT_DATA with the rest of its lists. So the `runsInBackground = true` described a producer that does not exist. The flag has no code consumer — isWorthNamingInNotification keys off `group`, not this. It is read by whoever is looking at a backgrounded breakdown and asking which of these purposes is allowed to be there, and the enum says a `runsInBackground = false` purpose appearing while backgrounded is a leak. A browse feed claiming to be allowed is exactly the entry that would get waved through. Nothing behaves differently. It stops the taxonomy lying to the next person who uses it to diagnose one. Co-Authored-By: Claude Opus 5 (1M context) --- .../relayClient/subscriptions/SubPurpose.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt index 926e11d11f..12014dfea7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/SubPurpose.kt @@ -63,8 +63,20 @@ enum class SubPurpose( /** NIP-65 relay lists, DM relay lists, blocked/trusted relay sets — outbox discovery. */ RELAY_LISTS(SubPurposeGroup.ACCOUNT, runsInBackground = true), - /** Kind-3 follows and the web-of-trust sets derived from them. */ - FOLLOW_LISTS(SubPurposeGroup.ACCOUNT, runsInBackground = true), + /** + * Browsing other people's NIP-51 follow sets — the Follow Sets feed. + * + * Named for kind-3 follows and the web-of-trust, and grouped under the account for it, but every + * producer is a browse filter on a screen (`discover/nip51FollowSets`). The account's own follows + * ride [ACCOUNT_DATA] with the rest of its lists, so nothing here outlives the screen and the + * `runsInBackground = true` it used to carry was simply untrue. + * + * That flag is read by people, not by code, which is exactly why a wrong one is worth fixing: + * anyone checking a backgrounded breakdown against it would have cleared this as allowed. + * + * ([MINT_DIRECTORY] is the other account-grouped purpose that is screen-lived.) + */ + FOLLOW_LISTS(SubPurposeGroup.ACCOUNT), /** Reports (kind 1984), mute lists, spam and ban state. */ MODERATION(SubPurposeGroup.ACCOUNT, runsInBackground = true), From 903bf11abf68665be254c46112f3133c4edab874 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 17:12:36 -0400 Subject: [PATCH 040/227] refactor(relays): audit cleanups on the subscription work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from reading the branch end to end: - AccountNotificationsEoseFromInboxRelaysManager carried a `pubkeyOf` helper with no callers and a comment inventing a reason for it. Removed. - `limit = 1 * pubkeys.size` said the multiplication out loud for no reason. - The two merged managers scale their limits differently and nothing said why. Metadata multiplies by account count, notifications does not, and that is deliberate: metadata is replaceable so its limit is a safety bound, while notifications are a stream whose limit is a page size — scaling would ask 4x the data on every cold start and relays clamp it anyway. Documented, along with the consequence (a busy account can crowd a quiet one out of the shared newest-N) and what recovers it (the history pager, left unmerged). Co-Authored-By: Claude Opus 5 (1M context) --- .../FilterAccountInfoAndListsFromKey.kt | 2 +- ...ntNotificationsEoseFromInboxRelaysManager.kt | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 025025af62..087e2cdeee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -155,7 +155,7 @@ fun filterAccountInfoAndListsFromKey( kinds = AmethystMetadataKinds, authors = pubkeys, tags = AmethystMetadataTagMapFilter, - limit = 1 * pubkeys.size, + limit = pubkeys.size, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index 6d1c1fdb74..1a61cc9029 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -24,7 +24,6 @@ import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEose import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -50,6 +49,19 @@ import kotlinx.coroutines.launch * to the relay, so it cannot rate-limit them per recipient; a merged query would let one spammed * account consume the shared `limit` and starve every other account's DMs. Each account keeps its own * budget there — see [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager]. + * + * ## The `limit` here is shared, and deliberately not scaled + * + * [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager] + * multiplies its limits by the number of merged accounts; this one does not, and the difference is the + * kind of event. Metadata is replaceable, so the limit is a safety bound and widening it costs nothing. + * Notifications are a stream, so the limit is a page size: scaling it by four accounts would ask four + * times the data of every relay on every cold start, and most would clamp it anyway (`max_limit` is 500 + * on strfry — already below the 2000 the summary filter asks for). + * + * So on a cold start a busy account can crowd a quiet one out of the shared newest-N. What recovers it + * is [AccountNotificationsHistoryEoseManager], which pages backward per account and stays unmerged for + * exactly that reason. */ class AccountNotificationsEoseFromInboxRelaysManager( client: INostrClient, @@ -170,7 +182,4 @@ class AccountNotificationsEoseFromInboxRelaysManager( userJobMap.clear() super.destroy() } - - /** Unused here, but kept so callers reading a key's owner do not reach into the account. */ - fun pubkeyOf(key: AccountQueryState): HexKey = key.account.userProfile().pubkeyHex } From 19a0088e36d053793e69f7a4adf03861b597c9c4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 17:56:19 -0400 Subject: [PATCH 041/227] fix(relays): refetch when a merged subscription gains an account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A merged filter keeps one EOSE cursor per relay. That is right while the set of accounts it covers is stable and silently wrong the moment it grows: the account that joins inherits a cursor it never earned, so `since` skips everything older and its history is never requested. The subscription looks healthy, reports EOSE and delivers new events forever after — it just never backfills. This is the normal startup path, not an edge case. The account on screen mounts from Compose and EOSEs within a second or two; the registry brings the rest in a moment later, onto relays that have already reported EOSE. Account switching and mid-session login hit it the same way. It bites metadata harder than notifications. Notifications have a backward pager that is deliberately still per-account and can recover the gap. Metadata has none, so an account that joined mid-session would go without its own profile, follows and lists until the next launch cleared the in-memory cursor. MergedAuthorTracker records which accounts each relay's filter last covered and reports growth; both merged managers drop that relay's cursor when it grows, so the next filter asks from scratch. Growth only: an account leaving takes nothing with it, and the accounts that remain already have their events. Verified: MergedAuthorTrackerTest covers first-sight, join, no-op, leave, swap, per-relay independence and clear — mutation-checked by making the predicate return false, which fails three of the seven. On emulator-5554 all four accounts keep their Notifications, DM Inbox and Account's data, and nos.lol refusals over three clean cold starts were 0/0/7 against 8 before, so the extra refetch costs nothing measurable. Co-Authored-By: Claude Opus 5 (1M context) --- .../metadata/AccountMetadataEoseManager.kt | 11 ++ ...NotificationsEoseFromInboxRelaysManager.kt | 9 ++ .../eoseManagers/MergedAuthorTracker.kt | 62 ++++++++++ .../eoseManagers/SingleSubEoseManager.kt | 12 ++ .../amethyst/commons/relays/EOSERelayList.kt | 11 ++ .../eoseManagers/MergedAuthorTrackerTest.kt | 110 ++++++++++++++++++ 6 files changed, 215 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/MergedAuthorTracker.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/MergedAuthorTrackerTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt index 8c1f2f2410..9abf466e80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.MergedAuthorTracker import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState @@ -67,6 +68,13 @@ class AccountMetadataEoseManager( return accountsPerRelay.flatMap { (relay, accounts) -> val pubkeys = accounts.map { it.account.userProfile().pubkeyHex } + + // An account joining a relay this subscription already covers must not inherit the cursor + // the earlier accounts earned — it would never ask for its own profile, follows or lists. + // This matters more here than for notifications: there is no backward pager to rescue it, + // so the account would go without until the next launch cleared the in-memory cursor. + if (authorsPerRelay.gainedAuthors(relay, pubkeys)) clearEoseFor(relay) + val relaySince = since?.get(relay)?.time // The account-switcher avatars: other logged-in accounts this screen wants to name. @@ -83,6 +91,8 @@ class AccountMetadataEoseManager( } } + private val authorsPerRelay = MergedAuthorTracker() + /** Per-account relay watchers, reconciled as accounts come and go. See the notifications manager. */ private val userJobMap = mutableMapOf>() @@ -108,6 +118,7 @@ class AccountMetadataEoseManager( } override fun destroy() { + authorsPerRelay.clear() userJobMap.values.forEach { jobs -> jobs.forEach { it.cancel() } } userJobMap.clear() super.destroy() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index 1a61cc9029..9b8342508e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.MergedAuthorTracker import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState @@ -90,6 +91,11 @@ class AccountNotificationsEoseFromInboxRelaysManager( return accountsPerRelay.flatMap { (relay, accounts) -> val pubkeys = accounts.map { it.account.userProfile().pubkeyHex } + // An account that joins a relay this subscription already covers would otherwise inherit + // the cursor the earlier accounts earned, and never ask for anything older than it. + // Dropping the cursor first means `since` below reads null and the merged filter refetches. + if (authorsPerRelay.gainedAuthors(relay, pubkeys)) clearEoseFor(relay) + // A cold-start floor, NOT paging — backward paging lives in // [AccountNotificationsHistoryEoseManager]. Read only when a relay has no EOSE yet; once it // does, the EOSE time wins and this is never consulted. @@ -140,6 +146,8 @@ class AccountNotificationsEoseFromInboxRelaysManager( * gets its own. Re-entrancy is safe — the watchers call `invalidateFilters()`, which lands back * here and finds every account already watched. */ + private val authorsPerRelay = MergedAuthorTracker() + private val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) @@ -178,6 +186,7 @@ class AccountNotificationsEoseFromInboxRelaysManager( } override fun destroy() { + authorsPerRelay.clear() userJobMap.values.forEach { jobs -> jobs.forEach { it.cancel() } } userJobMap.clear() super.destroy() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/MergedAuthorTracker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/MergedAuthorTracker.kt new file mode 100644 index 0000000000..65c9c6bb41 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/MergedAuthorTracker.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Remembers which accounts a merged filter last covered on each relay, and reports when that set + * *gains* a member. + * + * A merged subscription keeps one EOSE cursor per relay. That is correct while the set of accounts it + * covers is stable, and silently wrong the moment it grows: the account that joins inherits a cursor + * earned by the accounts already there, so its `since` skips everything older — the app opens the + * subscription, believes it is live, and never asks for the history the new account actually needs. + * + * It is the normal startup path, not an edge case. The account on screen mounts from Compose and + * EOSEs within a second or two; the other accounts arrive from the registry a moment later and land on + * relays that have already reported EOSE. + * + * Growth is the only trigger. An account *leaving* takes nothing with it — the accounts that remain + * already have their events, so their cursor stays valid. + */ +class MergedAuthorTracker { + private val lastSeen = mutableMapOf>() + + /** + * Records [authors] as the current set for [relay] and returns true when it contains someone the + * previous set did not — i.e. the caller should drop that relay's EOSE and refetch from scratch. + * + * False the first time a relay is seen: there is no cursor to invalidate yet. + */ + fun gainedAuthors( + relay: NormalizedRelayUrl, + authors: Collection, + ): Boolean { + val current = authors.toSet() + val previous = lastSeen.put(relay, current) + return previous != null && !previous.containsAll(current) + } + + /** Forgets everything, for teardown. */ + fun clear() = lastSeen.clear() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt index 8c012cc94d..fd2c6f4922 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt @@ -53,6 +53,18 @@ abstract class SingleSubEoseManager( fun since() = latestEOSEs.since() + /** + * Drops one relay's EOSE cursor so the next assembly asks it from scratch. + * + * This manager keeps a single cursor per relay for the whole key set, which is right while that + * set is stable and wrong the moment it grows: a key that joins later inherits a cursor earned by + * keys that were already there, and everything older than it is never requested. Subclasses whose + * filters merge keys together call this when a relay's set of keys gains a member. + */ + protected fun clearEoseFor(relay: NormalizedRelayUrl) { + latestEOSEs.remove(relay) + } + open fun newEose( relay: NormalizedRelayUrl, time: Long, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt index 59c1f9dc2a..53c7aadc3c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt @@ -47,6 +47,17 @@ class EOSERelayList { relayList = mutableMapOf() } + /** + * Forgets one relay's cursor, so the next filter built for it asks from scratch. + * + * Needed when a subscription's *scope* widens rather than its contents changing — a merged filter + * that starts covering another account has already-EOSE'd relays whose `since` would silence + * exactly the history the new account still needs. + */ + fun remove(relayUrl: NormalizedRelayUrl) { + relayList.remove(relayUrl) + } + fun since() = relayList fun newEose( diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/MergedAuthorTrackerTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/MergedAuthorTrackerTest.kt new file mode 100644 index 0000000000..2cc3cea499 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/MergedAuthorTrackerTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The rule that keeps a merged subscription from silently withholding history. + * + * One EOSE cursor per relay is right while the covered set is stable and wrong the moment it grows: + * whoever joins inherits a cursor they never earned, so `since` skips everything older and the app + * never asks for it. These pin *when* the cursor has to be dropped — over-dropping costs a refetch, + * under-dropping loses data silently, so the asymmetry is deliberate. + */ +class MergedAuthorTrackerTest { + private val relayA = NormalizedRelayUrl("wss://a.example/") + private val relayB = NormalizedRelayUrl("wss://b.example/") + + private val vitor = "aa".repeat(32) + private val edo = "bb".repeat(32) + private val liz = "cc".repeat(32) + + @Test + fun `the first sight of a relay never drops a cursor`() { + val tracker = MergedAuthorTracker() + + // Nothing has EOSE'd yet, so there is nothing to invalidate. + assertFalse(tracker.gainedAuthors(relayA, listOf(vitor))) + } + + @Test + fun `an account joining a relay drops that relay's cursor`() { + val tracker = MergedAuthorTracker() + tracker.gainedAuthors(relayA, listOf(vitor)) + + // The real startup path: the screen's account mounts and EOSEs, then the registry brings the + // rest in a second later. Without this, Edo and Liz get `since = ` forever. + assertTrue(tracker.gainedAuthors(relayA, listOf(vitor, edo, liz))) + } + + @Test + fun `the same set again is not growth`() { + val tracker = MergedAuthorTracker() + tracker.gainedAuthors(relayA, listOf(vitor, edo)) + + // updateFilter runs on every invalidation — order must not matter, and a no-op must stay one. + assertFalse(tracker.gainedAuthors(relayA, listOf(vitor, edo))) + assertFalse(tracker.gainedAuthors(relayA, listOf(edo, vitor))) + } + + @Test + fun `an account leaving is not growth`() { + val tracker = MergedAuthorTracker() + tracker.gainedAuthors(relayA, listOf(vitor, edo, liz)) + + // Backgrounding drops the accounts that did not opt in. The ones that remain already have + // their events, so their cursor is still honest — refetching would be pure waste. + assertFalse(tracker.gainedAuthors(relayA, listOf(vitor))) + } + + @Test + fun `a swap counts as growth`() { + val tracker = MergedAuthorTracker() + tracker.gainedAuthors(relayA, listOf(vitor)) + + // Same size, different member: Edo is new here even though nothing grew in count. + assertTrue(tracker.gainedAuthors(relayA, listOf(edo))) + } + + @Test + fun `relays are tracked independently`() { + val tracker = MergedAuthorTracker() + tracker.gainedAuthors(relayA, listOf(vitor)) + tracker.gainedAuthors(relayB, listOf(vitor)) + + assertTrue(tracker.gainedAuthors(relayA, listOf(vitor, edo))) + // B's set did not change, so B's cursor must survive A's churn. + assertFalse(tracker.gainedAuthors(relayB, listOf(vitor))) + } + + @Test + fun `clearing forgets everything, so the next sight is a first sight`() { + val tracker = MergedAuthorTracker() + tracker.gainedAuthors(relayA, listOf(vitor, edo)) + tracker.clear() + + assertFalse(tracker.gainedAuthors(relayA, listOf(vitor))) + } +} From a295f31f9406eda825376bdbf49ce58d17a6e12b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 18:42:25 -0400 Subject: [PATCH 042/227] refactor(relays): mount the Cashu wallet like every other account subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet was the only account-level subscription whose lifetime was decided by the model. CashuWalletState collected relay sets on the Account's own scope and called subscribe/unsubscribe itself, so it ran for every Account object that happened to be resident — including accounts loaded purely so pushed gift wraps could be decrypted by their owner, which have no wallet anyone is looking at. Gating that with a subscribedAccounts flow made it worse: a model object read the relay layer's bookkeeping ("is this pubkey REQ-ing anywhere?") to decide whether to talk to relays, and encoded a proxy for the rule rather than the rule. It happened to work only because the registry mounts every account in the foreground. CashuWalletEoseManager is a PerUserEoseManager in the account group, exactly like NwcNotificationsEoseManager already was. Start and stop now come from the same mounts as notifications, DMs and gift wraps — the screen's for the account on show, the registry's for the rest — and will follow whatever the foreground/background rule becomes without knowing about it. Per user, never merged: each wallet reads its own outbox for its own events and its own inbox for nutzaps addressed to it. commons keeps the query shape as a plain cashuWalletFilters() function and loses the CashuWalletFilterAssembler wrapper. subscribedAccounts disappears entirely — from Account, AccountCacheState, AppModules, both AccountViewModel previews, both androidTests, and AccountFilterAssembler itself. Verified on the wire, not just on the screen: with REQ logging temporarily on, 134 REQ frames carried kind 17375 across 9 relays naming all 4 logged-in accounts, and 90 carried kind 9321. The subscriptions screen agrees — Wallet and Nutzap Inbox under each of the four. Co-Authored-By: Claude Opus 5 (1M context) --- .../NotificationFeedFilterModeOverrideTest.kt | 3 - .../ThreadDualAxisChartAssemblerTest.kt | 3 - .../com/vitorpamplona/amethyst/AppModules.kt | 2 - .../vitorpamplona/amethyst/model/Account.kt | 5 - .../model/accountsCache/AccountCacheState.kt | 4 - .../model/nip60Cashu/CashuWalletState.kt | 67 +------- .../RelaySubscriptionsCoordinator.kt | 6 - .../account/AccountFilterAssembler.kt | 25 +-- .../nip60Cashu/CashuWalletEoseManager.kt | 124 ++++++++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 12 -- .../amethyst/cli/CashuContext.kt | 2 +- .../assemblers/CashuWalletFilterAssembler.kt | 156 +++++++----------- 12 files changed, 194 insertions(+), 215 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletEoseManager.kt diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt index 3f2c36b95d..f7ebb414f5 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler -import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache @@ -86,8 +85,6 @@ class NotificationFeedFilterModeOverrideTest { signer = NostrSignerInternal(keyPair), geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, nwcFilterAssembler = { NWCPaymentFilterAssembler(client) }, - cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) }, - subscribedAccounts = { MutableStateFlow(emptySet()) }, cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) }, okHttpClientForMoney = { OkHttpClient() }, otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 38a97dc730..68da55b297 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler -import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings @@ -77,8 +76,6 @@ class ThreadDualAxisChartAssemblerTest { signer = NostrSignerInternal(keyPair), geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, nwcFilterAssembler = { NWCPaymentFilterAssembler(client) }, - cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) }, - subscribedAccounts = { MutableStateFlow(emptySet()) }, cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) }, okHttpClientForMoney = { OkHttpClient() }, otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 8098fed6e7..0dfa486665 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -872,8 +872,6 @@ class AppModules( AccountCacheState( geolocationFlow = { locationManager.geohashStateFlow }, nwcFilterAssembler = { sources.nwc }, - cashuWalletFilterAssembler = { sources.cashuWallet }, - subscribedAccounts = { sources.account.subscribedAccounts }, cashuMintDirectoryFilterAssembler = { sources.cashuMintDirectory }, okHttpClientForMoney = roleBasedHttpClientBuilder::okHttpClientForMoney, contentResolverFn = { appContext.contentResolver }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b05ea7e504..05c855facf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -441,9 +441,6 @@ class Account( override val signer: NostrSigner, val geolocationFlow: () -> StateFlow, val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, - val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler, - /** Accounts with account-level subscriptions mounted — see [cashuWalletState]. */ - val subscribedAccounts: () -> StateFlow>, val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler, val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient, val otsResolverBuilder: () -> OtsResolver, @@ -743,8 +740,6 @@ class Account( signer = signer, cache = cache, scope = scope, - assembler = cashuWalletFilterAssembler(), - subscribedAccounts = subscribedAccounts(), outboxRelaysFlow = outboxRelays.flow, inboxRelaysFlow = notificationRelays.flow, dmRelaysFlow = dmRelays.flow, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 9fcbfd7e40..49b9004e68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -59,8 +59,6 @@ import java.io.File class AccountCacheState( val geolocationFlow: () -> StateFlow, val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, - val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler, - val subscribedAccounts: () -> StateFlow>, val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler, val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient, val contentResolverFn: () -> ContentResolver, @@ -267,8 +265,6 @@ class AccountCacheState( signer = signerWithClientTag, geolocationFlow = geolocationFlow, nwcFilterAssembler = nwcFilterAssembler, - cashuWalletFilterAssembler = cashuWalletFilterAssembler, - subscribedAccounts = subscribedAccounts, cashuMintDirectoryFilterAssembler = cashuMintDirectoryFilterAssembler, okHttpClientForMoney = okHttpClientForMoney, otsResolverBuilder = otsResolverBuilder, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index b2b2b01a61..e18dfbde9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -28,8 +28,6 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError -import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler -import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.Event @@ -102,13 +100,6 @@ class CashuWalletState( private val signer: NostrSigner, private val cache: LocalCache, private val scope: CoroutineScope, - private val assembler: CashuWalletFilterAssembler, - /** - * The accounts whose account-level subscriptions are mounted right now - * (`AccountFilterAssembler.subscribedAccounts`). The wallet follows it so it - * runs for exactly the accounts that pull from relays at all. - */ - private val subscribedAccounts: StateFlow>, private val outboxRelaysFlow: StateFlow>, private val inboxRelaysFlow: StateFlow>, private val dmRelaysFlow: StateFlow>, @@ -397,7 +388,6 @@ class CashuWalletState( // Lifecycle // ============================================================ private val jobs = mutableListOf() - private var currentSubscription: CashuWalletQueryState? = null @Volatile private var started = false @@ -475,35 +465,11 @@ class CashuWalletState( // our own kind:10019 — and another client may have published that // with relays unrelated to our NIP-65 lists — so we listen on the // union of those plus our NIP-65 inbox + DM relays. - jobs += - scope.launch(Dispatchers.IO) { - combine( - subscribedAccounts, - outboxRelaysFlow, - inboxRelaysFlow, - dmRelaysFlow, - _nutzapInfoEvent, - ) { subscribed, outbox, inbox, dm, info -> - // Only pull a wallet for an account that is actually subscribed: the - // one on screen, or one the user opted into keeping active in the - // background. Other accounts are held in memory purely so pushed - // gift wraps can be decrypted by their owner — asking relays for - // their wallet put a Wallet and a Nutzap Inbox subscription on the - // wire for every saved account, including accounts that have no - // wallet at all. - if (pubKey !in subscribed) { - null - } else { - CashuWalletQueryState( - pubkey = pubKey, - ownEventRelays = outbox, - inboxRelays = inbox + dm + (info?.relays() ?: emptyList()), - ) - } - }.collect { next -> - if (next == null) clearSubscription() else syncSubscription(next) - } - } + // The relay subscription for this wallet is NOT here. It lives in + // CashuWalletEoseManager, inside the account-level assembler group, so it mounts and + // unmounts with every other account-level loader instead of running for the whole life of + // the Account object. What stays below is wallet *state*: indexing what arrives, and the + // local bookkeeping around it. // Reactive incremental update: any new event arrival that matches our // pubkey + the NIP-60/61 kinds we care about gets indexed. @@ -558,29 +524,6 @@ class CashuWalletState( fun destroy() { jobs.forEach { it.cancel() } jobs.clear() - currentSubscription?.let { runCatching { assembler.unsubscribe(it) } } - currentSubscription = null - } - - // ============================================================ - // Subscription management - // ============================================================ - private fun clearSubscription() { - currentSubscription?.let { runCatching { assembler.unsubscribe(it) } } - currentSubscription = null - } - - private fun syncSubscription(next: CashuWalletQueryState) { - val previous = currentSubscription - if (next.ownEventRelays.isEmpty() && next.inboxRelays.isEmpty()) { - clearSubscription() - return - } - if (previous == next) return // unchanged - - previous?.let { runCatching { assembler.unsubscribe(it) } } - currentSubscription = next - assembler.subscribe(next) } // ============================================================ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 653878cd50..a213727a50 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler -import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssembler @@ -204,10 +203,6 @@ class RelaySubscriptionsCoordinator( // active when the wallet's on-chain transactions screen is on top. val onchainZaps = OnchainZapsFilterAssembler(client) - // active when a NIP-60 Cashu wallet exists for the account. - // Subscribes to kinds 17375/7375/7376/7374/10019 by author + inbound 9321 #p=self. - val cashuWallet = CashuWalletFilterAssembler(client) - // active while the user is browsing the NIP-87 mint picker. Subscribes to // kind:38172 cashu mint announcements + kind:38000 cashu-scoped // recommendations on the configured relay set. @@ -279,7 +274,6 @@ class RelaySubscriptionsCoordinator( chess, nwc, onchainZaps, - cashuWallet, cashuMintDirectory, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index 60c502975a..a5f530da31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -32,11 +32,10 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01No import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47WalletConnect.NwcNotificationsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletEoseManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow // This allows multiple screen to be listening to logged-in accounts. // @@ -107,6 +106,9 @@ class AccountFilterAssembler( notificationsHistory, // Live tail: NIP-47 wallet notifications (payment_received) on each connected wallet's own relay. NwcNotificationsEoseManager(client, ::preferredKeys), + // NIP-60 wallet + NIP-61 nutzap inbox. Mounted here rather than run from a collector + // inside CashuWalletState, so it starts and stops with every other account-level loader. + CashuWalletEoseManager(client, ::preferredKeys), MarmotGroupEventsEoseManager(client, ::preferredKeys), ) @@ -128,24 +130,7 @@ class AccountFilterAssembler( keys.firstOrNull { it.feedContentStates != null } ?: keys.first() } - private val subscribedAccountsInternal = MutableStateFlow>(emptySet()) - - /** - * The accounts whose always-on subscriptions are mounted right now, from - * either mount path: a screen's [AccountFilterAssemblerSubscription] or the - * headless [AccountSubscriptionRegistry]. - * - * This is the answer to "does this account currently pull from relays?", so - * account-scoped loaders that live outside this assembler — the Cashu wallet - * — can follow it instead of running for every [Account] object that happens - * to be loaded in memory. - */ - val subscribedAccounts = subscribedAccountsInternal.asStateFlow() - - override fun invalidateKeys() { - subscribedAccountsInternal.value = allKeys().mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex } - invalidateFilters() - } + override fun invalidateKeys() = invalidateFilters() override fun invalidateFilters() = group.forEach { it.invalidateFilters() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletEoseManager.kt new file mode 100644 index 0000000000..2142ffe504 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletEoseManager.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu + +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuWalletFilters +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +/** + * The account's NIP-60 wallet and NIP-61 nutzap inbox. + * + * This used to run from a collector inside `CashuWalletState`, on the account's own scope, which made + * the wallet the only account-level subscription whose lifetime was decided by the model rather than + * by a mount. It ran for every [com.vitorpamplona.amethyst.model.Account] object that happened to be + * resident — including accounts loaded purely so pushed gift wraps could be decrypted, which have no + * wallet anyone is looking at — and the attempt to fix that bolted a "is this pubkey subscribed + * anywhere" flow onto the model, so a model object was reading the relay layer's bookkeeping to + * decide whether to talk to relays. + * + * As a manager in [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler]'s + * group it starts and stops with every other account-level loader: the screen's mount for the account + * on show, the registry for the rest, and whatever the foreground/background rule becomes without this + * having to know about it. Exactly how NWC already worked. + * + * Per user, never merged: each account's wallet reads its own outbox for its own events and its own + * inbox for nutzaps addressed to it, so there is no shared query to fold them into. + */ +class CashuWalletEoseManager( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List { + val account = key.account + val wallet = account.cashuWalletState + + // NIP-65 outbox for our own wallet events; inbox + DM relays plus whatever our own kind:10019 + // advertises for inbound nutzaps, since another client may have published that with relays + // unrelated to our NIP-65 lists. + return cashuWalletFilters( + CashuWalletQueryState( + pubkey = account.userProfile().pubkeyHex, + ownEventRelays = account.outboxRelays.flow.value, + inboxRelays = + account.notificationRelays.flow.value + + account.dmRelays.flow.value + + (wallet.nutzapInfoEvent.value?.relays() ?: emptyList()), + ), + since, + ) + } + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + + // The relay sets and the nutzap-info event all move the query, so each one re-invalidates. + // Sampled because a relay-list edit can land as a burst of list events. + userJobMap[user] = + listOf( + key.account.outboxRelays.flow, + key.account.notificationRelays.flow, + key.account.dmRelays.flow, + ).map { flow -> + key.account.scope.launch(Dispatchers.IO) { + flow.sample(1000).collectLatest { invalidateFilters() } + } + } + + listOf( + key.account.scope.launch(Dispatchers.IO) { + key.account.cashuWalletState.nutzapInfoEvent + .sample(1000) + .collectLatest { invalidateFilters() } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap.remove(key)?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 28cda53204..9d372376ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -3004,12 +3004,6 @@ fun mockAccountViewModel(): AccountViewModel { signer = NostrSignerInternal(keyPair), geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, nwcFilterAssembler = { nwcFilters }, - cashuWalletFilterAssembler = { - com.vitorpamplona.amethyst.commons.relayClient.assemblers - .CashuWalletFilterAssembler(client) - }, - // A mock account is always "on screen", so its wallet renders in previews. - subscribedAccounts = { MutableStateFlow(setOf(keyPair.pubKey.toHexKey())) }, cashuMintDirectoryFilterAssembler = { com.vitorpamplona.amethyst.commons.relayClient.assemblers .CashuMintDirectoryFilterAssembler(client) @@ -3066,12 +3060,6 @@ fun mockVitorAccountViewModel(): AccountViewModel { signer = NostrSignerInternal(keyPair), geolocationFlow = { MutableStateFlow(LocationState.LocationResult.Loading) }, nwcFilterAssembler = { nwcFilters }, - cashuWalletFilterAssembler = { - com.vitorpamplona.amethyst.commons.relayClient.assemblers - .CashuWalletFilterAssembler(client) - }, - // A mock account is always "on screen", so its wallet renders in previews. - subscribedAccounts = { MutableStateFlow(setOf(keyPair.pubKey.toHexKey())) }, cashuMintDirectoryFilterAssembler = { com.vitorpamplona.amethyst.commons.relayClient.assemblers .CashuMintDirectoryFilterAssembler(client) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt index 9542c53a8b..28cf1d9a63 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt @@ -100,7 +100,7 @@ class CashuContext( */ suspend fun snapshot(): CashuWalletReader.WalletSnapshot { val pk = ctx.identity.pubKeyHex - // Mirror commons' CashuWalletFilterAssembler exactly: authored wallet + // Mirror commons' cashuWalletFilters exactly: authored wallet // kinds by authors=[pk], inbound nutzaps by #p — so amy projects the // same event set the Android app subscribes to. val authored = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt index 78fb9a191d..35ca044868 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt @@ -21,14 +21,10 @@ package com.vitorpamplona.amethyst.commons.relayClient.assemblers import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent @@ -63,8 +59,7 @@ data class CashuWalletQueryState( ) /** - * Subscribes to all NIP-60 / NIP-61 events that participate in this - * account's Cashu wallet: + * Every NIP-60 / NIP-61 filter for one account's Cashu wallet: * * - kind 17375 — the wallet event (replaceable) * - kind 7375 — unspent proofs (token events) @@ -73,102 +68,69 @@ data class CashuWalletQueryState( * - kind 10019 — nutzap info (replaceable, for incoming nutzaps) * - kind 9321 — inbound nutzaps tagged with the user's pubkey * - * All authored events are queried by `authors=[pubkey]`; nutzaps are queried by - * `#p=[pubkey]` (we receive them, not send them, from this filter's perspective). + * Authored events are queried by `authors=[pubkey]`; nutzaps by `#p=[pubkey]`, since we receive + * those rather than send them. + * + * A plain function rather than a subscription manager: the wallet's lifetime belongs to the account, + * so it is mounted by the account-level assembler alongside notifications, DMs and NWC, and this only + * has to describe the query. */ -@Stable -class CashuWalletFilterAssembler( - client: INostrClient, -) : ComposeSubscriptionManager() { - private val sub = CashuWalletSubAssembler(client, ::allKeys) +fun cashuWalletFilters( + key: CashuWalletQueryState, + since: SincePerRelayMap?, +): List { + val pubkey = key.pubkey + val ownEventRelays = key.ownEventRelays + val inboxRelays = key.inboxRelays + if (ownEventRelays.isEmpty() && inboxRelays.isEmpty()) return emptyList() - override fun invalidateFilters() = sub.invalidateFilters() + val ownedFilter = + ExplainedFilter( + purpose = SubPurpose.WALLET, + kinds = + listOf( + CashuWalletEvent.KIND, + CashuTokenEvent.KIND, + CashuSpendingHistoryEvent.KIND, + CashuMintQuoteEvent.KIND, + NutzapInfoEvent.KIND, + // NIP-87 mint recommendations the user has published. + // Pulled here (instead of relying on the general + // account filter) so the Cashu Settings screen can + // list and retract them without any extra subscription. + MintRecommendationEvent.KIND, + ), + authors = listOf(pubkey), + accountPubKeys = listOfNotNull(pubkey), + ) - override fun invalidateKeys() = invalidateFilters() + val inboundNutzapsFilter = + ExplainedFilter( + purpose = SubPurpose.NUTZAP_INBOX, + kinds = listOf(NutzapEvent.KIND), + tags = mapOf("p" to listOf(pubkey)), + accountPubKeys = listOfNotNull(pubkey), + ) - override fun destroy() = sub.destroy() -} - -private class CashuWalletSubAssembler( - client: INostrClient, - allKeys: () -> Set, -) : SingleSubEoseManager(client, allKeys, invalidateAfterEose = true) { - override fun distinct(key: CashuWalletQueryState): Any = key.pubkey - - /** - * One set of filters **per account**, never a merged one. - * - * [SingleSubEoseManager] hands over every distinct key, so with two wallets logged in this used - * to take `keys.first().pubkey` while pooling *both* accounts' relays — the second account's - * wallet was never subscribed, and its inbox relays were queried for the first account's - * nutzaps. Keeping each account's pubkey with its own relay sets is also what lets the - * subscription screen attribute these filters instead of piling them under "not attributed". - */ - override fun updateFilter( - keys: List, - since: SincePerRelayMap?, - ): List? { - if (keys.isEmpty()) return null - return keys.flatMap { filtersFor(it, since) }.ifEmpty { null } - } - - private fun filtersFor( - key: CashuWalletQueryState, - since: SincePerRelayMap?, - ): List { - val pubkey = key.pubkey - val ownEventRelays = key.ownEventRelays - val inboxRelays = key.inboxRelays - if (ownEventRelays.isEmpty() && inboxRelays.isEmpty()) return emptyList() - - val ownedFilter = - ExplainedFilter( - purpose = SubPurpose.WALLET, - kinds = - listOf( - CashuWalletEvent.KIND, - CashuTokenEvent.KIND, - CashuSpendingHistoryEvent.KIND, - CashuMintQuoteEvent.KIND, - NutzapInfoEvent.KIND, - // NIP-87 mint recommendations the user has published. - // Pulled here (instead of relying on the general - // account filter) so the Cashu Settings screen can - // list and retract them without any extra subscription. - MintRecommendationEvent.KIND, - ), - authors = listOf(pubkey), - accountPubKeys = listOfNotNull(pubkey), + // Own NIP-60 events are read from the user's outbox; inbound nutzaps + // from the user's inbox set. A relay that appears in both gets both + // filters. + val ownedSubs = + ownEventRelays.map { relay -> + val sinceTime = since?.get(relay)?.time + RelayBasedFilter( + relay, + if (sinceTime != null) ownedFilter.copy(since = sinceTime) else ownedFilter, ) - - val inboundNutzapsFilter = - ExplainedFilter( - purpose = SubPurpose.NUTZAP_INBOX, - kinds = listOf(NutzapEvent.KIND), - tags = mapOf("p" to listOf(pubkey)), - accountPubKeys = listOfNotNull(pubkey), + } + val inboundSubs = + inboxRelays.map { relay -> + val sinceTime = since?.get(relay)?.time + RelayBasedFilter( + relay, + if (sinceTime != null) inboundNutzapsFilter.copy(since = sinceTime) else inboundNutzapsFilter, ) + } - // Own NIP-60 events are read from the user's outbox; inbound nutzaps - // from the user's inbox set. A relay that appears in both gets both - // filters. - val ownedSubs = - ownEventRelays.map { relay -> - val sinceTime = since?.get(relay)?.time - RelayBasedFilter( - relay, - if (sinceTime != null) ownedFilter.copy(since = sinceTime) else ownedFilter, - ) - } - val inboundSubs = - inboxRelays.map { relay -> - val sinceTime = since?.get(relay)?.time - RelayBasedFilter( - relay, - if (sinceTime != null) inboundNutzapsFilter.copy(since = sinceTime) else inboundNutzapsFilter, - ) - } - - return ownedSubs + inboundSubs - } + return ownedSubs + inboundSubs } From e3e17e8ecb470034e31429b33302caf0a812f7bf Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 18:54:46 -0400 Subject: [PATCH 043/227] fix(notifications): stop resetting the watchdog alarm on every account change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service layers were being re-enabled level-triggered. Before this branch the inner flow was a Boolean behind distinctUntilChanged, so enableServiceLayers() ran once per real transition. It now carries (all accounts, participating accounts), and Account has no equals — so the flow re-emits whenever the account map changes identity, and the outer collector restarts on every foreground/background crossing, which includes every screen-off. That matters because ServiceWatchdogManager.schedule() calls setInexactRepeating with FLAG_UPDATE_CURRENT and a first fire of `elapsedRealtime() + 5min`. Every call replaces the alarm and pushes that first fire out again. Screen-on happens far more often than every five minutes, so L5 — the layer whose entire job is noticing a dead service and restarting it — would effectively never have fired. NotificationCatchUpWorker was unaffected: it enqueues with ExistingPeriodicWorkPolicy.KEEP, so repeat calls leave the existing period alone. Enable/disable is edge-triggered again, explicitly this time rather than as a side effect of what the upstream flow happens to emit. Also drops ActiveSubscriptionsState.busiestPurposeFilters, which has no readers — the cards draw their share against attributedFilters. Verified on emulator-5554: after a cold start, HOME, and return, the foreground service is still running and the watchdog alarm is still registered. Co-Authored-By: Claude Opus 5 (1M context) --- .../AlwaysOnNotificationServiceManager.kt | 17 +++++++++++------ .../ActiveSubscriptionsViewModel.kt | 5 +---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt index d2bcd9d240..30b3add1dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt @@ -149,12 +149,17 @@ class AlwaysOnNotificationServiceManager( // The service layers are a background concern, so they stay tied to // the master switch and to somebody having opted in. A foreground-only // account must not start a foreground service that outlives the screen. - if (masterEnabled && participating.isNotEmpty()) { - wasEnabled = true - enableServiceLayers() - } else if (wasEnabled) { - disableServiceLayers() - wasEnabled = false + // + // Edge-triggered, deliberately. This flow re-emits whenever the account + // map changes identity or the app crosses foreground — far more often + // than the old boolean did — and ServiceWatchdogManager.schedule() + // replaces its alarm with one starting `now + 5min`. Calling it on every + // emission pushed the watchdog's first fire past every screen-on, so the + // layer that exists to restart a dead service would never have run. + val shouldRun = masterEnabled && participating.isNotEmpty() + if (shouldRun != wasEnabled) { + if (shouldRun) enableServiceLayers() else disableServiceLayers() + wasEnabled = shouldRun } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt index 2a34e09b2d..a5db379f0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt @@ -136,10 +136,7 @@ data class ActiveSubscriptionsState( * overstate every card, which is the mistake the per-entity rows already taught once. */ val attributedFilters: Int = 0, -) { - /** The largest purpose, so every card can draw its share against a common scale. */ - val busiestPurposeFilters: Int = accounts.flatMap { it.purposes }.maxOfOrNull { it.filterCount } ?: 0 -} +) class ActiveSubscriptionsViewModel : ViewModel() { private val _state = MutableStateFlow(ActiveSubscriptionsState()) From b8d0caa8ff6cfa1cbe54b5204f39f048d2127a68 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 19:25:26 -0400 Subject: [PATCH 044/227] fix(commons): import kotlinx.coroutines.IO so the iOS targets build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Dispatchers.IO` is an internal member in common code; the public form on Kotlin/Native is the `kotlinx.coroutines.IO` extension property. Without that import the member resolves on JVM and Android and fails only on iOS, which is precisely what commons' compile-only iOS spike exists to surface. MergedTopFeedAuthorListsState moved into commonMain in 3f4723437e and was the one file that came across without the import — the other 22 commonMain users of Dispatchers.IO already have it, and the only other three files that mention it do so in comments. One error, one file, one missing import. Reproduced locally with :commons:compileKotlinIosSimulatorArm64 before fixing; both iOS targets, JVM, Android and the full test suite pass after. Co-Authored-By: Claude Opus 5 (1M context) --- .../model/topNavFeeds/MergedTopFeedAuthorListsState.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/MergedTopFeedAuthorListsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/MergedTopFeedAuthorListsState.kt index 670930f6c2..20c07e514c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/MergedTopFeedAuthorListsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/MergedTopFeedAuthorListsState.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -100,6 +101,9 @@ class MergedTopFeedAuthorListsState( notificationNavFilter.value, ), ) + // `kotlinx.coroutines.IO` must stay imported: `Dispatchers.IO` is an internal member in + // common, and the public form on Kotlin/Native is that extension. Drop the import and this + // still builds on JVM/Android, failing only the iOS targets. }.flowOn(Dispatchers.IO) .stateIn( scope, From 5fe33a815247c1a1e5edc041759a2e5d95f6dd3b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 19:36:28 -0400 Subject: [PATCH 045/227] fix(relays): serialize writes to the single-sub EOSE cursor map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EOSERelayList backs SingleSubEoseManager with a plain mutableMapOf, and addOrUpdate is called from SubscriptionListener callbacks — i.e. from each relay's own socket-reader thread. A client holding a few hundred relays therefore had that many concurrent writers to one unsynchronized map. EOSEAccountFast wraps its lists in a lock for exactly this reason; a bare list handed to SingleSubEoseManager had nothing. The race predates this branch but the branch made it load-bearing: both merged managers (notifications and account metadata, across every logged-in account and every relay they read) now run through SingleSubEoseManager, and the EOSE refetch fix added a second writer in remove(). Writes are serialized with KmpLock, the same primitive ComposeSubscriptionManager uses. Reads still go through the live map from since(), deliberately — but the two merged managers no longer depend on that. They were reading `since` immediately after clearing a relay and relying on the mutation being visible through it, so hardening since() into a snapshot later would have silently disabled the refetch with no test to notice. Growth now zeroes the cursor for that pass explicitly, in addition to clearing it. Verified: both iOS targets, JVM, Android, desktop and the full suite build and pass; a cold start on emulator-5554 shows no fatal exceptions, no ConcurrentModificationException, and 0 nos.lol refusals. Co-Authored-By: Claude Opus 5 (1M context) --- .../metadata/AccountMetadataEoseManager.kt | 7 +++-- ...NotificationsEoseFromInboxRelaysManager.kt | 10 ++++--- .../amethyst/commons/relays/EOSERelayList.kt | 29 ++++++++++++++----- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt index 9abf466e80..283ee421e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt @@ -73,9 +73,12 @@ class AccountMetadataEoseManager( // the earlier accounts earned — it would never ask for its own profile, follows or lists. // This matters more here than for notifications: there is no backward pager to rescue it, // so the account would go without until the next launch cleared the in-memory cursor. - if (authorsPerRelay.gainedAuthors(relay, pubkeys)) clearEoseFor(relay) + // Drop the stored cursor AND ignore it for this pass, so the refetch does not depend on + // `since` being a live view of the map we just mutated. + val gained = authorsPerRelay.gainedAuthors(relay, pubkeys) + if (gained) clearEoseFor(relay) - val relaySince = since?.get(relay)?.time + val relaySince = if (gained) null else since?.get(relay)?.time // The account-switcher avatars: other logged-in accounts this screen wants to name. // Screens supply them; the background registry does not, so this is usually empty. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index 9b8342508e..e01043fe6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -92,9 +92,11 @@ class AccountNotificationsEoseFromInboxRelaysManager( val pubkeys = accounts.map { it.account.userProfile().pubkeyHex } // An account that joins a relay this subscription already covers would otherwise inherit - // the cursor the earlier accounts earned, and never ask for anything older than it. - // Dropping the cursor first means `since` below reads null and the merged filter refetches. - if (authorsPerRelay.gainedAuthors(relay, pubkeys)) clearEoseFor(relay) + // the cursor the earlier accounts earned, and never ask for anything older than it. Drop + // the stored cursor AND ignore it for this pass — not just the former, which would leave + // the refetch depending on `since` being a live view of the map we just mutated. + val gained = authorsPerRelay.gainedAuthors(relay, pubkeys) + if (gained) clearEoseFor(relay) // A cold-start floor, NOT paging — backward paging lives in // [AccountNotificationsHistoryEoseManager]. Read only when a relay has no EOSE yet; once it @@ -119,7 +121,7 @@ class AccountNotificationsEoseFromInboxRelaysManager( // could never rescue it — it only arms once the feed holds a full page, and the feed // could not fill because the query only ever asked for a week. A fresh install of an // established account hit the same deadlock. - val notificationSince = since?.get(relay)?.time ?: pagingBoundary + val notificationSince = (if (gained) null else since?.get(relay)?.time) ?: pagingBoundary // NIP-29 group activity (reactions/replies to my messages) is deliberately NOT requested // here. It lives on the group's host relay and used to be one `#h` filter per relay carrying diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt index 53c7aadc3c..dc831f3db6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.relays +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl typealias SincePerRelayMap = MutableMap @@ -31,10 +33,21 @@ typealias SincePerRelayMap = MutableMap class EOSERelayList { var relayList: SincePerRelayMap = mutableMapOf() + /** + * Writers are serialized because they are not all on one thread: [addOrUpdate] runs on each + * relay's own socket-reader thread as EOSE frames land, so a client holding a few hundred relays + * has that many potential writers to one plain map. [EOSEAccountFast] wraps its lists in a lock + * for the same reason; a bare list handed to [SingleSubEoseManager] had none. + * + * Reads still go through the map returned by [since] — deliberately live rather than a snapshot, + * since callers clear a relay and then re-read it inside one assembly pass. + */ + private val lock = KmpLock() + fun addOrUpdate( relayUrl: NormalizedRelayUrl, time: Long, - ) { + ) = lock.withLock { val eose = relayList[relayUrl] if (eose == null) { relayList[relayUrl] = MutableTime(time) @@ -43,9 +56,10 @@ class EOSERelayList { } } - fun clear() { - relayList = mutableMapOf() - } + fun clear() = + lock.withLock { + relayList = mutableMapOf() + } /** * Forgets one relay's cursor, so the next filter built for it asks from scratch. @@ -54,9 +68,10 @@ class EOSERelayList { * that starts covering another account has already-EOSE'd relays whose `since` would silence * exactly the history the new account still needs. */ - fun remove(relayUrl: NormalizedRelayUrl) { - relayList.remove(relayUrl) - } + fun remove(relayUrl: NormalizedRelayUrl) = + lock.withLock { + relayList.remove(relayUrl) + } fun since() = relayList From 9fe24be6d6eabb81ffed0d71c7b13711ee0c9d61 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 19:47:50 -0400 Subject: [PATCH 046/227] perf(relays): keep the EOSE cursor lock-free on the per-event path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit guarded EOSERelayList with a lock, which put the wrong path under it. addOrUpdate runs on **every live event** — SubscriptionListener.onEvent calls newEose for each one, hundreds per second across a few hundred relays — so that was one monitor for every event arriving in the app. Almost none of those events change the map. MutableTime exists precisely so a known relay only bumps a Long inside its own entry; the map is structurally written on the *first* frame from a relay, plus remove() and clear(). A couple of hundred writes for the lifetime of the process, against millions of reads. So the map is copy-on-write behind @Volatile: replaced wholesale under the lock, never mutated in place after publication. Readers take nothing. The per-event bump takes nothing. Only a relay's first frame pays, and it pays a map copy of a few hundred entries, once. The bump itself stays unsynchronized, which is deliberate: two socket threads racing updateIfNewer can leave the older timestamp, and this value is a floor for `since`, so losing a millisecond re-asks for a couple of events rather than skipping any. Documented on the method rather than fixed with an atomic that would cost a barrier per event. Verified: iOS, JVM, Android, desktop and the full suite build and pass. Cold start on emulator-5554 — no fatal exceptions, no ConcurrentModificationException, 0 nos.lol refusals, 182 relays connected and the purpose breakdown populated. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/commons/relays/EOSERelayList.kt | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt index dc831f3db6..ca22c91182 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.relays import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.concurrent.Volatile typealias SincePerRelayMap = MutableMap @@ -30,29 +31,51 @@ typealias SincePerRelayMap = MutableMap * Tracks EOSE (End Of Stored Events) timestamps for relay subscriptions. * Used by UserCardsCache and similar classes to manage relay subscription state. */ -class EOSERelayList { - var relayList: SincePerRelayMap = mutableMapOf() +class EOSERelayList { /** - * Writers are serialized because they are not all on one thread: [addOrUpdate] runs on each - * relay's own socket-reader thread as EOSE frames land, so a client holding a few hundred relays - * has that many potential writers to one plain map. [EOSEAccountFast] wraps its lists in a lock - * for the same reason; a bare list handed to [SingleSubEoseManager] had none. + * Copy-on-write: replaced wholesale under [lock], never mutated in place after publication, so + * readers need no synchronization at all. * - * Reads still go through the map returned by [since] — deliberately live rather than a snapshot, - * since callers clear a relay and then re-read it inside one assembly pass. + * The access pattern makes this nearly free. [addOrUpdate] runs on **every live event** — hundreds + * per second across a few hundred relays — but an event from a relay already in the map only bumps + * the `Long` inside its own [MutableTime]. The map itself is only ever written on the *first* frame + * from a relay, plus [remove] and [clear]: a couple of hundred writes for the lifetime of the + * process. Locking the common path would have put every one of those events through one monitor + * for no structural change at all. */ + @Volatile + var relayList: SincePerRelayMap = mutableMapOf() + private set + + /** Guards the rare structural writes only. Readers and the per-event bump never take it. */ private val lock = KmpLock() fun addOrUpdate( relayUrl: NormalizedRelayUrl, time: Long, - ) = lock.withLock { - val eose = relayList[relayUrl] - if (eose == null) { - relayList[relayUrl] = MutableTime(time) - } else { - eose.updateIfNewer(time) + ) { + // Hot path: the relay is already known, so nothing about the map changes. + // + // The bump itself is unsynchronized. Two socket threads racing it can leave the older of two + // timestamps, because `updateIfNewer` reads-compares-writes without a lock — which is harmless + // here: this value is a floor for `since`, so losing a millisecond re-asks for a couple of + // events rather than skipping any. + val existing = relayList[relayUrl] + if (existing != null) { + existing.updateIfNewer(time) + return + } + + // Rare: first frame from this relay. Re-check inside the lock, since another thread may have + // inserted it between the read above and here. + lock.withLock { + val current = relayList[relayUrl] + if (current != null) { + current.updateIfNewer(time) + } else { + relayList = relayList.toMutableMap().apply { put(relayUrl, MutableTime(time)) } + } } } @@ -70,7 +93,9 @@ class EOSERelayList { */ fun remove(relayUrl: NormalizedRelayUrl) = lock.withLock { - relayList.remove(relayUrl) + if (relayList.containsKey(relayUrl)) { + relayList = relayList.toMutableMap().apply { remove(relayUrl) } + } } fun since() = relayList From 0b77de879d9aa3aa2d916c199a19db0d78104b8f Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:55:51 +0000 Subject: [PATCH 047/227] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-ar-rSA/strings.xml | 10 +++ .../src/main/res/values-bn-rBD/strings.xml | 10 +++ amethyst/src/main/res/values-cs/strings.xml | 28 ++++-- .../src/main/res/values-de-rDE/strings.xml | 28 ++++-- .../src/main/res/values-el-rGR/strings.xml | 10 +++ .../src/main/res/values-en-rGB/strings.xml | 10 +++ .../src/main/res/values-eo-rUY/strings.xml | 10 +++ .../src/main/res/values-es-rES/strings.xml | 10 +++ .../src/main/res/values-es-rMX/strings.xml | 10 +++ .../src/main/res/values-es-rUS/strings.xml | 10 +++ .../src/main/res/values-fa-rIR/strings.xml | 10 +++ .../src/main/res/values-fi-rFI/strings.xml | 10 +++ .../src/main/res/values-fr-rCA/strings.xml | 10 +++ .../src/main/res/values-fr-rFR/strings.xml | 10 +++ .../src/main/res/values-hi-rIN/strings.xml | 10 +++ .../src/main/res/values-hu-rHU/strings.xml | 10 +++ .../src/main/res/values-in-rID/strings.xml | 10 +++ .../src/main/res/values-it-rIT/strings.xml | 10 +++ .../src/main/res/values-ja-rJP/strings.xml | 10 +++ .../src/main/res/values-ko-rKR/strings.xml | 10 +++ .../src/main/res/values-lv-rLV/strings.xml | 10 +++ .../src/main/res/values-nl-rNL/strings.xml | 10 +++ .../src/main/res/values-pl-rPL/strings.xml | 90 ++++++++++++++++++- .../src/main/res/values-pt-rBR/strings.xml | 28 ++++-- .../src/main/res/values-pt-rPT/strings.xml | 10 +++ .../src/main/res/values-ru-rRU/strings.xml | 10 +++ .../src/main/res/values-ru-rUA/strings.xml | 10 +++ .../src/main/res/values-sl-rSI/strings.xml | 10 +++ .../src/main/res/values-sr-rSP/strings.xml | 10 +++ .../src/main/res/values-sv-rSE/strings.xml | 28 ++++-- amethyst/src/main/res/values-sw/strings.xml | 10 +++ .../src/main/res/values-ta-rIN/strings.xml | 10 +++ .../src/main/res/values-th-rTH/strings.xml | 10 +++ .../src/main/res/values-tr-rTR/strings.xml | 10 +++ .../src/main/res/values-uk-rUA/strings.xml | 10 +++ .../src/main/res/values-uz-rUZ/strings.xml | 10 +++ .../src/main/res/values-vi-rVN/strings.xml | 10 +++ .../src/main/res/values-zh-rCN/strings.xml | 10 +++ .../src/main/res/values-zh-rHK/strings.xml | 10 +++ .../src/main/res/values-zh-rSG/strings.xml | 10 +++ .../src/main/res/values-zh-rTW/strings.xml | 10 +++ 41 files changed, 525 insertions(+), 37 deletions(-) diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index b86269bf3a..843b04b0cf 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -1339,6 +1339,16 @@ متصل بـ %1$d relay متصل بـ %1$d relay + + + + جارٍ الاتصال بـ relay صندوق الوارد… خدمة الإشعارات الدائمة تحافظ على اتصال دائم بـ relay صندوق الوارد الخاص بك لضمان تسليم الإشعارات فورًا. تُظهر إشعارًا مستمرًا. تستهلك بطارية أكثر لكنها تضمن عدم فوات أي رسالة. diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index b4d61c2faf..2e9f264e1e 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -1282,6 +1282,16 @@ %1$d টি relay-তে সংযুক্ত %1$d টি relay-তে সংযুক্ত + + + + ইনবক্স relays-এ সংযুক্ত হচ্ছে… সর্বদা-চালু বিজ্ঞপ্তি সেবা তাৎক্ষণিক বিজ্ঞপ্তি প্রদানের জন্য আপনার ইনবক্স relays-এর সাথে স্থায়ী সংযোগ বজায় রাখে। একটি চলমান বিজ্ঞপ্তি দেখায়। বেশি ব্যাটারি ব্যবহার করে কিন্তু নিশ্চিত করে যে কোনো বার্তা মিস হবে না। diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 36092ba04d..876d263166 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -2022,6 +2022,16 @@ Připojeno k %1$d relay Připojeno k %1$d relays + + + + Připojování k inbox relayím\u2026 Služba trvalých oznámení Udržuje trvalé připojení k vašim inbox relayím pro okamžité doručování oznámení. Zobrazuje průběžné oznámení. Spotřebovává více baterie, ale zajišťuje, že nezmeškáte žádnou zprávu. @@ -2303,8 +2313,10 @@ Lokace-Exkluzívní příspěvek Uvidí to pouze následovníci umístění. Tvoji obecní následovníci to neuvidí. Hashtag-exkluzivní příspěvek + Externí obsah Komentář k webové stránce Komentář k externímu zdroji + Otevřít v prohlížeči %1$d min čtení %1$d stopa @@ -2440,6 +2452,7 @@ Vytvořit skupinu Nový kanál + Nové fórum Soukromý kanál Skrytý ze seznamu kanálů a jen na pozvání. Vypnuto znamená, že ho může najít a připojit se k němu kdokoli na tomto relayi. Fórový kanál @@ -2482,6 +2495,8 @@ Smazat „%1$s“? Odstraní to skupinu a její historii pro všechny a nelze to vrátit zpět. Smazat kanál Smazat „%1$s“? Odstraní to kanál a jeho zprávy pro všechny a nelze to vrátit zpět. + Archivovat kanál + Zrušit archivaci kanálu Vlákna Připnout zprávu Odepnout zprávu @@ -2634,6 +2649,9 @@ Odeslat… Nová zpráva Nové zvýraznění + Nový obrázek + Nové krátké video + Nové video Nové zvýraznění Zvýrazněný text Co vás zaujalo? @@ -3416,6 +3434,7 @@ Zdá se, že zatím nejste členem žádného kanálu na tomto relayi. Nejprve přijměte pozvánku do pracovního prostoru v prohlížeči a pak to zkuste znovu. Kanály Fóra + Archivované Pracuji… Přidat člena Přidat lidi do tohoto pracovního prostoru @@ -4792,13 +4811,4 @@ URL avataru (volitelné) Publikování… Publikovat personu - Archivovat kanál - Zrušit archivaci kanálu - Nové fórum - Externí obsah - Archivované - Nový obrázek - Nové krátké video - Nové video - Otevřít v prohlížeči diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 8bf354d3b0..4b96c7eddc 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1940,6 +1940,16 @@ Mit %1$d Relay verbunden Mit %1$d Relays verbunden + + + + Verbinde mit Inbox-Relays\u2026 Dauerhafter Benachrichtigungsdienst Hält eine dauerhafte Verbindung zu deinen Inbox-Relays für sofortige Benachrichtigungen aufrecht. Zeigt eine fortlaufende Benachrichtigung an. Verbraucht mehr Akku, stellt aber sicher, dass du keine Nachricht verpasst. @@ -2216,8 +2226,10 @@ Standort-exklusiver Beitrag Nur Follower des Ortes werden es sehen. Deine allgemeinen Follower werden es nicht sehen. Hashtag-exklusive Beitrag + Externe Inhalte Eine Webseite kommentieren Eine externe Ressource kommentieren + Im Browser öffnen %1$d Min. Lesezeit %1$d Titel @@ -2349,6 +2361,7 @@ Eine Gruppe erstellen Neuer Kanal + Neues Forum Privater Kanal Aus der Kanalliste ausgeblendet und nur mit Einladung. Aus bedeutet, dass jeder auf diesem Relay ihn finden und beitreten kann. Forumskanal @@ -2391,6 +2404,8 @@ „%1$s“ löschen? Damit werden die Gruppe und ihr Verlauf für alle entfernt, und das lässt sich nicht rückgängig machen. Kanal löschen „%1$s“ löschen? Damit werden der Kanal und seine Nachrichten für alle entfernt, und das lässt sich nicht rückgängig machen. + Kanal archivieren + Kanal aus dem Archiv nehmen Threads Nachricht anheften Nachricht loslösen @@ -2533,6 +2548,9 @@ Senden an… Neue Nachricht Neues Highlight + Neues Bild + Neuer Kurzfilm + Neues Video Neues Highlight Hervorgehobener Text Was ist dir aufgefallen? @@ -3295,6 +3313,7 @@ Du scheinst noch in keinem Kanal auf diesem Relay Mitglied zu sein. Nimm zuerst die Workspace-Einladung im Browser an und versuche es dann erneut. Kanäle Foren + Archiviert Arbeitet… Mitglied hinzufügen Personen zu diesem Workspace hinzufügen @@ -4616,13 +4635,4 @@ Avatar-URL (optional) Wird veröffentlicht… Persona veröffentlichen - Kanal archivieren - Kanal aus dem Archiv nehmen - Neues Forum - Externe Inhalte - Archiviert - Neues Bild - Neuer Kurzfilm - Neues Video - Im Browser öffnen diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index 0b5cde1064..d76b3df599 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -1269,6 +1269,16 @@ Συνδέθηκε σε %1$d relay Συνδέθηκε σε %1$d relays + + + + Σύνδεση στα relays εισερχομένων… Υπηρεσία ειδοποιήσεων πάντα ενεργή Διατηρεί μόνιμη σύνδεση στα relays εισερχομένων σας για άμεση παράδοση ειδοποιήσεων. Εμφανίζει μια συνεχή ειδοποίηση. Καταναλώνει περισσότερη μπαταρία αλλά εξασφαλίζει ότι δεν θα χάσετε ποτέ κανένα μήνυμα. diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index a6b528ccc5..f6c7b88637 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -52,6 +52,16 @@ + + + + + + + Konektante al enirkestaj relay-oj… Ĉiam-aktiva sciigservico Konservas daŭrantan konekton al viaj enirkestaj relay-oj por tuja sciiglivero. Montras daŭrantan sciigon. Uzas pli da baterio sed certigas ke vi neniam maltrafas mesaĝon. diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index beec0f8462..ceb0b432a6 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -1308,6 +1308,16 @@ Conectado a %1$d relay Conectado a %1$d relays + + + + Conectando a los relays de bandeja de entrada… Servicio de notificaciones permanente Mantiene una conexión persistente a tus relays de bandeja de entrada para la entrega instantánea de notificaciones. Muestra una notificación en curso. Consume más batería, pero garantiza que nunca te pierdas un mensaje. diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 89adb0a64e..72f2707eb9 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -1300,6 +1300,16 @@ Conectado a %1$d relay Conectado a %1$d relays + + + + Conectando a los relays de bandeja de entrada… Servicio de notificaciones permanente Mantiene una conexión persistente a tus relays de bandeja de entrada para la entrega instantánea de notificaciones. Muestra una notificación en curso. Consume más batería, pero garantiza que nunca te pierdas un mensaje. diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index faee24e949..87e79a9dc5 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -1300,6 +1300,16 @@ Conectado a %1$d relay Conectado a %1$d relays + + + + Conectando a los relays de bandeja de entrada… Servicio de notificaciones permanente Mantiene una conexión persistente a tus relays de bandeja de entrada para la entrega instantánea de notificaciones. Muestra una notificación en curso. Consume más batería, pero garantiza que nunca te pierdas un mensaje. diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index b275cc477a..20abe2bb9c 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -1287,6 +1287,16 @@ متصل به %1$d relay متصل به %1$d relay + + + + در حال اتصال به relay های صندوق ورودی… سرویس اعلان همیشه فعال یک اتصال دائمی به relay های صندوق ورودی شما برای تحویل فوری اعلان‌ها حفظ می‌کند. یک اعلان مداوم نمایش می‌دهد. مصرف باتری بیشتری دارد اما اطمینان می‌دهد هیچ پیامی از دست نمی‌رود. diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index 4a70108a1e..5886c57ddf 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -1270,6 +1270,16 @@ Yhdistetty %1$d relayhin Yhdistetty %1$d relayhin + + + + Yhdistetään inbox-relayihin… Jatkuvasti päällä oleva ilmoituspalvelu Pitää pysyvän yhteyden inbox-relayihisi välittömän ilmoituksen toimitusta varten. Näyttää jatkuvan ilmoituksen. Kuluttaa enemmän akkua, mutta varmistaa että et koskaan missaa viestiä. diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index bdac0a843e..c31207506d 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -1240,6 +1240,16 @@ Connecté à %1$d relais Connecté à %1$d relais + + + + Connexion aux relais de boîte de réception… Service de notification permanent Maintient une connexion persistante à vos relais de boîte de réception pour une livraison instantanée des notifications. Affiche une notification permanente. Consomme plus de batterie mais garantit que vous ne manquez aucun message. diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index a521ace923..c39b243578 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -1433,6 +1433,16 @@ Connecté à %1$d relais Connecté à %1$d relais + + + + Connexion aux relais de boîte de réception… Service de notification permanent Maintient une connexion persistante à vos relais de boîte de réception pour une livraison instantanée des notifications. Affiche une notification permanente. Consomme plus de batterie mais garantit que vous ne manquez aucun message. diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index f8fc98c291..43aed37ee7 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1940,6 +1940,16 @@ संयोजित %1$d पुनःप्रसारक के साथ संयोजित %1$d पुनःप्रसारकों के साथ + + + + आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026 सदैव सक्रिय सूचना सेवा अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे। diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 02e5e3a33e..5c65609348 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -1612,6 +1612,16 @@ Kapcsolódva %1$d átjátszóhoz Kapcsolódva %1$d átjátszóhoz + + + + Kapcsolódás a beérkező üzenetátjátszókhoz\u2026 Folyamatos értesítési szolgáltatás Folyamatos kapcsolatot tart fenn a beérkező üzenetek átjátszóival az értesítések azonnali kézbesítése érdekében. Megjeleníti a folyamatban lévő értesítéseket. Több akkumulátort fogyaszt, de így biztosan nem marad le egyetlen üzenetről sem. diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index f1b0aedb82..e20b2e6b83 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -1241,6 +1241,16 @@ Terhubung ke %1$d relay + + + + Menghubungkan ke relay inbox… Layanan notifikasi selalu aktif Menjaga koneksi persisten ke relay inbox Anda untuk pengiriman notifikasi instan. Menampilkan notifikasi yang terus berjalan. Menggunakan lebih banyak baterai tetapi memastikan Anda tidak pernah melewatkan pesan. diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index 31d02be74e..9bd08310b9 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -1254,6 +1254,16 @@ Connesso a %1$d relay Connesso a %1$d relay + + + + Connessione ai relay inbox in corso… Servizio di notifica sempre attivo Mantiene una connessione persistente ai tuoi relay inbox per la consegna istantanea delle notifiche. Mostra una notifica persistente. Consuma più batteria ma garantisce di non perdere mai un messaggio. diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index 3fd4b50ab8..a1bcb9d1f5 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -1269,6 +1269,16 @@ %1$d 件の relay に接続中 + + + + インボックス relay に接続中… 常時接続通知サービス インボックス relay への永続的な接続を維持し、通知をすぐに届けます。継続中の通知が表示されます。バッテリー消費は増えますが、メッセージを見逃しません。 diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index 9e0c601b33..11a37899b8 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -1261,6 +1261,16 @@ %1$d개의 relay에 연결됨 + + + + inbox relay에 연결 중… 상시 알림 서비스 즉각적인 알림 전달을 위해 inbox relay에 지속적인 연결을 유지합니다. 진행 중인 알림이 표시됩니다. 배터리를 더 많이 사용하지만 메시지를 놓치지 않도록 합니다. diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index e0570e2c09..321277ef41 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -1288,6 +1288,16 @@ Savienots ar %1$d relay Savienots ar %1$d relay + + + + Savienojas ar iesūtnes relay… Vienmēr aktīvs paziņojumu pakalpojums Uztur pastāvīgu savienojumu ar jūsu iesūtnes relay tūlītējai paziņojumu piegādei. Rāda pastāvīgu paziņojumu. Izmanto vairāk akumulatora, bet nodrošina, ka nepalaidīsiet garām nevienu ziņu. diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 22a28884e1..0006542c2a 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -1927,6 +1927,16 @@ Verbonden met %1$d relay Verbonden met %1$d relays + + + + Verbinden met inbox-relays… Altijd-aan meldingsdienst Houdt een persistente verbinding met je inbox-relays voor directe melding. Toont een permanente notificatie. Gebruikt meer batterij maar zorgt dat je nooit een bericht mist. diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 98950f5e02..9ee423c874 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -2022,6 +2022,16 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Podłączono do %1$d transmiterów Podłączono do %1$d transmiterów + + + + Łączenie z transmiterami odbiorczymi\u2026 Usługa powiadomień zawsze włączona Utrzymuje stałe połączenie z transmiterami odbiorczymi, aby zapewnić natychmiastowe dostarczanie powiadomień. Wyświetla bieżące powiadomienia. Zużywa więcej baterii, ale gwarantuje, że nigdy nie przegapisz żadnej wiadomości. @@ -2236,7 +2246,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Czaty efemeryczne Interaktywne opowieści Szachy - Świadectwa + Obserwacje ptaków + Certyfikaty Szkice NIP Muzyka & audio Podcasty @@ -2302,8 +2313,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Post ekskluzywny dla lokalizacji Tylko obserwatorzy z twojej lokalizacji to zobaczą. Twoi ogólni obserwatorzy nie zobaczą tego. Wpis hasztagowy + Treści zewnętrzne Skomentuj na stronie internetowej Skomentuj na zewnętrznym serwisie + Otwórz w przeglądarce Czas czytania: %1$d min. %1$d utwór @@ -2637,6 +2650,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Wyślij do… Nowa wiadomość Nowe wyróżnienie + Nowe zdjęcie + Nowy filmik + Nowe wideo Nowe wyróżnienie Wyróżniony tekst Co szczególnie zwróciło twoją uwagę? @@ -3339,8 +3355,20 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest %1$s: %2$s + 🔊 %1$s rozpoczął/a naradę + 🔊 %1$s dołączył do zgromadzenia + 🔊 %1$s opuścił zgromadzenie + 🔊 Zebranie zakończone + ⚙️ %1$s zapytał o pracę + ⚙️ %1$s zaakceptował ofertę + ⚙️ Postęp pracy + ⚙️ Wynik pracy + ⚙️ Praca anulowana + ⚠️ Błąd Zadania + ◆ %1$s głosował na post + ◆ %1$s ocenił post negatywnie Płótno Żadne płótno nie zostało jeszcze udostępnione w tym projekcie. Edytuj płótno @@ -3382,6 +3410,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Przestrzeń robocza Do Dodaj kogoś (npub lub hex) + Szukaj wg nazwy, NIP-05 lub npub + Nie znaleziono żadnych wyników. Spróbuj wpisać inne imię i nazwisko, adres NIP-05 lub wklej numer npub. Rozpocznij konwersację Otwieranie… Usuń @@ -3405,6 +3435,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Wygląda na to, że nie jesteś jeszcze członkiem żadnego kanału w tym transmiterze. Najpierw zaakceptuj zaproszenie do przestrzeni roboczej w przeglądarce, a następnie spróbuj ponownie. Kanały Fora + Zarchiwizowane Pracuję… Dodaj członka Dodaj osoby do tej przestrzeni roboczej @@ -3417,6 +3448,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Przypnij kanał Odepnij kanał Brak konwersacji + + %1$d ukryta rozmowa + %1$d ukrytych rozmów + %1$d ukrytych rozmów + %1$d ukryte rozmowy + Zobacz %1$d rozmowę więcej Zobacz %1$d rozmów więcej @@ -4679,14 +4716,31 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Kompatybilny z Bitchatem; dociera do pobliskich użytkowników na tych samych transmiterach. Publiczny i efemeryczny – brak historii, każdy uczestnik może go odczytać. + Zaległości + Uruchomienia Workflow + Praca agenta + Nowe uruchomienie + Wymaga Twojej zgody Pracuje teraz Wysłano Zamknięte Wymaga zatwierdzenia Oczekiwanie na zatwierdzenie (brak opisu) + Workflow: %1$s przez + oczekiwanie na + To Ty jesteś osobą zatwierdzającą, ale to konto nie ma uprawnień do podejmowania decyzji. + Odrzuć + Zatwierdź & otwórz PR + Zatwierdzić to działanie? + Zabronić to działanie? + Runner wypchnie swoją gałąź i otworzy pull request dla „%1$s”. Zatwierdzanie nigdy nie łączy się ani nie wdraża-to nadal dzieje się na GitHubie. + Praca agenta dla \"%1$s\" zostanie odrzucona. Tej operacji nie można cofnąć. + ten bieg Anuluj + Oczekiwanie na zatwierdzenie + Uruchom zatwierdzone Nie udało się uruchomić zadania — sprawdź, czy możesz publikować posty w tej przestrzeni roboczej Zatwierdzono — autor tworzy pull request Odmowa – prace zostały odrzucone @@ -4725,5 +4779,39 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Naciśnij „Nowe uruchomienie” w celu uruchomienia procesu. Moduł wykonawczy realizuje zadanie i zatrzymuje się na etapie zatwierdzenia — przed wysłaniem jakichkolwiek danych zatwierdzenie musi udzielić osoba. Świadectwa + Zachowaj certyfikat + Posiadam certyfikat dotyczący tego konta. Jest on dołączany automatycznie podczas uwierzytelniania się na transmiterze Buzz. + Dotacje: %1$s + wszelkiego rodzaju – w dowolnym czasie (nieograniczony) + Usuń + Wklej podpisany przez właściciela tag autoryzacyjny wydany dla tego konta, aby uwierzytelnić się w przestrzeni roboczej Buzz jako członek wirtualny. + tag autoryzacji JSON + Przeprowadzić certyfikację + Wymagany klucz lokalny + Certyfikat NIP-OA stanowi podpis pod zaszyfrowanym zobowiązaniem, a nie zdarzeniem Nostr, dlatego może je wygenerować wyłącznie podpisujący, który posiada nieprzetworzony klucz prywatny użytkownika. To konto korzysta ze zdalnego (bunkier NIP-46) lub zewnętrznego (NIP-55) sygnatariusza, który nie może potwierdzić certyfikatu. + Upoważnij klucz publiczny agenta do publikowania w Twojej przestrzeni roboczej bez konieczności rejestrowania tego klucza. Agent dołącza do swoich zdarzeń poniższy sygnowany tag. + Agent (wyszukaj nazwę lub wklej npub / hex) + Wprowadź npub lub 64-char hex klucz. + Zmień agenta + Warunki (opcjonalnie) — pozostaw puste, aby uzyskać certyfikację bez ograniczeń. + Ogranicz do typu (0–65535) + Tylko zdarzenia po (unix sekund) + Tylko zdarzenia przed (unix sekund) + Generuj certyfikat + Podpisany certyfikat + Kopiuj tag + ⚠ Dokument ten należy przekazać wyłącznie operatorowi agenta. Dopóki dokument ten jest ważny, a użytkownik pozostaje członkiem przestrzeni roboczej, upoważnienie to pozwala agentowi na publikowanie postów jako członek zgodnie z powyższymi warunkami. + Nowa persona + Edytuj persona + Slug (persona id) + a-z, 0-9, \'-\' lub \'_\'. Nie można zmienić po utworzeniu. + Nazwa wyświetlana + Komunikat systemowy + Model (opcjonalnie) + Dostawca (opcjonalnie) + Czas pracy (opcjonalnie) + Adres URL awatara (opcjonalnie) + Publikowanie… + Publikuj persona diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index ac4d92395c..ba71f48a49 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1938,6 +1938,16 @@ Conectado a %1$d relay Conectado a %1$d relays + + + + Conectando aos relays de caixa de entrada\u2026 Serviço de notificações sempre ativo Mantém uma conexão persistente com seus relays de caixa de entrada para entrega instantânea de notificações. Mostra uma notificação contínua. Usa mais bateria, mas garante que você nunca perca uma mensagem. @@ -2217,8 +2227,10 @@ Postagem exclusiva de localização Somente seguidores da localização verão isso. Seus seguidores gerais não verão isso. Postagem exclusiva de Hashtag + Conteúdo externo Comentar em um site Comentar em um recurso externo + Abrir no navegador %1$d min de leitura %1$d faixa @@ -2350,6 +2362,7 @@ Criar um grupo Novo canal + Novo fórum Canal privado Oculto da lista de canais e apenas por convite. Desligado significa que qualquer pessoa neste relay pode encontrá-lo e entrar. Canal de fórum @@ -2392,6 +2405,8 @@ Excluir “%1$s”? Isso remove o grupo e seu histórico para todos e não pode ser desfeito. Excluir canal Excluir “%1$s”? Isso remove o canal e suas mensagens para todos e não pode ser desfeito. + Arquivar canal + Desarquivar canal Tópicos Fixar mensagem Desafixar mensagem @@ -2534,6 +2549,9 @@ Enviar para… Nova mensagem Novo destaque + Nova Foto + Novo Vídeo Curto + Novo Vídeo Novo destaque Texto destacado O que chamou sua atenção? @@ -3296,6 +3314,7 @@ Você ainda não parece ser membro de nenhum canal neste relay. Aceite primeiro o convite do espaço de trabalho no navegador e tente novamente. Canais Fóruns + Arquivados Trabalhando… Adicionar membro Adicionar pessoas a este espaço de trabalho @@ -4621,13 +4640,4 @@ URL do avatar (opcional) Publicando… Publicar persona - Arquivar canal - Desarquivar canal - Novo fórum - Conteúdo externo - Arquivados - Nova Foto - Novo Vídeo Curto - Novo Vídeo - Abrir no navegador diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 1e838e4160..8e990f2f3c 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -1264,6 +1264,16 @@ Conectado a %1$d relay Conectado a %1$d relays + + + + Conectando aos relays de caixa de entrada\u2026 Serviço de notificações sempre ativo Mantém uma conexão persistente com seus relays de caixa de entrada para entrega instantânea de notificações. Mostra uma notificação contínua. Usa mais bateria, mas garante que você nunca perca uma mensagem. diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index c354bb3ffd..74a61bceeb 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -1307,6 +1307,16 @@ Подключено к %1$d relay Подключено к %1$d relay + + + + Подключение к входящим relay… Постоянный сервис уведомлений Поддерживает постоянное соединение с вашими inbox-relay для мгновенной доставки уведомлений. Отображает постоянное уведомление. Расходует больше заряда батареи, но гарантирует, что вы не пропустите ни одного сообщения. diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 6fbaa90707..987afd893c 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -1293,6 +1293,16 @@ Подключено к %1$d relay Подключено к %1$d relay + + + + Подключение к входящим relay… Постоянный сервис уведомлений Поддерживает постоянное соединение с вашими inbox-relay для мгновенной доставки уведомлений. Отображает постоянное уведомление. Расходует больше заряда батареи, но гарантирует, что вы не пропустите ни одного сообщения. diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 23c0485bed..5a195ac944 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -2031,6 +2031,16 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Povezano z %1$d releji Povezano z %1$d releji + + + + Povezovanje vhodnih relejev\u2026 Vedno aktivna obvestila Ohranja stalno povezavo z vašimi releji za takojšnjo dostavo obvestil. Prikazuje trajno obvestilo. Porabi več baterije, a zagotavlja, da ne zamudite nobenega sporočila. diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 6f64572760..ffd4f2a4d8 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -1277,6 +1277,16 @@ Povezan sa %1$d relay-a Povezan sa %1$d relay-a + + + + Povezivanje sa inbox relays… Uvek aktivna usluga obaveštenja Održava stalnu vezu sa vašim inbox relays za trenutnu isporuku obaveštenja. Prikazuje stalno obaveštenje. Troši više baterije, ali osigurava da nikada ne propustite poruku. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 44e9929e4b..d2a555d0f3 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1938,6 +1938,16 @@ Ansluten till %1$d relä Ansluten till %1$d reläer + + + + Ansluter till inbox-relän\u2026 Alltid på-notifieringstjänst Upprätthåller en konstant anslutning till dina inbox-relän för omedelbar leverans av notifieringar. Visar en pågående notifiering. Använder mer batteri men säkerställer att du aldrig missar ett meddelande. @@ -2219,8 +2229,10 @@ Plats-exklusivt inlägg Endast anhängare av platsen kommer att se den. Dina allmänna anhängare kommer inte att se den. Hashtag-exklusivt inlägg + Externt innehåll Kommentera en webbplats Kommentera en extern resurs + Öppna i webbläsare %1$d min läsning %1$d spår @@ -2352,6 +2364,7 @@ Skapa en grupp Ny kanal + Nytt forum Privat kanal Dold från kanallistan och endast via inbjudan. Av innebär att vem som helst på det här relät kan hitta och gå med i den. Forumkanal @@ -2394,6 +2407,8 @@ Radera ”%1$s”? Det tar bort gruppen och dess historik för alla, och kan inte ångras. Radera kanal Radera ”%1$s”? Det tar bort kanalen och dess meddelanden för alla, och kan inte ångras. + Arkivera kanal + Avarkivera kanal Trådar Fäst meddelande Lossa meddelande @@ -2536,6 +2551,9 @@ Skicka till… Nytt meddelande Ny höjdpunkt + Ny bild + Ny kortfilm + Ny video Ny höjdpunkt Markerad text Vad fastnade du för? @@ -3298,6 +3316,7 @@ Du verkar inte vara medlem i några kanaler på det här relät än. Acceptera arbetsyteinbjudan i webbläsaren först och försök sedan igen. Kanaler Forum + Arkiverade Arbetar… Lägg till medlem Lägg till personer i den här arbetsytan @@ -4624,13 +4643,4 @@ Avatar-URL (valfritt) Publicerar… Publicera persona - Arkivera kanal - Avarkivera kanal - Nytt forum - Externt innehåll - Arkiverade - Ny bild - Ny kortfilm - Ny video - Öppna i webbläsare diff --git a/amethyst/src/main/res/values-sw/strings.xml b/amethyst/src/main/res/values-sw/strings.xml index 5b3e30d073..284ff28d02 100644 --- a/amethyst/src/main/res/values-sw/strings.xml +++ b/amethyst/src/main/res/values-sw/strings.xml @@ -1272,6 +1272,16 @@ Imeunganishwa na relay %1$d Imeunganishwa na relay %1$d + + + + Inaunganika kwenye relays za inbox… Huduma ya arifa inayofanya kazi daima Inaweka muunganiko wa kudumu kwenye relays za inbox yako kwa utoaji wa arifa wa papo hapo. Inaonyesha arifa inayoendelea. Inatumia betri zaidi lakini inahakikisha hukosi ujumbe wowote. diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index 4eb9a88314..04b96d5144 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -1276,6 +1276,16 @@ %1$d relay-உடன் இணைக்கப்பட்டது %1$d relays-உடன் இணைக்கப்பட்டது + + + + inbox relays உடன் இணைக்கிறது… எப்போதும் இயங்கும் அறிவிப்பு சேவை உடனடி அறிவிப்பு வழங்கலுக்காக உங்கள் inbox relays உடன் நிரந்தர இணைப்பை பராமரிக்கிறது. தொடர்ச்சியான அறிவிப்பை காட்டுகிறது. அதிக பேட்டரி பயன்படுத்துகிறது, ஆனால் ஒரு செய்தியையும் தவறவிடாமல் உறுதி செய்கிறது. diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 89afe85031..e7400d14e9 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -1247,6 +1247,16 @@ เชื่อมต่อกับ %1$d relay แล้ว + + + + กำลังเชื่อมต่อกับ inbox relay… บริการแจ้งเตือนแบบถาวร รักษาการเชื่อมต่อถาวรกับ inbox relay ของคุณเพื่อการส่งการแจ้งเตือนแบบทันที แสดงการแจ้งเตือนที่กำลังดำเนินอยู่ ใช้แบตเตอรี่มากขึ้น แต่รับประกันว่าคุณจะไม่พลาดข้อความ diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 05d2240aa1..738fb567fe 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -1279,6 +1279,16 @@ %1$d relay\'e bağlandı %1$d relay\'e bağlandı + + + + Gelen kutusu relay\'lerine bağlanılıyor… Her zaman açık bildirim servisi Anlık bildirim teslimi için gelen kutusu relay\'lerinize kalıcı bir bağlantı tutar. Devam eden bir bildirim gösterir. Daha fazla pil kullanır ancak bir mesajı kaçırmamanızı sağlar. diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index 1245237e89..b190cd5656 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -1303,6 +1303,16 @@ Підключено до %1$d relay Підключено до %1$d relay + + + + Підключення до inbox relay… Постійна служба сповіщень Підтримує постійне з\'єднання з вашими inbox relay для миттєвої доставки сповіщень. Відображає постійне сповіщення. Використовує більше заряду батареї, але гарантує, що ви ніколи не пропустите повідомлення. diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index f1ae558cdc..bf04eb9365 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -1279,6 +1279,16 @@ %1$d relay\'ga ulangan %1$d relay\'larga ulangan + + + + Kirish relaylariga ulanilmoqda… Har doim yoqiq bildirishnoma xizmati Darhol bildirishnoma yetkazib berish uchun kirish relaylaringizga doimiy ulanishni saqlaydi. Davom etayotgan bildirishnomani ko\'rsatadi. Ko\'proq batareya sarflaydi, lekin xabarni o\'tkazib yubormasligingizni ta\'minlaydi. diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index 52ad53fb62..43cc390c81 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -1252,6 +1252,16 @@ Đã kết nối với %1$d relay + + + + Đang kết nối với các relay hộp thư đến… Dịch vụ thông báo luôn hoạt động Duy trì kết nối liên tục đến các relay hộp thư đến của bạn để gửi thông báo tức thì. Hiển thị thông báo liên tục. Tốn nhiều pin hơn nhưng đảm bảo bạn không bao giờ bỏ lỡ tin nhắn. diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 94212f744a..6d1d290078 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1718,6 +1718,16 @@ 已连接到 %1$d 个中继 + + + + 正在连接到收件箱中继\u2026 “始终显示通知”服务 保持与收件箱中继的持续连接以便即时发送通知。 显示正在进行的通知。使用更多电量,但确保您永远不会错过消息。 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index 51b49f8569..77d6bcdd10 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -1278,6 +1278,16 @@ 已连接到 %1$d 个中继 + + + + 正在连接到收件箱中继\u2026 “始终显示通知”服务 保持与收件箱中继的持续连接以便即时发送通知。 显示正在进行的通知。使用更多电量,但确保您永远不会错过消息。 diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 2d1329ed08..f1a820d4c1 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -1278,6 +1278,16 @@ 已连接到 %1$d 个中继 + + + + 正在连接到收件箱中继\u2026 “始终显示通知”服务 保持与收件箱中继的持续连接以便即时发送通知。 显示正在进行的通知。使用更多电量,但确保您永远不会错过消息。 diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index bf945aec25..6d5ba050d1 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -1269,6 +1269,16 @@ 已連接至 %1$d 個 relay + + + + 正在連接收件匣 relay… 常駐通知服務 與收件匣 relay 保持持久連線,以確保即時傳遞通知。會顯示持續性通知,耗電量較高,但可確保您不會錯過任何訊息。 From 9ea4b81c1f9692d6c234ca779c69e140738b49f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 00:14:17 +0000 Subject: [PATCH 048/227] feat(quartz): size-enforcing Hex.decode64/128 and encode64/128 Adds exact-size codec entry points to the Hex utility so 32-byte pubkeys/event ids (64 chars) and 64-byte signatures (128 chars) with the wrong size or invalid characters are rejected instead of silently decoded: - decode64 / decode128 throw IllegalArgumentException; the OrNull variants return null for untrusted input. - encode64 / encode128 require exactly 32 / 64 input bytes. The decode is single-pass: character validation is folded into the decode loop via a sign-bit OR-accumulator (the lookup table yields -1 for invalid chars), so it is faster than the isHex64 + decode two-pass combination. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4 --- .../com/vitorpamplona/quartz/utils/Hex.kt | 70 +++++++++++ .../quartz/utils/HexExactSizeTest.kt | 113 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/HexExactSizeTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt index 49137ac8aa..18f457d6d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt @@ -36,6 +36,8 @@ package com.vitorpamplona.quartz.utils * val hex = Hex.encode(bytes) // ByteArray -> lower-case hex * val bytes = Hex.decode(hex) // hex (any case) -> ByteArray * if (Hex.isHex64(id)) { ... } // is this a valid 32-byte hex id? + * val id = Hex.decode64(idHex) // exactly 64 chars or it throws + * val sig = Hex.decode128OrNull(sigHex) // exactly 128 chars or null * ``` */ object Hex { @@ -188,6 +190,74 @@ object Hex { } } + /** + * Decodes a 32-byte pubkey/event id, accepting only exactly 64 hex chars + * (upper or lower case). Throws [IllegalArgumentException] on any other + * length or on non-hex characters — use [decode64OrNull] for untrusted + * input. Single pass: validation is folded into the decode, so this is + * faster than `isHex64` + [decode]. + */ + fun decode64(hex: String): ByteArray = decode64OrNull(hex) ?: throw IllegalArgumentException("Invalid 64-char hex $hex") + + /** Like [decode64] but returns null instead of throwing. */ + fun decode64OrNull(hex: String): ByteArray? = if (hex.length == 64) decodeExactOrNull(hex, 32) else null + + /** + * Decodes a 64-byte value (a Schnorr signature), accepting only exactly + * 128 hex chars (upper or lower case). Throws [IllegalArgumentException] + * on any other length or on non-hex characters — use [decode128OrNull] + * for untrusted input. + */ + fun decode128(hex: String): ByteArray = decode128OrNull(hex) ?: throw IllegalArgumentException("Invalid 128-char hex $hex") + + /** Like [decode128] but returns null instead of throwing. */ + fun decode128OrNull(hex: String): ByteArray? = if (hex.length == 128) decodeExactOrNull(hex, 64) else null + + /** + * Decodes [hex] into [byteLen] bytes, or null if any char is not a hex + * digit. The caller has already checked `hex.length == 2 * byteLen`. + * Validation is free: the lookup table yields -1 for invalid chars, which + * keeps the OR-accumulator negative, so one sign check at the end covers + * every char with no branches inside the loop. + */ + private fun decodeExactOrNull( + hex: String, + byteLen: Int, + ): ByteArray? = + try { + val out = ByteArray(byteLen) + var acc = 0 + var c = 0 + for (i in 0 until byteLen) { + val b = (hexToByte[hex[c++].code] shl 4) or hexToByte[hex[c++].code] + acc = acc or b + out[i] = b.toByte() + } + if (acc < 0) null else out + } catch (_: IndexOutOfBoundsException) { + // chars above 0xFF (e.g. emoji) fall outside the lookup table + null + } + + /** + * Encodes a 32-byte pubkey/event id as a 64-char lower-case hex string. + * Throws [IllegalArgumentException] when [input] is not exactly 32 bytes. + */ + fun encode64(input: ByteArray): String { + require(input.size == 32) { "Expected 32 bytes, got ${input.size}" } + return encode(input) + } + + /** + * Encodes a 64-byte value (a Schnorr signature) as a 128-char lower-case + * hex string. Throws [IllegalArgumentException] when [input] is not + * exactly 64 bytes. + */ + fun encode128(input: ByteArray): String { + require(input.size == 64) { "Expected 64 bytes, got ${input.size}" } + return encode(input) + } + /** Encodes [input] as a lower-case hex string (two chars per byte). */ fun encode(input: ByteArray): String { val out = CharArray(input.size * 2) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/HexExactSizeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/HexExactSizeTest.kt new file mode 100644 index 0000000000..2b9f4a5c94 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/HexExactSizeTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class HexExactSizeTest { + val id64 = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" + val sig128 = id64 + "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" + + @Test + fun decode64RoundTrip() { + assertEquals(id64, Hex.encode64(Hex.decode64(id64))) + assertContentEquals(Hex.decode(id64), Hex.decode64(id64)) + assertContentEquals(Hex.decode(id64), Hex.decode64OrNull(id64)) + } + + @Test + fun decode64AcceptsUpperCase() { + assertContentEquals(Hex.decode(id64), Hex.decode64(id64.uppercase())) + } + + @Test + fun decode64RejectsWrongLengths() { + assertFailsWith { Hex.decode64("") } + assertFailsWith { Hex.decode64(id64.drop(1)) } + assertFailsWith { Hex.decode64(id64.drop(2)) } + assertFailsWith { Hex.decode64(id64 + "ab") } + assertFailsWith { Hex.decode64(sig128) } + + assertNull(Hex.decode64OrNull("")) + assertNull(Hex.decode64OrNull(id64.drop(2))) + assertNull(Hex.decode64OrNull(id64 + "ab")) + assertNull(Hex.decode64OrNull(sig128)) + } + + @Test + fun decode64RejectsInvalidChars() { + // every position, both a plain non-hex char and an emoji (code > 0xFF) + for (i in 0 until 64) { + val withG = id64.substring(0, i) + "g" + id64.substring(i + 1) + assertNull(Hex.decode64OrNull(withG), withG) + assertFailsWith { Hex.decode64(withG) } + } + val withEmoji = "🥰" + id64.drop(2) + assertNull(Hex.decode64OrNull(withEmoji)) + assertFailsWith { Hex.decode64(withEmoji) } + } + + @Test + fun decode128RoundTrip() { + assertEquals(sig128, Hex.encode128(Hex.decode128(sig128))) + assertContentEquals(Hex.decode(sig128), Hex.decode128(sig128)) + assertContentEquals(Hex.decode(sig128), Hex.decode128OrNull(sig128.uppercase())) + } + + @Test + fun decode128RejectsWrongLengthsAndInvalidChars() { + assertFailsWith { Hex.decode128("") } + assertFailsWith { Hex.decode128(id64) } + assertFailsWith { Hex.decode128(sig128.drop(2)) } + assertFailsWith { Hex.decode128(sig128 + "ab") } + + assertNull(Hex.decode128OrNull(id64)) + assertNull(Hex.decode128OrNull(sig128.dropLast(1) + "x")) + assertNull(Hex.decode128OrNull("🥰" + sig128.drop(2))) + } + + @Test + fun encodeRejectsWrongSizes() { + assertFailsWith { Hex.encode64(ByteArray(31)) } + assertFailsWith { Hex.encode64(ByteArray(33)) } + assertFailsWith { Hex.encode64(ByteArray(64)) } + assertFailsWith { Hex.encode128(ByteArray(32)) } + assertFailsWith { Hex.encode128(ByteArray(63)) } + assertFailsWith { Hex.encode128(ByteArray(65)) } + } + + @Test + fun randomsMatchGenericDecode() { + for (i in 0..1000) { + val id = RandomInstance.bytes(32) + assertEquals(Hex.encode(id), Hex.encode64(id)) + assertContentEquals(id, Hex.decode64(Hex.encode64(id))) + + val sig = RandomInstance.bytes(64) + assertEquals(Hex.encode(sig), Hex.encode128(sig)) + assertContentEquals(sig, Hex.decode128(Hex.encode128(sig))) + } + } +} From d8bae8c6289423f0ee48d97cabcfb21e24d0dde8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 00:33:51 +0000 Subject: [PATCH 049/227] perf(quartz): tune Hex.decodeExactOrNull at the bytecode level javap on the previous version showed the hexToByte field re-loaded twice per iteration, the trip count as a runtime parameter, and the whole loop wrapped in an exception table (only needed because chars above 0xFF overflow the 256-entry lookup table). Now the table is hoisted into a local, the function is inline so the 32/64-byte length becomes a compile-time constant at each call site, and out-of-range chars are rejected branchlessly: the index is masked with 'and 0xFF' so it cannot overflow, while '255 - code' goes negative for any char above 0xFF and is folded into the same sign-bit accumulator that already catches invalid hex digits. No try/catch, no exception table, no branches in the loop. Measured on the JVM (4096 random ids, best-of-200 rounds, two runs with variant order reversed to rule out JIT profile artifacts): ~25% faster than the previous version and ~2x faster than the isHex64 + decode two-pass combination. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4 --- .../com/vitorpamplona/quartz/utils/Hex.kt | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt index 18f457d6d1..b6434e3a8b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt @@ -216,28 +216,36 @@ object Hex { /** * Decodes [hex] into [byteLen] bytes, or null if any char is not a hex * digit. The caller has already checked `hex.length == 2 * byteLen`. - * Validation is free: the lookup table yields -1 for invalid chars, which - * keeps the OR-accumulator negative, so one sign check at the end covers - * every char with no branches inside the loop. + * + * Tuned at the bytecode level (see `HexBenchmark`): the table is hoisted + * into a local (the JVM/ART can't always prove the field load loop + * invariant), `inline` turns [byteLen] into a compile-time trip count at + * each call site, and validation is branchless — the table yields -1 for + * invalid chars and `255 - code` goes negative for chars above 0xFF (e.g. + * emoji, kept in bounds by the `and 0xFF` mask), so OR-ing everything into + * one accumulator and sign-checking it at the end rejects all bad input + * with no branches and no exception table. ~25% faster than the same loop + * with a per-iteration field load and a try/catch guard, and ~2x faster + * than `isHex64` + [decode]. */ - private fun decodeExactOrNull( + @Suppress("NOTHING_TO_INLINE") + private inline fun decodeExactOrNull( hex: String, byteLen: Int, - ): ByteArray? = - try { - val out = ByteArray(byteLen) - var acc = 0 - var c = 0 - for (i in 0 until byteLen) { - val b = (hexToByte[hex[c++].code] shl 4) or hexToByte[hex[c++].code] - acc = acc or b - out[i] = b.toByte() - } - if (acc < 0) null else out - } catch (_: IndexOutOfBoundsException) { - // chars above 0xFF (e.g. emoji) fall outside the lookup table - null + ): ByteArray? { + val table = hexToByte + val out = ByteArray(byteLen) + var acc = 0 + var c = 0 + for (i in 0 until byteLen) { + val c0 = hex[c++].code + val c1 = hex[c++].code + val b = (table[c0 and 0xFF] shl 4) or table[c1 and 0xFF] + acc = acc or b or (255 - c0) or (255 - c1) + out[i] = b.toByte() } + return if (acc < 0) null else out + } /** * Encodes a 32-byte pubkey/event id as a 64-char lower-case hex string. From 166ebc755e602d8e963e413dcfb9283e9327d67e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:45:45 +0000 Subject: [PATCH 050/227] chore(actions): bump the actions group with 5 updates Bumps the actions group with 5 updates: | Package | From | To | | --- | --- | --- | | [actions/setup-java](https://github.com/actions/setup-java) | `5` | `5.6.0` | | [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `3.0.1` | `3.0.2` | | [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` | | [docker/login-action](https://github.com/docker/login-action) | `3` | `4` | | [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` | Updates `actions/setup-java` from 5 to 5.6.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/v5...v5.6.0) Updates `softprops/action-gh-release` from 3.0.1 to 3.0.2 - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/718ea10b132b3b2eba29c1007bb80653f286566b...3d0d9888cb7fd7b750713d6e236d1fcb99157228) Updates `docker/setup-buildx-action` from 3 to 4 - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4) Updates `docker/login-action` from 3 to 4 - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v3...v4) Updates `docker/build-push-action` from 6 to 7 - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 5.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: softprops/action-gh-release dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: docker/setup-buildx-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: docker/login-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: docker/build-push-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 10 +++++----- .github/workflows/create-release.yml | 22 +++++++++++----------- .github/workflows/smoke-test-desktop.yml | 4 ++-- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4a0da455f5..105856cd45 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -69,7 +69,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -126,7 +126,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -161,7 +161,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -220,7 +220,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 19ca1146e8..ce5d002955 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -53,7 +53,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -285,7 +285,7 @@ jobs: - name: Upload to GH Release (skip on dry-run) if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: dist/* tag_name: ${{ steps.ver.outputs.tag }} @@ -337,7 +337,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -536,7 +536,7 @@ jobs: - name: Upload to GH Release (skip on dry-run) if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: dist/* tag_name: ${{ steps.ver.outputs.tag }} @@ -586,7 +586,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -777,7 +777,7 @@ jobs: - name: Upload to GH Release (skip on dry-run) if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true' - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: dist/* tag_name: ${{ steps.ver.outputs.tag }} @@ -831,17 +831,17 @@ jobs: echo "image=$IMAGE" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: geode/Dockerfile @@ -866,7 +866,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -1010,7 +1010,7 @@ jobs: fi - name: Upload Android assets to GH Release - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: dist/* tag_name: ${{ github.ref_name }} diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index 0b51683681..3dafc575dd 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -28,7 +28,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 @@ -58,7 +58,7 @@ jobs: uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v5.6.0 with: distribution: 'temurin' java-version: 21 From 729fb1bc17ad450f6919e523898262744f38c848 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 01:47:01 +0000 Subject: [PATCH 051/227] perf(quartz): hoist lookup tables in Hex.decode/encode/isEqual/readLong; bench new codecs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit javap showed the hexToByte/byteToHex field re-loaded on every use inside these methods (16 times per readLong call) — the JVM/ART doesn't reliably prove the load loop-invariant. Hoisting it into a local measured ~25% faster for decode and ~10% for isEqual and readLong on the JVM (4096 random 32-byte ids, best-of-150 rounds, 3 repeats); encode was neutral on HotSpot but is hoisted too since ART is historically worse at this (see the internalIsHex comment). Branchless variants of isHex/isHex64 were also measured and were a wash-to-slightly-worse than the branchy early-exit versions on valid input, so those keep their current implementations. Also adds the new exact-size codecs to the on-device HexBenchmark (decode64, decode64OrNull, encode64, decode128, encode128, toLong256, and the old isHex64+decode two-pass for comparison) so ART numbers can be collected with the existing benchmark harness. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4 --- .../quartz/benchmark/HexBenchmark.kt | 40 ++++++++++++++ .../com/vitorpamplona/quartz/utils/Hex.kt | 53 +++++++++++-------- 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt index 64aa1cf179..3b11aa84df 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt @@ -39,9 +39,13 @@ class HexBenchmark { @get:Rule val r = BenchmarkRule() val hex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" + val hex128 = hex + "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" val bytes = fr.acinq.secp256k1.Hex .decode(hex) + val bytes64 = + fr.acinq.secp256k1.Hex + .decode(hex128) @Test fun hexIsEqual() { @@ -103,4 +107,40 @@ class HexBenchmark { fun isHex64() { r.measureRepeated { Hex.isHex64(hex) } } + + @Test + fun hexDecode64() { + r.measureRepeated { Hex.decode64(hex) } + } + + @Test + fun hexDecode64OrNull() { + r.measureRepeated { Hex.decode64OrNull(hex) } + } + + @Test + fun hexEncode64() { + r.measureRepeated { Hex.encode64(bytes) } + } + + @Test + fun hexDecode128() { + r.measureRepeated { Hex.decode128(hex128) } + } + + @Test + fun hexEncode128() { + r.measureRepeated { Hex.encode128(bytes64) } + } + + /** The pre-existing two-pass way to safely decode an id, for comparison with [hexDecode64OrNull]. */ + @Test + fun hexIsHex64ThenDecode() { + r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) else null } + } + + @Test + fun hexToLong256() { + r.measureRepeated { Hex.toLong256(hex) } + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt index b6434e3a8b..1b7a0e542d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt @@ -185,9 +185,15 @@ object Hex { require(hex.length and 1 == 0) { "Invalid hex $hex" } - return ByteArray(hex.length / 2) { - (hexToByte[hex[2 * it].code] shl 4 or hexToByte[hex[2 * it + 1].code]).toByte() + // table hoisted into a local: the JVM/ART doesn't reliably prove the + // field load loop-invariant, and re-loading it per char costs ~25% + val table = hexToByte + val out = ByteArray(hex.length shr 1) + var c = 0 + for (i in out.indices) { + out[i] = ((table[hex[c++].code] shl 4) or table[hex[c++].code]).toByte() } + return out } /** @@ -268,10 +274,11 @@ object Hex { /** Encodes [input] as a lower-case hex string (two chars per byte). */ fun encode(input: ByteArray): String { + val table = byteToHex val out = CharArray(input.size * 2) var outIdx = 0 for (i in 0 until input.size) { - val chars = byteToHex[input[i].toInt() and 0xFF] + val chars = table[input[i].toInt() and 0xFF] out[outIdx++] = (chars shr 8).toChar() out[outIdx++] = (chars and 0xFF).toChar() } @@ -290,23 +297,26 @@ object Hex { fun readLong( hex: String, offset: Int, - ): Long = - (hexToByte[hex[offset].code].toLong() shl 60) or - (hexToByte[hex[offset + 1].code].toLong() shl 56) or - (hexToByte[hex[offset + 2].code].toLong() shl 52) or - (hexToByte[hex[offset + 3].code].toLong() shl 48) or - (hexToByte[hex[offset + 4].code].toLong() shl 44) or - (hexToByte[hex[offset + 5].code].toLong() shl 40) or - (hexToByte[hex[offset + 6].code].toLong() shl 36) or - (hexToByte[hex[offset + 7].code].toLong() shl 32) or - (hexToByte[hex[offset + 8].code].toLong() shl 28) or - (hexToByte[hex[offset + 9].code].toLong() shl 24) or - (hexToByte[hex[offset + 10].code].toLong() shl 20) or - (hexToByte[hex[offset + 11].code].toLong() shl 16) or - (hexToByte[hex[offset + 12].code].toLong() shl 12) or - (hexToByte[hex[offset + 13].code].toLong() shl 8) or - (hexToByte[hex[offset + 14].code].toLong() shl 4) or - hexToByte[hex[offset + 15].code].toLong() + ): Long { + // table hoisted into a local — one field load instead of sixteen + val t = hexToByte + return (t[hex[offset].code].toLong() shl 60) or + (t[hex[offset + 1].code].toLong() shl 56) or + (t[hex[offset + 2].code].toLong() shl 52) or + (t[hex[offset + 3].code].toLong() shl 48) or + (t[hex[offset + 4].code].toLong() shl 44) or + (t[hex[offset + 5].code].toLong() shl 40) or + (t[hex[offset + 6].code].toLong() shl 36) or + (t[hex[offset + 7].code].toLong() shl 32) or + (t[hex[offset + 8].code].toLong() shl 28) or + (t[hex[offset + 9].code].toLong() shl 24) or + (t[hex[offset + 10].code].toLong() shl 20) or + (t[hex[offset + 11].code].toLong() shl 16) or + (t[hex[offset + 12].code].toLong() shl 12) or + (t[hex[offset + 13].code].toLong() shl 8) or + (t[hex[offset + 14].code].toLong() shl 4) or + t[hex[offset + 15].code].toLong() + } /** * Reads the first 64 bits (16 hex chars) of [hex] as a single [Long]. @@ -350,9 +360,10 @@ object Hex { id: String, ourId: ByteArray, ): Boolean { + val table = byteToHex var charIndex = 0 for (i in 0 until ourId.size) { - val chars = byteToHex[ourId[i].toInt() and 0xFF] + val chars = table[ourId[i].toInt() and 0xFF] if ( id[charIndex++] != (chars shr 8).toChar() || id[charIndex++] != (chars and 0xFF).toChar() From 5c24b003e7558cde448cdbd0d2de5ea871075e17 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 22:21:00 -0400 Subject: [PATCH 052/227] NIP-66: measure relays from the traffic a client already makes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A monitor normally probes — opens connections purely to measure, then throws them away. A client that is already subscribing, fetching and publishing has better data for free: measured under real load, against the relays it actually uses, at the concurrency it actually runs. RelayObserver is a RelayConnectionListener, so it sees every connection whichever code path opened it and none of them has to report anything: rtt-open onConnecting to onConnected rtt-read first REQ to its EOSE rtt-write first EVENT to its OK reachable it opened, or served something auth-required it sent AUTH, or CLOSED saying so the error, verbatim, when it never opened Everything is OBSERVED. Nothing is copied from a relay's NIP-11: that is the relay's own claim, available to anyone who asks, and republishing it under a monitor's signature adds nothing but a chance to go stale. Where the two disagree — a relay advertising open reads that then challenges us — the observation is the half worth having, and copying the claim would erase it. It also keeps quartz free of an HTTP dependency. RelayMonitor is the whole wiring: construct one and connections are measured, signed as 30166s on an interval, and folded into a cheap in-memory isKnownDead for picking relays. That read has to be cheap — an outbox picker runs per event — so it answers from a snapshot refreshed on an interval, never a store query. The signer is required. Measuring relay quality and letting others check it IS NIP-66, and an optional signer would just add the failure mode this library keeps designing out: configured, silent, doing nothing. A client that should not publish does not construct one. RelayObserver also replaces the CLI's RelayDiagnostics, which was the same listener minus the timings. Porting it surfaced a bug both shared: substringBefore(':') returns the WHOLE string when there is no colon, so a relay's free-form CLOSED prose became its own tally key and the map grew with the number of distinct sentences relays wrote. The colon is now required. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/vitorpamplona/amethyst/cli/Context.kt | 7 +- .../amethyst/cli/RelayDiagnostics.kt | 96 ------ .../cli/commands/graperank/GrapeRankCrawl.kt | 4 +- .../cli/commands/graperank/GrapeRankScore.kt | 2 +- .../reachability/RelayMonitor.kt | 161 +++++++++ .../reachability/RelayObserver.kt | 305 ++++++++++++++++++ .../reachability/RelayReachabilityStore.kt | 48 +++ .../reachability/RelayObserverTest.kt | 255 +++++++++++++++ 8 files changed, 778 insertions(+), 100 deletions(-) delete mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/RelayDiagnostics.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayMonitor.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index f37c1b5509..c0887ed607 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -65,6 +65,7 @@ import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayObserver import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayReachabilityStore import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -210,8 +211,12 @@ class Context( * (auth-required / rate-limited / restricted / …), and NIP-42 AUTH * challenges — so a failed REQ can be explained instead of guessed at. * Registered on [client] for the life of this run. + * + * Quartz's [RelayObserver], which also measures the connect/read/write + * round trips behind that feedback and is what a [RelayMonitor] publishes + * as NIP-66. One listener now answers both questions. */ - val relayDiagnostics: RelayDiagnostics = RelayDiagnostics().also { client.addConnectionListener(it) } + val relayDiagnostics: RelayObserver = RelayObserver().also { client.addConnectionListener(it) } /** * Adaptive per-relay concurrent-subscription cap. Starts every relay diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/RelayDiagnostics.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/RelayDiagnostics.kt deleted file mode 100644 index be275a71dd..0000000000 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/RelayDiagnostics.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.cli - -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicLong - -/** - * Client-wide tally of the relay feedback the crawl would otherwise never see: - * `NOTICE` frames, `CLOSED` reasons (`auth-required` / `rate-limited` / - * `restricted` / …), and NIP-42 `AUTH` challenges. Registered as a - * [RelayConnectionListener] on the shared client, so every incoming message - * during a run is counted and a REQ failure can be explained instead of - * guessed at. - * - * Callbacks fire on the per-relay socket threads, so all state is concurrent. - */ -class RelayDiagnostics : RelayConnectionListener { - private val closedByReason = ConcurrentHashMap() - private val noticeSamples = ConcurrentHashMap() - private val authChallenges = AtomicLong() - - override fun onIncomingMessage( - relay: IRelayClient, - msgStr: String, - msg: Message, - ) { - when (msg) { - // CLOSED reasons follow the NIP-01 machine-readable "word: text" - // convention, so the prefix categorises the failure. - is ClosedMessage -> bump(closedByReason, prefix(msg.message)) - // NOTICE is free-form; keep the (truncated) text so recurring - // relay complaints ("too many concurrent REQs", …) are visible. - is NoticeMessage -> if (noticeSamples.size < MAX_DISTINCT_NOTICES) bump(noticeSamples, msg.message.trim().take(80)) - is AuthMessage -> authChallenges.incrementAndGet() - else -> Unit - } - } - - private fun bump( - map: ConcurrentHashMap, - key: String, - ) { - map.getOrPut(key) { AtomicLong() }.incrementAndGet() - } - - /** The NIP-01 machine-readable prefix (`word` before `:`), or `other`. */ - private fun prefix(message: String): String { - val head = message.substringBefore(':').trim().lowercase() - return head.ifEmpty { "other" }.take(24) - } - - fun hadFeedback(): Boolean = authChallenges.get() > 0 || closedByReason.isNotEmpty() || noticeSamples.isNotEmpty() - - /** JSON-friendly summary for the command output. */ - fun snapshot(): Map = - mapOf( - "auth_challenges" to authChallenges.get(), - "closed_by_reason" to closedByReason.entries.associate { it.key to it.value.get() }.toSortedMap(), - "notices" to noticeSamples.values.sumOf { it.get() }, - "notice_top" to - noticeSamples.entries - .sortedByDescending { it.value.get() } - .take(TOP_NOTICES) - .map { "${it.key} (${it.value.get()})" }, - ) - - companion object { - private const val MAX_DISTINCT_NOTICES = 500 - private const val TOP_NOTICES = 8 - } -} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankCrawl.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankCrawl.kt index d3316d619d..d34a31f1ee 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankCrawl.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankCrawl.kt @@ -129,7 +129,7 @@ object GrapeRankCrawl { /** Echo any relay NOTICE/CLOSED feedback + adaptive throttling the crawl saw. */ internal fun reportRelayFeedback(ctx: Context) { if (ctx.relayDiagnostics.hadFeedback()) { - System.err.println("[graperank] relay feedback: ${ctx.relayDiagnostics.snapshot()}") + System.err.println("[graperank] relay feedback: ${ctx.relayDiagnostics.summary()}") } if (ctx.relayLimiter.hadThrottling()) { System.err.println("[graperank] relay throttling: ${ctx.relayLimiter.snapshot()}") @@ -204,7 +204,7 @@ object GrapeRankCrawl { "observer" to observer, "crawl_rounds" to stats.rounds, "relays_contacted" to stats.relaysContacted, - "relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.snapshot() else null, + "relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.summary() else null, "relay_throttling" to if (ctx.relayLimiter.hadThrottling()) ctx.relayLimiter.snapshot() else null, "max_hop_reached" to (stats.hopHistogram.keys.maxOrNull() ?: 0), "users_by_hop" to stats.hopHistogram.mapKeys { it.key.toString() }, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt index bcd6a68a1f..4a147dc115 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt @@ -191,7 +191,7 @@ object GrapeRankScore { "observer" to observer, "crawl_rounds" to (crawlStats?.rounds ?: 0), "relays_contacted" to (crawlStats?.relaysContacted ?: 0), - "relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.snapshot() else null, + "relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.summary() else null, "relay_throttling" to if (ctx.relayLimiter.hadThrottling()) ctx.relayLimiter.snapshot() else null, "max_hop_reached" to (hopHistogram.keys.maxOrNull() ?: 0), "users_by_hop" to hopHistogram.mapKeys { it.key.toString() }, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayMonitor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayMonitor.kt new file mode 100644 index 0000000000..a1e8a7bfcb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayMonitor.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip66RelayMonitor.reachability + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.concurrent.Volatile + +/** + * NIP-66 relay monitoring for a client that was going to talk to relays anyway. + * + * Construct one, and from then on every connection the client makes is measured + * ([RelayObserver]), signed and stored as a kind:30166 ([RelayReachabilityStore]) + * on an interval, and folded back into a cheap [isKnownDead] the caller consults + * when it picks relays. There is nothing else to wire. + * + * ## Reading is the cheap side, and it has to be + * + * A relay picker runs per event — thousands of times a second in an outbox + * fan-out — so [isKnownDead] answers from an in-memory snapshot refreshed on the + * same interval as the writes, never from a store query. The store round trip + * happens [refreshIntervalMs] apart, not per routing decision. + * + * ## The signer is required + * + * Measuring relay quality and letting others check it IS NIP-66; a monitor that + * cannot sign is not a monitor. Making the signer optional would also create the + * failure this library keeps trying to design out — a component configured, + * silent, and doing nothing. A client that should not publish simply does not + * construct one of these, which is a decision visible where it is made. + * + * Note that a monitor is its own identity: per NIP-66 it has its own pubkey, + * profile and relay list, distinct from any user account the client also holds. + * + * ## What ends up in the record + * + * Only what was observed — connect, read and write round trips, whether the + * relay actually demanded AUTH, and the network type implied by the url. Nothing + * is copied out of a relay's NIP-11 document: that is the relay's own claim + * about itself, available to anyone who asks, and re-publishing it under a + * monitor's signature would add nothing but an opportunity to go stale. + */ +class RelayMonitor( + private val client: INostrClient, + store: IEventStore, + private val scope: CoroutineScope, + signer: NostrSigner, + ttlSeconds: Long = RelayReachabilityStore.DEFAULT_TTL_SECONDS, + private val flushIntervalMs: Long = DEFAULT_FLUSH_INTERVAL_MS, + private val refreshIntervalMs: Long = DEFAULT_REFRESH_INTERVAL_MS, + private val onError: (String) -> Unit = {}, +) : AutoCloseable { + val observer = RelayObserver() + + private val reachability = RelayReachabilityStore(store, signer, ttlSeconds) + + @Volatile private var snapshot: RelayReachabilityStore.Snapshot? = null + + init { + client.addConnectionListener(observer) + scope.launch { flushLoop() } + scope.launch { refreshLoop() } + } + + /** + * Skip this relay? Answers from memory, so it is safe to call per routing + * decision. False until the first [refresh] completes — an unknown relay is + * one to try, never one to shun. + */ + fun isKnownDead(relay: NormalizedRelayUrl): Boolean = snapshot?.isKnownDead(relay) == true + + /** Relays proven unreachable within the TTL and not seen live since. */ + fun deadSet(): Set = snapshot?.dead ?: emptySet() + + /** Relays with a recent successful open, from any monitor whose records we hold. */ + fun liveSet(): Set = snapshot?.live ?: emptySet() + + /** Re-read the reachability records, including any other monitor's that arrived. */ + suspend fun refresh() { + runCatching { snapshot = reachability.snapshot() } + .onFailure { onError("could not read relay reachability: ${it.message}") } + } + + /** + * Sign and store what has been observed since the last flush. Returns how + * many records were written. + * + * A relay whose state has not changed is skipped: re-writing its record + * would refresh a freshness window that nothing re-measured. + */ + suspend fun flush(): Int { + val fresh = observer.collectUnreported() + if (fresh.isEmpty()) return 0 + return runCatching { reachability.record(fresh, TimeUtils.now()) } + .onFailure { onError("could not write relay reachability: ${it.message}") } + .getOrDefault(0) + } + + private suspend fun flushLoop() { + while (scope.isActive) { + delay(flushIntervalMs) + flush() + } + } + + private suspend fun refreshLoop() { + // Immediately, then on the interval: the first thing a run should know is + // what the last one learned, before it dials anything. + refresh() + while (scope.isActive) { + delay(refreshIntervalMs) + refresh() + } + } + + /** + * Detach and stop measuring. Does NOT flush — the last write needs a + * coroutine and a bound on how long a shutdown may block, both of which + * belong to the caller. Call [flush] inside your own timeout first. + */ + override fun close() { + runCatching { client.removeConnectionListener(observer) } + } + + companion object { + /** + * Five minutes: long enough that a flapping relay does not mint a record + * per flap, short enough that a crash loses little. The records are + * replaceable, so writing again costs one document, not one more. + */ + const val DEFAULT_FLUSH_INTERVAL_MS = 5 * 60 * 1000L + + /** How often the in-memory dead/live view is re-read from the store. */ + const val DEFAULT_REFRESH_INTERVAL_MS = 5 * 60 * 1000L + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt new file mode 100644 index 0000000000..a749e1f704 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip66RelayMonitor.reachability + +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap +import kotlin.concurrent.Volatile +import kotlin.time.TimeSource + +/** + * What a client learns about relays just by talking to them. + * + * A NIP-66 monitor normally probes: it opens connections for the sole purpose of + * measuring, and then throws them away. A client that is already subscribing, + * fetching and publishing has better data available for free — measured under + * real load, against the relays it actually uses, at the concurrency it actually + * runs. Attached to a client as a [RelayConnectionListener], this collects it. + * + * Everything here is **observed**. Nothing is copied from a relay's NIP-11 + * document, and that is deliberate: a relay's self-description is available to + * anyone who asks for it, so republishing it under a monitor's signature adds + * nothing but a chance to be stale. Where the two disagree — a relay that + * advertises open reads and then sends AUTH — the observation is the half worth + * having, and copying the claim would erase it. + * + * ## Threading + * + * Callbacks for one relay arrive on that relay's own socket thread, so a single + * [Observation] is only ever written by one thread. The fields are `@Volatile` + * for visibility to the reader that publishes them, not for mutual exclusion, + * and the counters need no atomics. The client-wide tallies ARE shared and use + * [ConcurrentMap.merge]. + * + * ## Why nothing is removed + * + * Observations are marked reported rather than deleted. A long-lived connection + * only fires `onConnected` once, so a measurement that vanished when it was + * published would leave the relays we know best — an upstream whose socket has + * been open for hours — with nothing to say about them ever again. The map is + * bounded by the number of distinct relays the client has ever dialled. + */ +class RelayObserver : RelayConnectionListener { + class Observation( + val url: NormalizedRelayUrl, + ) { + // Monotonic marks, not wall clock: these measure durations, and a clock + // step mid-connection must not produce a negative or wild latency. + @Volatile var connectingAt: TimeSource.Monotonic.ValueTimeMark? = null + + @Volatile var rttOpenMs: Long? = null + + @Volatile var firstReqAt: TimeSource.Monotonic.ValueTimeMark? = null + + @Volatile var rttReadMs: Long? = null + + @Volatile var firstEventAt: TimeSource.Monotonic.ValueTimeMark? = null + + @Volatile var rttWriteMs: Long? = null + + /** It opened, or served something. Nothing more is claimed by this. */ + @Volatile var reachable: Boolean = false + + /** Why it did not open, verbatim from the transport. */ + @Volatile var error: String? = null + + /** It sent AUTH, or CLOSED a subscription demanding it. Measured, not read off NIP-11. */ + @Volatile var authRequired: Boolean = false + + /** The NIP-01 machine-readable prefix of the last CLOSED. */ + @Volatile var closedReason: String? = null + + /** The last NOTICE text, truncated — often the only explanation a relay gives. */ + @Volatile var notice: String? = null + + /** Set by every observation, cleared when published. See the class doc. */ + @Volatile var unreported: Boolean = false + + internal fun touch() { + unreported = true + } + } + + private val seen = ConcurrentMap() + + // Client-wide tallies, across every relay. Separate from the per-relay state + // because they answer a different question — "how did this run go" rather + // than "what shall I record about this relay" — and because a summary must + // survive publishing, which clears the per-relay flags. + private val closedByReason = ConcurrentMap() + private val noticeSamples = ConcurrentMap() + private val authChallenges = ConcurrentMap() + + private fun of(relay: IRelayClient) = seen.getOrPut(relay.url) { Observation(relay.url) } + + override fun onConnecting(relay: IRelayClient) { + val o = of(relay) + o.connectingAt = TimeSource.Monotonic.markNow() + // Cleared, not kept: a reconnect is a fresh attempt, and carrying an old + // error forward would report a working relay as broken for as long as the + // process lives after one bad minute. + o.error = null + o.touch() + } + + override fun onConnected( + relay: IRelayClient, + attempt: Int, + success: Boolean, + ) { + val o = of(relay) + o.reachable = true + o.error = null + o.connectingAt?.let { o.rttOpenMs = it.elapsedNow().inWholeMilliseconds.coerceAtLeast(0) } + o.touch() + } + + override fun onCannotConnect( + relay: IRelayClient, + error: String, + ) { + val o = of(relay) + // NOT `reachable = false`. A relay that answered an hour ago and is down + // now is a different thing from one that never answered at all, and only + // the writer decides which record that becomes. + o.error = error.take(MAX_TEXT) + o.touch() + } + + /** + * Outgoing commands start the read and write clocks — the FIRST of each per + * relay, since a later REQ on a warm socket measures nothing about the relay. + */ + override fun onSent( + relay: IRelayClient, + msgStr: String, + command: Command, + success: Boolean, + ) { + if (!success) return + val o = of(relay) + when (command) { + is ReqCmd -> if (o.firstReqAt == null) o.firstReqAt = TimeSource.Monotonic.markNow() + is EventCmd -> if (o.firstEventAt == null) o.firstEventAt = TimeSource.Monotonic.markNow() + else -> Unit + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + message: Message, + ) { + val o = of(relay) + when (message) { + is EoseMessage -> { + if (o.rttReadMs == null) { + o.firstReqAt?.let { + o.rttReadMs = it.elapsedNow().inWholeMilliseconds.coerceAtLeast(0) + o.touch() + } + } + } + + is OkMessage -> { + if (o.rttWriteMs == null) { + o.firstEventAt?.let { + o.rttWriteMs = it.elapsedNow().inWholeMilliseconds.coerceAtLeast(0) + o.touch() + } + } + } + + // Serving an event is proof of life even from a relay that never + // sends EOSE — some do not, and treating those as unresponsive would + // shed relays that work perfectly well. Guarded because this fires + // for EVERY event on every socket: an unconditional write here would + // bounce a cache line between threads to say nothing new. + is EventMessage -> { + if (!o.reachable) { + o.reachable = true + o.touch() + } + } + + is AuthMessage -> { + o.authRequired = true + o.touch() + authChallenges.merge(relay.url.url, 1L) { a, b -> a + b } + } + + is NoticeMessage -> { + val text = message.message.trim().take(NOTICE_KEY) + o.notice = text + o.touch() + if (noticeSamples.size() < MAX_DISTINCT_NOTICES) noticeSamples.merge(text, 1L) { a, b -> a + b } + } + + is ClosedMessage -> { + val reason = prefixOf(message.message) + o.closedReason = reason + // NIP-42 refusal, in the shape relays use when the subscription + // is what got rejected rather than the connection. + if (reason == AUTH_REQUIRED) o.authRequired = true + o.touch() + closedByReason.merge(reason, 1L) { a, b -> a + b } + } + + else -> Unit + } + } + + /** + * Everything observed since the last call, marked reported as it is read. + * + * A relay whose state has not changed is left out: writing its record again + * would refresh a freshness window that nothing re-measured. + */ + fun collectUnreported(): List = + seen + .snapshot() + .values + .filter { it.unreported } + .onEach { it.unreported = false } + + /** Every relay ever observed, whether or not it has changed. */ + fun all(): Collection = seen.snapshot().values + + fun observationOf(relay: NormalizedRelayUrl): Observation? = seen[relay] + + fun hadFeedback(): Boolean = authChallenges.size() > 0 || closedByReason.size() > 0 || noticeSamples.size() > 0 + + /** + * A run-level summary of the feedback relays gave — the frames a client + * otherwise never surfaces, so a failed REQ can be explained instead of + * guessed at. + */ + fun summary(): Map { + val notices = noticeSamples.snapshot() + return mapOf( + "auth_challenges" to authChallenges.snapshot().values.sum(), + "auth_required_relays" to authChallenges.size(), + "closed_by_reason" to closedByReason.snapshot().toSortedMap(), + "notices" to notices.values.sum(), + "notice_top" to + notices.entries + .sortedByDescending { it.value } + .take(TOP_NOTICES) + .map { "${it.key} (${it.value})" }, + ) + } + + companion object { + private const val MAX_TEXT = 200 + private const val NOTICE_KEY = 80 + private const val MAX_DISTINCT_NOTICES = 500 + private const val TOP_NOTICES = 8 + private const val AUTH_REQUIRED = "auth-required" + private const val OTHER = "other" + private const val MAX_PREFIX = 24 + + /** + * The NIP-01 machine-readable prefix — the word before `:` — or `other`. + * + * The colon is required. `substringBefore` returns the WHOLE string when + * the separator is absent, so without this check a relay's free-form + * CLOSED prose became its own tally key and the map's cardinality grew + * with the number of distinct sentences relays happened to write. + */ + fun prefixOf(message: String): String { + if (!message.contains(':')) return OTHER + val head = message.substringBefore(':').trim().lowercase() + return head.ifEmpty { OTHER }.take(MAX_PREFIX) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt index 3d3631a5f0..fe28f94954 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.networkType +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.requirement import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.rtt import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkType import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType @@ -157,6 +158,53 @@ class RelayReachabilityStore( for (relay in dead) if (relay !in reachableRttMs) writeOne(relay, up = false, now, 0) } + /** + * Write everything a run observed, one replaceable record per relay. + * + * Skips a relay it learned nothing about — one that was never dialled, or + * only started connecting. Silence is not evidence, and a record written on + * no observation would refresh a freshness window nothing re-measured. + * + * Returns the number of records written. + */ + suspend fun record( + observations: Collection, + now: Long = TimeUtils.now(), + ): Int { + var written = 0 + for (o in observations) { + if (!o.reachable && o.error == null) continue + writeObserved(o, now) + written++ + } + return written + } + + private suspend fun writeObserved( + o: RelayObserver.Observation, + now: Long, + ) { + val template = + RelayDiscoveryEvent.build(o.url, createdAt = now) { + networkType(networkTypeOf(o.url)) + if (o.reachable) { + // Liveness is the presence of rtt-open, per NIP-66. A relay we + // reached without timing the open — served us an event on a + // socket that was already up — still gets the tag so it reads + // as live, but never an invented latency: 0 would be a lie + // aggregators rank on. + o.rttOpenMs?.let { rtt(RttType.OPEN, it) } ?: rtt(RttType.OPEN, 0) + o.rttReadMs?.let { rtt(RttType.READ, it) } + o.rttWriteMs?.let { rtt(RttType.WRITE, it) } + } + // Observed, not read off NIP-11: this relay actually challenged + // us. A relay advertising open reads and then demanding AUTH is + // exactly what a monitor exists to catch. + if (o.authRequired) requirement("auth") + } + store.insert(signer.sign(template)) + } + private suspend fun writeOne( relay: NormalizedRelayUrl, up: Boolean, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt new file mode 100644 index 0000000000..65ba639082 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip66RelayMonitor.reachability + +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * These records get published under a monitor's own key, so what matters is what + * the observer is willing to CLAIM: an unmeasured latency must never be reported + * as a measurement, a relay nobody dialled must never be reported at all, and one + * bad minute must not bury a relay that works. + */ +class RelayObserverTest { + private val url = RelayUrlNormalizer.normalize("wss://relay.example") + private val other = RelayUrlNormalizer.normalize("wss://other.example") + + private class FakeRelayClient( + override val url: NormalizedRelayUrl, + ) : IRelayClient { + override fun connect() = Unit + + override fun needsToReconnect() = false + + override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit + + override fun isConnected() = true + + override fun sendOrConnectAndSync(cmd: Command) = Unit + + override fun sendIfConnected(cmd: Command) = Unit + + override fun disconnect() = Unit + } + + private fun client(u: NormalizedRelayUrl) = FakeRelayClient(u) + + private fun RelayObserver.only() = collectUnreported().single() + + // ---- what we measured --------------------------------------------------- + + @Test + fun `an opened connection is timed, not assumed`() { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + + val obs = o.only() + assertTrue(obs.reachable) + assertNotNull(obs.rttOpenMs, "rtt-open must be measured — aggregators rank on it") + assertNull(obs.error) + } + + @Test + fun `the read clock runs from the first REQ to the first EOSE`() { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + o.onSent(client(url), "", ReqCmd("sub", emptyList()), true) + o.onIncomingMessage(client(url), "", EoseMessage("sub")) + + assertNotNull(o.only().rttReadMs) + } + + @Test + fun `the write clock runs from the first EVENT to its OK`() { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + o.onIncomingMessage(client(url), "", OkMessage("id", true, "")) + + assertNull(o.collectUnreported().single().rttWriteMs, "an OK with nothing sent behind it times nothing") + } + + @Test + fun `a non-REQ command does not start the read clock`() { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + o.onSent(client(url), "", CloseCmd("sub"), true) + o.onIncomingMessage(client(url), "", EoseMessage("sub")) + + assertNull(o.only().rttReadMs) + } + + // ---- what we refuse to claim -------------------------------------------- + + @Test + fun `a connection that never opened records the reason and no latency`() { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onCannotConnect(client(url), "Expected HTTP 101 response but was '503 Service Unavailable'") + + val obs = o.only() + assertFalse(obs.reachable) + assertNull(obs.rttOpenMs, "nothing opened, so there is nothing to time") + assertTrue(obs.error!!.contains("503")) + } + + @Test + fun `a relay that answered stays answered through a later failure`() { + // A relay that worked a minute ago and blipped now is not the same thing + // as one that never answered, and only the writer decides which record + // that becomes. A single failure must not erase the success under it. + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + o.onCannotConnect(client(url), "connection reset") + + assertTrue(o.only().reachable, "one bad minute must not bury a relay that answered") + } + + @Test + fun `a reconnect clears the previous attempt's error`() { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onCannotConnect(client(url), "timeout") + o.onConnecting(client(url)) + + assertNull(o.only().error, "a stale error would report a live relay as broken forever") + } + + // ---- AUTH, which is why an anonymous crawl finds a relay empty ------------ + + @Test + fun `a demand for AUTH is recorded, from either shape`() { + val challenged = RelayObserver() + challenged.onIncomingMessage(client(url), "", AuthMessage("challenge")) + assertTrue(challenged.only().authRequired) + + val closed = RelayObserver() + closed.onIncomingMessage(client(url), "", ClosedMessage("sub", "auth-required: subscribers only")) + val obs = closed.only() + assertTrue(obs.authRequired) + assertEquals("auth-required", obs.closedReason) + } + + @Test + fun `a CLOSED that is not about auth is categorised, not misread`() { + val o = RelayObserver() + o.onIncomingMessage(client(url), "", ClosedMessage("sub", "rate-limited: slow down")) + + val obs = o.only() + assertEquals("rate-limited", obs.closedReason) + assertFalse(obs.authRequired, "only an auth refusal means auth is required") + } + + // ---- publishing bookkeeping --------------------------------------------- + + @Test + fun `an unchanged relay is not re-reported, and its measurement survives`() { + // Re-writing a record refreshes its freshness window, so a relay nobody + // re-measured must be left out. But the measurement itself has to stay: + // a long-lived socket fires onConnected once, and if publishing erased + // it, the relays we know best would be the ones we could never describe + // again. + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + + val first = o.collectUnreported().single() + assertNotNull(first.rttOpenMs) + assertEquals(0, o.collectUnreported().size, "nothing new to say") + + o.onIncomingMessage(client(url), "", NoticeMessage("slow down")) + val second = o.collectUnreported().single() + assertEquals(first.rttOpenMs, second.rttOpenMs, "the last real measurement still stands") + } + + @Test + fun `each relay is observed on its own`() { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + o.onConnecting(client(other)) + o.onCannotConnect(client(other), "nodename nor servname provided") + + val byUrl = o.collectUnreported().associateBy { it.url } + assertTrue(byUrl.getValue(url).reachable) + assertFalse(byUrl.getValue(other).reachable) + } + + // ---- the run-level summary (what RelayDiagnostics used to give) ----------- + + @Test + fun `the summary tallies feedback across every relay`() { + val o = RelayObserver() + assertFalse(o.hadFeedback()) + + o.onIncomingMessage(client(url), "", AuthMessage("c1")) + o.onIncomingMessage(client(other), "", AuthMessage("c2")) + o.onIncomingMessage(client(url), "", ClosedMessage("s", "rate-limited: slow")) + o.onIncomingMessage(client(other), "", ClosedMessage("s", "rate-limited: slow")) + o.onIncomingMessage(client(url), "", NoticeMessage("too many REQs")) + + assertTrue(o.hadFeedback()) + val s = o.summary() + assertEquals(2L, s["auth_challenges"]) + assertEquals(2, s["auth_required_relays"]) + assertEquals(mapOf("rate-limited" to 2L), s["closed_by_reason"]) + assertEquals(1L, s["notices"]) + } + + @Test + fun `the summary outlives publishing`() { + // It answers "how did this run go", which must not be reset by the + // unrelated act of writing records out. + val o = RelayObserver() + o.onIncomingMessage(client(url), "", AuthMessage("c")) + o.collectUnreported() + + assertTrue(o.hadFeedback(), "a flush must not erase the run's tally") + assertEquals(1L, o.summary()["auth_challenges"]) + } + + @Test + fun `a machine-readable prefix is extracted, or 'other'`() { + assertEquals("auth-required", RelayObserver.prefixOf("auth-required: come back signed")) + assertEquals("other", RelayObserver.prefixOf("just some prose")) + assertEquals("other", RelayObserver.prefixOf("")) + } +} From cf75272202b000ef258218d0972111cb69adfbc6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 22:31:39 -0400 Subject: [PATCH 053/227] Fix the native build: toSortedMap is java.util commonMain, so it compiled on JVM and broke every native target. Sorted into a LinkedHashMap instead, which is the same output everywhere. Found by CI on iosSimulatorArm64 because I had only compiled the JVM target locally; all five now build. Co-Authored-By: Claude Opus 5 (1M context) --- .../nip66RelayMonitor/reachability/RelayObserver.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt index a749e1f704..d6e88b8e83 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt @@ -269,7 +269,15 @@ class RelayObserver : RelayConnectionListener { return mapOf( "auth_challenges" to authChallenges.snapshot().values.sum(), "auth_required_relays" to authChallenges.size(), - "closed_by_reason" to closedByReason.snapshot().toSortedMap(), + // Sorted into a LinkedHashMap rather than toSortedMap(): that one is + // java.util and this file is commonMain, so it built on JVM and broke + // the native targets. + "closed_by_reason" to + closedByReason + .snapshot() + .entries + .sortedBy { it.key } + .associate { it.key to it.value }, "notices" to notices.values.sum(), "notice_top" to notices.entries From 18d229be58e4a407afbf064a4ab6b3851fb6a494 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 22:55:36 -0400 Subject: [PATCH 054/227] Match the listener's parameter names; drop commas from test names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native targets reject a comma inside a backticked name, so five tests that read fine on JVM broke every Kotlin/Native build. Renamed without them. The override parameters now match RelayConnectionListener — pingMillis, compressed, cmdStr, cmd, msg, errorMessage — which silences six warnings and, more to the point, fixes a misreading: onConnected's second and third parameters are the connection's ping and whether it is compressed, and I had them named attempt and success. Both were missed the same way: jvmTest passes without ever compiling the native TEST sources. All five targets now compile, main and test. Co-Authored-By: Claude Opus 5 (1M context) --- .../reachability/RelayObserver.kt | 22 +++++++++---------- .../reachability/RelayObserverTest.kt | 10 ++++----- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt index d6e88b8e83..05f869ce32 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt @@ -134,8 +134,8 @@ class RelayObserver : RelayConnectionListener { override fun onConnected( relay: IRelayClient, - attempt: Int, - success: Boolean, + pingMillis: Int, + compressed: Boolean, ) { val o = of(relay) o.reachable = true @@ -146,13 +146,13 @@ class RelayObserver : RelayConnectionListener { override fun onCannotConnect( relay: IRelayClient, - error: String, + errorMessage: String, ) { val o = of(relay) // NOT `reachable = false`. A relay that answered an hour ago and is down // now is a different thing from one that never answered at all, and only // the writer decides which record that becomes. - o.error = error.take(MAX_TEXT) + o.error = errorMessage.take(MAX_TEXT) o.touch() } @@ -162,13 +162,13 @@ class RelayObserver : RelayConnectionListener { */ override fun onSent( relay: IRelayClient, - msgStr: String, - command: Command, + cmdStr: String, + cmd: Command, success: Boolean, ) { if (!success) return val o = of(relay) - when (command) { + when (cmd) { is ReqCmd -> if (o.firstReqAt == null) o.firstReqAt = TimeSource.Monotonic.markNow() is EventCmd -> if (o.firstEventAt == null) o.firstEventAt = TimeSource.Monotonic.markNow() else -> Unit @@ -178,10 +178,10 @@ class RelayObserver : RelayConnectionListener { override fun onIncomingMessage( relay: IRelayClient, msgStr: String, - message: Message, + msg: Message, ) { val o = of(relay) - when (message) { + when (msg) { is EoseMessage -> { if (o.rttReadMs == null) { o.firstReqAt?.let { @@ -219,14 +219,14 @@ class RelayObserver : RelayConnectionListener { } is NoticeMessage -> { - val text = message.message.trim().take(NOTICE_KEY) + val text = msg.message.trim().take(NOTICE_KEY) o.notice = text o.touch() if (noticeSamples.size() < MAX_DISTINCT_NOTICES) noticeSamples.merge(text, 1L) { a, b -> a + b } } is ClosedMessage -> { - val reason = prefixOf(message.message) + val reason = prefixOf(msg.message) o.closedReason = reason // NIP-42 refusal, in the shape relays use when the subscription // is what got rejected rather than the connection. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt index 65ba639082..f1192871f7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt @@ -73,7 +73,7 @@ class RelayObserverTest { // ---- what we measured --------------------------------------------------- @Test - fun `an opened connection is timed, not assumed`() { + fun `an opened connection is timed rather than assumed`() { val o = RelayObserver() o.onConnecting(client(url)) o.onConnected(client(url), 1, true) @@ -156,7 +156,7 @@ class RelayObserverTest { // ---- AUTH, which is why an anonymous crawl finds a relay empty ------------ @Test - fun `a demand for AUTH is recorded, from either shape`() { + fun `a demand for AUTH is recorded from either shape`() { val challenged = RelayObserver() challenged.onIncomingMessage(client(url), "", AuthMessage("challenge")) assertTrue(challenged.only().authRequired) @@ -169,7 +169,7 @@ class RelayObserverTest { } @Test - fun `a CLOSED that is not about auth is categorised, not misread`() { + fun `a CLOSED that is not about auth is categorised rather than misread`() { val o = RelayObserver() o.onIncomingMessage(client(url), "", ClosedMessage("sub", "rate-limited: slow down")) @@ -181,7 +181,7 @@ class RelayObserverTest { // ---- publishing bookkeeping --------------------------------------------- @Test - fun `an unchanged relay is not re-reported, and its measurement survives`() { + fun `an unchanged relay is not re-reported but its measurement survives`() { // Re-writing a record refreshes its freshness window, so a relay nobody // re-measured must be left out. But the measurement itself has to stay: // a long-lived socket fires onConnected once, and if publishing erased @@ -247,7 +247,7 @@ class RelayObserverTest { } @Test - fun `a machine-readable prefix is extracted, or 'other'`() { + fun `a machine-readable prefix is extracted or falls back to other`() { assertEquals("auth-required", RelayObserver.prefixOf("auth-required: come back signed")) assertEquals("other", RelayObserver.prefixOf("just some prose")) assertEquals("other", RelayObserver.prefixOf("")) From ae61e69136fd64a8bdbb18b9d83b57a8aec1fd07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 05:27:51 +0000 Subject: [PATCH 055/227] fix(relays): deliver in-process server frames only after onOpen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nip42AuthDmDeliveryTest stalled for its full 10s timeout on CI while passing locally. The stall is a race in InProcessWebSocket.connect(): server.connect() runs the session's connect-time policies synchronously, so FullAuthPolicy's AUTH challenge reached the client's listener before the socket assigned its `incoming` channel and before onOpen fired — breaking the WebSocketListener contract (no onMessage before onOpen). RelayAuthenticator answers that challenge on its own coroutine. When the signed AUTH reply hit send() before the connect thread reached the `incoming` assignment, send() returned false and the reply was silently dropped. Nothing recovers from that: the challenge is already dedup'd as answered, and an EVENT rejected with OK-false `auth-required:` never re-triggers auth (only a CLOSED does), so the pending gift wrap was never resent — exactly the CI signature (10.011s, no auth activity between the authenticator's Init and Destroy logs). Server->client frames now go through an outbound channel drained by a coroutine started only after onOpen, so every connect-time frame reaches the listener with the socket fully wired. Order is preserved by the single drainer, same as the existing inbound path. Both new InProcessWebSocketTest cases fail deterministically without the reorder (the challenge always outran onOpen; a reply sent from the first onMessage was always rejected) and pass with it, on top of the full :geode:test and :quartz:jvmTest suites. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Bf1Y91sfjxwi2ig4ymGTA9 --- .../server/inprocess/InProcessWebSocket.kt | 36 +++- .../inprocess/InProcessWebSocketTest.kt | 175 ++++++++++++++++++ 2 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocketTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocket.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocket.kt index 69def1b78c..0b3672e445 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocket.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocket.kt @@ -41,7 +41,19 @@ import kotlinx.coroutines.launch * - Outbound (`send`) → server `RelaySession.receive()` via an inbound * channel drained by a single coroutine, preserving message order * per the [WebSocketListener] contract. - * - Server-side `send` callbacks → [WebSocketListener.onMessage]. + * - Server-side `send` callbacks → [WebSocketListener.onMessage], via an + * outbound channel drained by a single coroutine started only after + * [WebSocketListener.onOpen] has fired. + * + * The outbound channel is not an optimization: a session's connect-time + * policies send synchronously from inside `server.connect` (e.g. + * [com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy]'s + * AUTH challenge), which is before this socket has stored its own state and + * before `onOpen`. Delivering those frames directly would break the + * [WebSocketListener] contract (no `onMessage` before `onOpen`) and — worse — + * a listener that answers the challenge from another thread (RelayAuthenticator + * signs and replies concurrently) could hit [send] while `incoming` is still + * null, silently losing the reply and deadlocking the NIP-42 handshake. * * Use this to wire a `NostrClient` to an embedded server in unit tests * or single-JVM scenarios without paying for a real TCP socket. Because @@ -49,8 +61,8 @@ import kotlinx.coroutines.launch * expects. * * Reconnect-after-disconnect is supported: each [connect] creates a - * fresh scope + drain channel so a previous [disconnect] (which - * cancels both) doesn't leave a dead drainer behind. + * fresh scope + drain channels so a previous [disconnect] (which + * cancels them) doesn't leave a dead drainer behind. */ class InProcessWebSocket( private val server: NostrServer, @@ -58,7 +70,9 @@ class InProcessWebSocket( ) : WebSocket { private var scope: CoroutineScope? = null private var incoming: Channel? = null + private var outgoing: Channel? = null private var drainJob: Job? = null + private var deliverJob: Job? = null private var session: RelaySession? = null override fun needsReconnect(): Boolean = session == null @@ -67,10 +81,12 @@ class InProcessWebSocket( if (session != null) return val newScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val newIncoming = Channel(UNLIMITED) - val s = server.connect { json -> out.onMessage(json) } + val newOutgoing = Channel(UNLIMITED) + val s = server.connect { json -> newOutgoing.trySend(json) } scope = newScope incoming = newIncoming + outgoing = newOutgoing session = s drainJob = newScope.launch { @@ -80,6 +96,15 @@ class InProcessWebSocket( } out.onOpen(0, false) + + // Started only after onOpen so every buffered connect-time frame (AUTH + // challenge & co.) reaches the listener with the socket fully wired. + deliverJob = + newScope.launch { + for (msg in newOutgoing) { + out.onMessage(msg) + } + } } override fun disconnect() { @@ -87,7 +112,10 @@ class InProcessWebSocket( session = null incoming?.close() incoming = null + outgoing?.close() + outgoing = null drainJob = null + deliverJob = null scope?.cancel() scope = null s.close() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocketTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocketTest.kt new file mode 100644 index 0000000000..2b9cda14cd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocketTest.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server.inprocess + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins the [WebSocketListener] contract on the in-process transport against a + * server whose policy sends from inside `onConnect` — [FullAuthPolicy] pushes + * its AUTH challenge synchronously while `server.connect` is still on the + * stack, before the socket has stored its own state. + * + * Both tests reproduce (pre-fix, deterministically) the CI-only stall in + * geode's Nip42AuthDmDeliveryTest: the challenge used to be delivered before + * `onOpen` and before `incoming` was assigned, so a listener that answered it + * concurrently (RelayAuthenticator signs on its own coroutine) could call + * [InProcessWebSocket.send] on a half-built socket, get `false`, and lose the + * AUTH reply forever — the challenge is dedup'd as already-answered, an EVENT + * rejected `auth-required:` never re-triggers auth, and the DM never lands. + */ +class InProcessWebSocketTest { + private val relayUrl = NormalizedRelayUrl("wss://relay.example.com/") + + private fun newServer() = + NostrServer( + store = EventStore(null), + policyBuilder = { FullAuthPolicy(relayUrl) }, + ) + + @Test + fun onOpenPrecedesEveryMessage() = + runTest { + withContext(Dispatchers.Default) { + val server = newServer() + val callbacks = Channel(UNLIMITED) + + val listener = + object : WebSocketListener { + override fun onOpen( + pingMillis: Int, + compression: Boolean, + ) { + callbacks.trySend("open") + } + + override fun onMessage(text: String) { + callbacks.trySend("message") + } + + override fun onClosed( + code: Int, + reason: String, + ) { + } + + override fun onFailure( + t: Throwable, + code: Int?, + response: String?, + ) { + } + } + + val socket = InProcessWebSocket(server, listener) + try { + socket.connect() + + // FullAuthPolicy sends its AUTH challenge at connect time, so both + // callbacks are guaranteed to arrive; the contract is their order. + val received = mutableListOf() + withTimeout(5_000) { + while ("message" !in received) received.add(callbacks.receive()) + } + + assertEquals( + "open", + received.first(), + "the connect-time AUTH challenge must not be delivered before onOpen; got $received", + ) + } finally { + socket.disconnect() + server.close() + } + } + } + + @Test + fun replySentFromChallengeHandlerIsNotDropped() = + runTest { + withContext(Dispatchers.Default) { + val server = newServer() + + var socket: InProcessWebSocket? = null + val replyAccepted = Channel(UNLIMITED) + + val listener = + object : WebSocketListener { + override fun onOpen( + pingMillis: Int, + compression: Boolean, + ) { + } + + override fun onMessage(text: String) { + // Answer the AUTH challenge immediately, the way + // RelayAuthenticator does. The socket must be fully + // wired by the time any server frame is delivered, + // so this send must be accepted — a `false` here is + // a silently lost AUTH and a dead NIP-42 handshake. + replyAccepted.trySend(socket?.send("""["CLOSE","probe"]""") == true) + } + + override fun onClosed( + code: Int, + reason: String, + ) { + } + + override fun onFailure( + t: Throwable, + code: Int?, + response: String?, + ) { + } + } + + val s = InProcessWebSocket(server, listener) + socket = s + try { + s.connect() + + val accepted = withTimeout(5_000) { replyAccepted.receive() } + + assertTrue( + accepted, + "a reply sent from the first onMessage must reach the server, not be dropped", + ) + } finally { + s.disconnect() + server.close() + } + } + } +} From 529c114802dc9ec1457105fd858876717b92042a Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 1 Aug 2026 10:33:11 +0200 Subject: [PATCH 056/227] fix: hoist the playback-error test fixtures out of composition --- .../composable/PlaybackErrorOverlayFitTest.kt | 61 ++++++++----------- 1 file changed, 25 insertions(+), 36 deletions(-) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt index dcd1867d03..5ea16ce0b6 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt @@ -66,28 +66,37 @@ class PlaybackErrorOverlayFitTest { private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + /** + * Built outside composition on purpose: the mock and its error state are fixtures for the whole + * test, not per-composition state. Creating them inside `setContent` would rebuild both on every + * recomposition (and trips Compose's UnrememberedMutableState lint). + */ + private fun failedControllerState() = + MediaControllerState( + controller = mockk(relaxed = true), + playbackError = + mutableStateOf( + PlaybackException( + "Malformed HLS manifest", + null, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED, + ), + ), + ) + private fun renderInBox( width: Dp, height: Dp, fontScale: Float = 1f, ) { + val controllerState = failedControllerState() + rule.setContent { val density = LocalDensity.current.density CompositionLocalProvider(LocalDensity provides Density(density, fontScale)) { Box(Modifier.width(width).height(height)) { RenderPlaybackError( - controllerState = - MediaControllerState( - controller = mockk(relaxed = true), - playbackError = - mutableStateOf( - PlaybackException( - "Malformed HLS manifest", - null, - PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED, - ), - ), - ), + controllerState = controllerState, videoUri = "https://streamstr.net/x/hls/live.m3u8", ) } @@ -151,24 +160,14 @@ class PlaybackErrorOverlayFitTest { // button is measured before the weighted text block that absorbs the shortfall. Measure // the same button roomy and then at its tightest, and require the two to agree. val boxHeight = mutableStateOf(400.dp) + val controllerState = failedControllerState() rule.setContent { val density = LocalDensity.current.density CompositionLocalProvider(LocalDensity provides Density(density, 2f)) { Box(Modifier.width(322.dp).height(boxHeight.value)) { RenderPlaybackError( - controllerState = - MediaControllerState( - controller = mockk(relaxed = true), - playbackError = - mutableStateOf( - PlaybackException( - "Malformed HLS manifest", - null, - PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED, - ), - ), - ), + controllerState = controllerState, videoUri = "https://streamstr.net/x/hls/live.m3u8", ) } @@ -196,24 +195,14 @@ class PlaybackErrorOverlayFitTest { // was just tall enough to keep the icon and not tall enough to pay for it, so the title // rendered sliced. Decoration must yield before words do. val boxHeight = mutableStateOf(400.dp) + val controllerState = failedControllerState() rule.setContent { val density = LocalDensity.current.density CompositionLocalProvider(LocalDensity provides Density(density, 2f)) { Box(Modifier.width(322.dp).height(boxHeight.value)) { RenderPlaybackError( - controllerState = - MediaControllerState( - controller = mockk(relaxed = true), - playbackError = - mutableStateOf( - PlaybackException( - "Malformed HLS manifest", - null, - PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED, - ), - ), - ), + controllerState = controllerState, videoUri = "https://streamstr.net/x/hls/live.m3u8", ) } From f7959571a7c8142fc91bff20e91f201e4ab36a6b Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:31:43 +0000 Subject: [PATCH 057/227] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-pl-rPL/strings.xml | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 9ee423c874..846afbff29 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -2024,9 +2024,39 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest + + %1$s \u00b7 %2$d transmiter + %1$s \u00b7 %2$d transmiterów + %1$s \u00b7 %2$d transmiterów + %1$s \u00b7 %2$d transmitery + + Przeglądanie + Multimedia + Hashtagi + Tematy + Rozmowa + Szukaj + Wyszukiwanie brakujących wydarzeń + Obserwowanie wydarzeń + Pobiera wydarzenia na podstawie identyfikatora, do których odnosi się jakiś element na ekranie, ale których jeszcze nie masz — cytat, wiadomość nadrzędna odpowiedzi, początek wątku. + Monitoruje aktualnie wyświetlane wydarzenia pod kątem nowych odpowiedzi, reakcji, udostępnień, zapsów i zgłoszeń, dzięki czemu liczby są aktualizowane na bieżąco podczas czytania. + Dodatki + Informacje o transmiterze + Inne + Wyszukiwarka listy transmiterów + Obserwowanie profili + Dane konta + Główny kanał + Grupy Transmiterów + + %1$d grupa + %1$d grup + %1$d grup + %1$d grupy + From c3c20c661574ea833dfabc4f5d9e00f9e7c90cdb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 1 Aug 2026 09:40:39 -0400 Subject: [PATCH 058/227] Let a monitor publish what it learned without dialling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RelayObserver is a RelayConnectionListener, so on its own it can only report on relays something opened a websocket to. On a large fan-out that is a small minority, and it is the wrong minority: the cheap checks that decide NOT to dial — a TCP probe, a DNS failure, a host struck out after repeated silence — are precisely the ones that learn a relay is gone, and their findings had nowhere to go. Measured on a 16,507-relay list: 104 records published. Everything else was ruled out before the client ever saw it, so the monitor had nothing to say about 99% of the relays it had just formed an opinion on. record() takes those findings. Same rules as the connection path — a relay that answered is not demoted by one failed probe, and a reachable relay with no measured time is published with no time rather than a fabricated zero. Co-Authored-By: Claude Opus 5 (1M context) --- .../reachability/RelayObserver.kt | 38 ++++++++++++++ .../reachability/RelayObserverTest.kt | 50 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt index 05f869ce32..bde310489c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt @@ -239,6 +239,44 @@ class RelayObserver : RelayConnectionListener { } } + /** + * Record a measurement taken OUTSIDE the websocket client — a TCP probe, a + * DNS failure, a host struck out after repeated silence. + * + * This class is a [RelayConnectionListener], so on its own it can only report + * on relays something opened a websocket to. On a large fan-out that is a + * small minority, and it is the wrong minority: the cheap checks that decide + * NOT to dial are precisely the ones that learn a relay is gone, and their + * findings had nowhere to go. Measured on a 16,507-relay list — 104 records + * published, because everything else was ruled out before the client saw it. + * + * A monitor that only reports what it happened to connect to is not a census. + * + * [rttOpenMs] is whatever was actually measured; null means reachable with no + * timing, and no timing is ever invented. + */ + fun record( + relay: NormalizedRelayUrl, + reachable: Boolean, + rttOpenMs: Long? = null, + error: String? = null, + ) { + val o = seen.getOrPut(relay) { Observation(relay) } + if (reachable) { + o.reachable = true + o.error = null + // Kept on the Observation, which is never removed — only marked + // reported — so a measurement survives every later flush. + rttOpenMs?.let { o.rttOpenMs = it } + } else { + // Same rule as onCannotConnect: a relay that answered earlier is not + // demoted by one failed probe. The writer decides what record that + // becomes, and "answered, then a probe failed" is not "dead". + o.error = (error ?: "unreachable").take(MAX_TEXT) + } + o.touch() + } + /** * Everything observed since the last call, marked reported as it is read. * diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt index f1192871f7..dbda038e0f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt @@ -200,6 +200,56 @@ class RelayObserverTest { assertEquals(first.rttOpenMs, second.rttOpenMs, "the last real measurement still stands") } + // ---- findings from outside the websocket client ------------------------ + + @Test + fun `a probe failure is published even though nothing was dialled`() { + // The cheap checks that decide NOT to open a websocket are exactly the + // ones that learn a relay is gone. Without a way in, a listener-only + // observer reports on the small minority it happened to connect to — + // 104 records out of a 16,507-relay list — which is not a census. + val o = RelayObserver() + o.record(url, reachable = false, error = "nodename nor servname provided") + + val obs = o.only() + assertFalse(obs.reachable) + assertEquals("nodename nor servname provided", obs.error) + assertNull(obs.rttOpenMs, "a failed probe times nothing") + } + + @Test + fun `a probe that connected reports its measured time or none at all`() { + val timed = RelayObserver() + timed.record(url, reachable = true, rttOpenMs = 42) + assertEquals(42L, timed.only().rttOpenMs) + + val untimed = RelayObserver() + untimed.record(url, reachable = true) + val obs = untimed.only() + assertTrue(obs.reachable) + assertNull(obs.rttOpenMs, "reachable without a timing must not invent one") + } + + @Test + fun `a failed probe does not demote a relay that already answered`() { + // Same rule the connection path follows: one bad probe is not death, and + // only the writer decides what record a mixed history becomes. + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + o.record(url, reachable = false, error = "connect timeout") + + assertTrue(o.only().reachable, "it answered; a later probe failure does not erase that") + } + + @Test + fun `an out-of-band finding is reported once like any other`() { + val o = RelayObserver() + o.record(url, reachable = false, error = "refused") + assertEquals(1, o.collectUnreported().size) + assertEquals(0, o.collectUnreported().size, "nothing new to say") + } + @Test fun `each relay is observed on its own`() { val o = RelayObserver() From 9cb17a26e80e3dde296a2c15cd4f2c923b7388c2 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:51:50 +0000 Subject: [PATCH 059/227] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-pl-rPL/strings.xml | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 846afbff29..db3045cba8 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -2057,11 +2057,72 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest %1$d grup %1$d grupy + Czaty efemeryczne + Czaty z funkcją lokalizacji + Czat podczas transmisji na żywo + Grupy NIP-29, do których dołączyłeś. Każda grupa działa na jednym transmiterze, więc aplikacja łączy się z każdym transmiterem, na którym znajduje się jakaś Twoja grupa. + Czaty, w których nie jest zapisywana historia — wiadomości są dostępne tylko wtedy, gdy użytkownik jest podłączony, więc aby otrzymywać jakiekolwiek wiadomości, należy pozostać subskrybentem. + Pokoje powiązane z lokalizacją dla obszarów, które obserwujesz, wymagane od transmiterów, które je obsługują. + Czatuj i zap cele powiązane z transmisjami na żywo, które masz otwarte lub które obserwujesz. + Skrzynka odbiorcza DM + Portfel + Skrzynka odbiorcza Nutzap + Katalog Mint + Podłącz portfel + Czaty społecznościowe + Kanały społecznościowe + Twoje transmitery skrzynek odbiorczych oraz niewielka, zmieniająca się próbka transmiterów, na których publikują osoby, które obserwujesz – na wypadek, gdyby wzmianka została dostarczona gdzie indziej. + Transmitery skrzynek odbiorczych DM, gdzie dostarczane są zawijane wiadomości DM. + Transmiter domowy każdego czatu, który masz otwarty lub do którego dołączyłeś. + Transmitery, na których każda społeczność publikuje swoje plany. + Wiadomości grupowe i pakiety kluczy na transmiterach poszczególnych grup. + Transmitery pokoju, dopóki jest otwarty. + Twój profil, ustawienia i wersje robocze na Twoich domowych transmiterach. + Profile osób znajdujących się obecnie na ekranie. + Ustala, na jakich transmiterach poszczególne osoby publikują swoje treści, dzięki czemu ich posty mogą być pobierane z właściwego miejsca. + Listy obserwacji, używane do budowania Twojego kanału i Twojej sieci WoT. + Raporty sporządzone przez obserwowanych przez Ciebie użytkowników na temat profili wyświetlanych obecnie na ekranie, uzyskane z poszczególnych transmiterów, na których publikują ci użytkownicy. + Zgłoszenia od obserwujących + Własne zdarzenia z portfela, odczytane z transmiterów, na których zostały opublikowane. + Monitoruje transmitery Nutzap, a także transmitery skrzynek odbiorczych i wiadomości prywatnych, dzięki czemu żadna płatność nie umknie. + Sprawdza na różnych transmiterach, na których istnieją serwisy typu „mint”, oraz które z nich są polecane przez użytkowników. + Powiadomienia z podłączonego portfela. + Aktywne subskrypcje transmitera + + %1$d filtr + %1$d filtrów + %1$d filtrów + %1$d filtry + + + %1$d transmiter + %1$d transmiterów + %1$d/ transmiterów + %1$d transmitery + + + %1$d filtr nie został jeszcze przypisany + %1$d filtrów nie zostało jeszcze przypisanych + %1$d filtrów nie zostało jeszcze przypisanych + %1$d filtry nie zostały jeszcze przypisane + + %1$s \u00b7 %2$s + Nie przypisano do konta + Wszystkie + Wszyscy + Osoby, które obserwujesz + Wybrana lista osób + Uciszone osoby + Twoje społeczności + Ulubiony kanał algorytmów + %1$d%% z wszystkich + subskrypcje filtry przekaźniki, żądania (reqs) połączenia dlaczego diagnostyka + Posty osób, które obserwujesz, są pobierane z transmiterów, na których każda z nich publikuje swoje treści. Łączenie z transmiterami odbiorczymi\u2026 Usługa powiadomień zawsze włączona Utrzymuje stałe połączenie z transmiterami odbiorczymi, aby zapewnić natychmiastowe dostarczanie powiadomień. Wyświetla bieżące powiadomienia. Zużywa więcej baterii, ale gwarantuje, że nigdy nie przegapisz żadnej wiadomości. From 532b9e67fe5b8bbb08ee19fed1820bb432e9518f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 16:29:08 +0000 Subject: [PATCH 060/227] refactor: collapse LocalCache event dispatch into grouped when branches justConsumeInnerInner was one when(event) with ~290 branches, of which 172 were identical single-call bodies routing to consumeBaseReplaceable or consumeRegularEvent, and ~55 more were single-line Buzz consumer calls. Since all four shared consumers take a plain Event, the boilerplate branches are now comma-grouped into one branch per consumer (replaceable/addressable, regular, Buzz timeline, Buzz store-only), keeping every branch with per-kind logic exactly as it was. Dispatch is provably unchanged: none of the 289 event classes has a supertype among the classes in any other branch group, so reordering cannot shadow a branch, and the old and new type-to-consumer mappings were compared exhaustively and are identical. The else branch still rejects unlisted kinds, preserving the supported-kinds allowlist. LocalCache.kt: 5155 -> 4554 lines. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../amethyst/model/LocalCache.kt | 1257 +++++------------ 1 file changed, 328 insertions(+), 929 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index bdad25dece..e7cdefae15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -4072,413 +4072,35 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { ): Boolean = try { when (event) { - is AcceptedBadgeSetEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AdvertisedRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AppDefinitionEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AppRecommendationEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AppSpecificDataEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AttestationEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AttestationRequestEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AttestorRecommendationEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AttestorProficiencyEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is AudioHeaderEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is AudioTrackEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BadgeAwardEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is BadgeDefinitionEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BlockedRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BlossomServersEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NestsServersEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BroadcastRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BookmarkListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is OldBookmarkListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CalendarEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CalendarDateSlotEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CalendarTimeSlotEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CalendarRSVPEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CallAnswerEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallHangupEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallIceCandidateEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallOfferEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallRejectEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CallRenegotiateEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - // ============================================================ - // NIP-60 Cashu wallet + NIP-61 nutzaps + // Kinds with dedicated consume() logic: typed overloads that + // update channels, zaps, drafts, the deletion index, etc. + // Everything without per-kind logic falls through to the + // generic replaceable / regular groups at the bottom. // ============================================================ - is CashuWalletEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - is CashuTokenEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CashuSpendingHistoryEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CashuMintQuoteEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NutzapInfoEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NutzapEvent -> { - consume(event, relay, wasVerified) - } - - // ============================================================ - // NIP-87 Cashu mint discovery + recommendations - // ============================================================ - // All three are kind 3xxxx (parameterized-replaceable per the - // spec) but neither CashuMintEvent / FedimintEvent / - // MintRecommendationEvent extends AddressableEvent in Quartz - // today, so consumeBaseReplaceable's `check(event is - // AddressableEvent)` would crash. Route through - // consumeRegularEvent — downstream consumers - // (CashuMintDirectoryState, CashuWalletState) already dedupe - // by (pubKey, dTag) and keep the newest. Without these - // entries the dispatch falls into the "Event Not Supported" - // else branch and silently drops the event, so our own - // kind:38000 thumbs-up never lands in the cache. - is CashuMintEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is FedimintEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is MintRecommendationEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ChannelCreateEvent -> { - consume(event, relay, wasVerified) - } - - is ChannelListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ChannelHideMessageEvent -> { - consume(event, relay, wasVerified) - } - - is ChannelMessageEvent -> { - consume(event, relay, wasVerified) - } - - is ChannelMetadataEvent -> { - consume(event, relay, wasVerified) - } - - is ChannelMuteUserEvent -> { - consume(event, relay, wasVerified) - } - - is ChatMessageEncryptedFileHeaderEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ChatMessageEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ChatMessageRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is Bolt12OfferListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ClassifiedsEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is FundraiserEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BirdexEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is BirdDetectionEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is Ps1SaveEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CommentEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CommunityDefinitionEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CommunityListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is CommunityPostApprovalEvent -> { - consume(event, relay, wasVerified) - } - - is ContactListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is DeletionEvent -> { - consume(event, relay, wasVerified) - } - - is DraftWrapEvent -> { - consume(event, relay, wasVerified) - } - - is EmojiPackEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is EmojiPackSelectionEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is EphemeralChatEvent -> { - consume(event, relay, wasVerified) - } - - is GeohashChatEvent -> { - consume(event, relay, wasVerified) - } - - is EphemeralChatListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - // NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + - // servers. Replaceable like its sibling lists; RelayGroupListState reads it from the - // addressable cache, so it must be stored (it was silently dropped before). - is SimpleGroupListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - // Concord private joined-communities list (kind 13302). Replaceable, self-encrypted; - // ConcordChannelListState observes it via the addressable cache (Address(13302, me, "")), - // so — exactly like the 10009 list above — it must be stored replaceably or the Concord - // hub stays empty even after the event arrives. - is ConcordCommunityListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GroupMetadataEvent -> { - consume(event, relay, wasVerified) - } - - is GroupMembersEvent -> { - consume(event, relay, wasVerified) - } - - is GroupAdminsEvent -> { - consume(event, relay, wasVerified) - } - - is GroupPinnedEvent -> { - consume(event, relay, wasVerified) - } + is NutzapEvent -> consume(event, relay, wasVerified) + is ChannelCreateEvent -> consume(event, relay, wasVerified) + is ChannelHideMessageEvent -> consume(event, relay, wasVerified) + is ChannelMessageEvent -> consume(event, relay, wasVerified) + is ChannelMetadataEvent -> consume(event, relay, wasVerified) + is ChannelMuteUserEvent -> consume(event, relay, wasVerified) + is CommunityPostApprovalEvent -> consume(event, relay, wasVerified) + is DeletionEvent -> consume(event, relay, wasVerified) + is DraftWrapEvent -> consume(event, relay, wasVerified) + is EphemeralChatEvent -> consume(event, relay, wasVerified) + is GeohashChatEvent -> consume(event, relay, wasVerified) + is GroupMetadataEvent -> consume(event, relay, wasVerified) + is GroupMembersEvent -> consume(event, relay, wasVerified) + is GroupAdminsEvent -> consume(event, relay, wasVerified) + is GroupPinnedEvent -> consume(event, relay, wasVerified) // 39003 (relay-declared roles) is durable group state like 39000/39001/39002: // route it onto the channel so a moderation UI can offer the relay's role set. - is SupportedRolesEvent -> { - consume(event, relay, wasVerified) - } + is SupportedRolesEvent -> consume(event, relay, wasVerified) - // Remaining NIP-29 relay-group kinds. The relay-signed 39004 AV-participants - // addressable is durable group state, so it's stored replaceably. The 9xxx - // moderation actions and join/leave requests are regular one-shot events the - // relay is authoritative for (it applies them and republishes the - // 39000/39001/39002); we store them so they're queryable and don't fall through - // to the "Not Supported" warning, but we don't act on them client-side. - is GroupParticipantsEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PutUserEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is RemoveUserEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is EditMetadataEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is DeleteEventEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is UpdatePinListEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is DeleteGroupEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CreateGroupEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CreateInviteEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is JoinRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is LeaveRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ExternalIdentitiesEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GenericRepostEvent -> { - consume(event, relay, wasVerified) - } - - is FhirResourceEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is FileHeaderEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ProfileGalleryEntryEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is FileServersEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is FileStorageEvent -> { - consume(event, relay, wasVerified) - } - - is FileStorageHeaderEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is FollowListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GeohashListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GoalEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } + is GenericRepostEvent -> consume(event, relay, wasVerified) + is FileStorageEvent -> consume(event, relay, wasVerified) is GiftWrapEvent -> { // A wrap with an empty content carries no NIP-44 ciphertext and can @@ -4492,177 +4114,11 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { } } - is GroupEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitIssueEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitReplyEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitPatchEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitPullRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitPullRequestUpdateEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitStatusEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is GitRepositoryEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is GitRepositoryStateEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is UserGraspListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is RootSiteEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NamedSiteEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is RootNappletEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NamedNappletEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ChessGameEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is RelayFeedsListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is JesterEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is KeyPackageEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is KeyPackageRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessGameChallengeEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessGameAcceptEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessMoveEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessGameEndEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveChessDrawOfferEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is HashtagListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is FavoriteAlgoFeedsListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is HighlightEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is IndexerRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is InteractiveStoryPrologueEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is InteractiveStorySceneEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is InteractiveStoryReadingStateEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is InterestSetEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LabeledBookmarkListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LiveActivitiesEvent -> { - consume(event, relay, wasVerified) - } - - is LiveActivitiesChatMessageEvent -> { - consume(event, relay, wasVerified) - } - - is LiveActivitiesRaidEvent -> { - consume(event, relay, wasVerified) - } - - is LiveActivitiesClipEvent -> { - consume(event, relay, wasVerified) - } - - is MeetingSpaceEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is MeetingRoomEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is MeetingRoomPresenceEvent -> { - consume(event, relay, wasVerified) - } - - is MusicTrackEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is MusicPlaylistEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PodcastEpisodeEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } + is LiveActivitiesEvent -> consume(event, relay, wasVerified) + is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified) + is LiveActivitiesRaidEvent -> consume(event, relay, wasVerified) + is LiveActivitiesClipEvent -> consume(event, relay, wasVerified) + is MeetingRoomPresenceEvent -> consume(event, relay, wasVerified) is PodcastMetadataEvent -> { // Drop the known "Mock Podcast" spam flood instead of caching thousands of them. @@ -4673,133 +4129,27 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { } } - is AuthoredPodcastsEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is FavoritePodcastsListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is Podcasting20EpisodeEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is Podcasting20TrailerEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is LnZapEvent -> { - consume(event, relay, wasVerified) - } - - is LnZapRequestEvent -> { - consume(event, relay, wasVerified) - } - - is OnchainZapEvent -> { - consume(event, relay, wasVerified) - } - - is Bolt12ZapEvent -> { - consume(event, relay, wasVerified) - } - - is NIP90StatusEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NIP90ContentDiscoveryResponseEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NIP90ContentDiscoveryRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NIP90UserDiscoveryResponseEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is NIP90UserDiscoveryRequestEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is LnZapPaymentRequestEvent -> { - consume(event, relay, wasVerified) - } - - is LnZapPaymentResponseEvent -> { - consume(event, relay, wasVerified) - } - - is LongTextNoteEvent -> { - consume(event, relay, wasVerified) - } - - is MetadataEvent -> { - consume(event, relay, wasVerified) - } - - is MuteListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NNSEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is NipTextEvent -> { - consume(event, relay, wasVerified) - } - - is OtsEvent -> { - consume(event, relay, wasVerified) - } - - is PictureEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is PrivateDmEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is PrivateOutboxRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ProfileBadgesEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ProxyRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PinListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PublicMessageEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is PeopleListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is RequestToVanishEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is CodeSnippetEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ZapPollEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } + is LnZapEvent -> consume(event, relay, wasVerified) + is LnZapRequestEvent -> consume(event, relay, wasVerified) + is OnchainZapEvent -> consume(event, relay, wasVerified) + is Bolt12ZapEvent -> consume(event, relay, wasVerified) + is LnZapPaymentRequestEvent -> consume(event, relay, wasVerified) + is LnZapPaymentResponseEvent -> consume(event, relay, wasVerified) + is LongTextNoteEvent -> consume(event, relay, wasVerified) + is MetadataEvent -> consume(event, relay, wasVerified) + is NipTextEvent -> consume(event, relay, wasVerified) + is OtsEvent -> consume(event, relay, wasVerified) + is PollResponseEvent -> consume(event, relay, wasVerified) + is ReactionEvent -> consume(event, relay, wasVerified) + is LabelEvent -> consume(event, relay, wasVerified) + is ContactCardEvent -> consume(event, relay, wasVerified) + is ReportEvent -> consume(event, relay, wasVerified) + is RepostEvent -> consume(event, relay, wasVerified) + is StatusEvent -> consume(event, relay, wasVerified) + is TextNoteModificationEvent -> consume(event, relay, wasVerified) + is ConcordChatEditEvent -> consume(event, relay, wasVerified) + is WikiNoteEvent -> consume(event, relay, wasVerified) + is PaymentTargetsEvent -> consume(event, relay, wasVerified) is ChatEvent -> { consumeRegularEvent(event, relay, wasVerified).also { @@ -4811,6 +4161,18 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { } } + is PollEvent -> { + consumeRegularEvent(event, relay, wasVerified).also { + attachToRelayGroupIfScoped(event, relay) + } + } + + is ThreadEvent -> { + consumeRegularEvent(event, relay, wasVerified).also { + attachThreadToRelayGroupIfScoped(event, relay) + } + } + // ------------------------------------------------------------------ // Buzz workspace kinds (block/buzz — the Buzz dialect of NIP-29). // Timeline kinds attach into the group's BuzzWorkspaceChannel; the @@ -4821,87 +4183,78 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { // BitChat `g` tag is absent, and it is handled below with the ephemerals. // ------------------------------------------------------------------ - is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified) is StreamMessageEditEvent -> consume(event, relay, wasVerified) - is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) is SystemMessageEvent -> consume(event, relay, wasVerified) is CanvasEvent -> consume(event, relay, wasVerified) - // Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003) - // and votes (45002) are store-only: the forum-thread detail loads them on demand by root. - is ForumPostEvent -> consumeBuzzForumPost(event, relay, wasVerified) - is ForumCommentEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ForumVoteEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is JobRequestEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobAcceptedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobProgressEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobResultEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobCancelEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is JobErrorEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is HuddleStartedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is HuddleParticipantJoinedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is HuddleParticipantLeftEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is HuddleEndedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - - // Buzz addressable/replaceable state. - is PersonaEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is TeamEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is ManagedAgentEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is EngramEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is WorkflowDefEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is EventReminderEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is PushLeaseEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is DmVisibilityEvent -> consume(event, relay, wasVerified) - is WindowBoundsEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is ArchivedIdentitiesListEvent -> consumeBaseReplaceable(event, relay, wasVerified) - - // Buzz store-only regular kinds (queryable state; no timeline row yet). - is StreamMessagePinnedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is StreamMessageBookmarkedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is StreamMessageScheduledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is StreamReminderEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is DmCreatedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is DmOpenEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is MemberAddedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) is MemberRemovedNotificationEvent -> consume(event, relay, wasVerified) is RelayMembershipListEvent -> consume(event, relay, wasVerified) is RelayAddMemberEvent -> consume(event, relay, wasVerified) is RelayRemoveMemberEvent -> consume(event, relay, wasVerified) - is AgentTurnMetricEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ModerationBanEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ModerationTimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ModerationUntimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ModerationResolveReportEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ProductFeedbackEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is RelayAdminAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is RelayAdminRemoveMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is RelayAdminChangeRoleEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is SetWorkspaceProfileEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ArchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is UnarchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ArchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is UnarchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is HuddleGuidelinesEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowTriggeredEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowStepStartedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowStepCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowStepFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowCancelledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowApprovalRequestedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowApprovalGrantedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowApprovalDeniedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is WorkflowTriggerEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ApprovalGrantEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ApprovalDenyEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) + is DmVisibilityEvent -> consume(event, relay, wasVerified) - // Buzz relay-signed sidecars and audit projections: store-only, queryable. - is AuditEntryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is ChannelSummaryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) - is PresenceSnapshotEvent -> consumeBuzzRegularEvent(event, relay, wasVerified) + // Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003) + // and votes (45002) are store-only: the forum-thread detail loads them on demand by root. + is ForumPostEvent -> consumeBuzzForumPost(event, relay, wasVerified) + + is StreamMessageV2Event, + is StreamMessageDiffEvent, + is JobRequestEvent, + is JobAcceptedEvent, + is JobProgressEvent, + is JobResultEvent, + is JobCancelEvent, + is JobErrorEvent, + is HuddleStartedEvent, + is HuddleParticipantJoinedEvent, + is HuddleParticipantLeftEvent, + is HuddleEndedEvent, + -> consumeBuzzTimelineEvent(event, relay, wasVerified) + + // Buzz store-only regular kinds (queryable state; no timeline row yet), + // plus relay-signed sidecars and audit projections. + is ForumCommentEvent, + is ForumVoteEvent, + is StreamMessagePinnedEvent, + is StreamMessageBookmarkedEvent, + is StreamMessageScheduledEvent, + is StreamReminderEvent, + is DmCreatedEvent, + is DmOpenEvent, + is DmAddMemberEvent, + is DmHideEvent, + is MemberAddedNotificationEvent, + is AgentTurnMetricEvent, + is ModerationBanEvent, + is ModerationTimeoutEvent, + is ModerationUntimeoutEvent, + is ModerationResolveReportEvent, + is ProductFeedbackEvent, + is RelayAdminAddMemberEvent, + is RelayAdminRemoveMemberEvent, + is RelayAdminChangeRoleEvent, + is SetWorkspaceProfileEvent, + is ArchiveRequestEvent, + is UnarchiveRequestEvent, + is ArchivedIdentityEvent, + is UnarchivedIdentityEvent, + is HuddleGuidelinesEvent, + is WorkflowTriggeredEvent, + is WorkflowStepStartedEvent, + is WorkflowStepCompletedEvent, + is WorkflowStepFailedEvent, + is WorkflowCompletedEvent, + is WorkflowFailedEvent, + is WorkflowCancelledEvent, + is WorkflowApprovalRequestedEvent, + is WorkflowApprovalGrantedEvent, + is WorkflowApprovalDeniedEvent, + is WorkflowTriggerEvent, + is ApprovalGrantEvent, + is ApprovalDenyEvent, + is AuditEntryEvent, + is ChannelSummaryEvent, + is PresenceSnapshotEvent, + -> consumeBuzzRegularEvent(event, relay, wasVerified) // Buzz ephemeral signals: transient by definition (20000-29999) — do not // pollute the note store, and do NOT mark the dialect from them (they are @@ -4926,165 +4279,211 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { // pairing before any workspace relationship is established. is PairingEvent -> false - is PollEvent -> { - consumeRegularEvent(event, relay, wasVerified).also { - attachToRelayGroupIfScoped(event, relay) - } - } + // ============================================================ + // Replaceable / addressable kinds with no per-kind logic: + // the newest version per address is kept, older ones dropped. + // New kinds without custom consume logic go in one of the two + // groups below — an unlisted kind falls into the else branch + // and is rejected as unsupported. + // ============================================================ + is AcceptedBadgeSetEvent, + is AdvertisedRelayListEvent, + is AppDefinitionEvent, + is AppRecommendationEvent, + is AppSpecificDataEvent, + is AttestationEvent, + is AttestationRequestEvent, + is AttestorRecommendationEvent, + is AttestorProficiencyEvent, + is AudioTrackEvent, + is BadgeDefinitionEvent, + is BlockedRelayListEvent, + is BlossomServersEvent, + is NestsServersEvent, + is BroadcastRelayListEvent, + is BookmarkListEvent, + is OldBookmarkListEvent, + is CalendarEvent, + is CalendarDateSlotEvent, + is CalendarTimeSlotEvent, + is CalendarRSVPEvent, + is CashuWalletEvent, + is NutzapInfoEvent, + is ChannelListEvent, + is ChatMessageRelayListEvent, + is Bolt12OfferListEvent, + is ClassifiedsEvent, + is FundraiserEvent, + is BirdexEvent, + is Ps1SaveEvent, + is CommunityDefinitionEvent, + is CommunityListEvent, + is ContactListEvent, + is EmojiPackEvent, + is EmojiPackSelectionEvent, + is EphemeralChatListEvent, + // NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + + // servers. Replaceable like its sibling lists; RelayGroupListState reads it from the + // addressable cache, so it must be stored (it was silently dropped before). + is SimpleGroupListEvent, + // Concord private joined-communities list (kind 13302). Replaceable, self-encrypted; + // ConcordChannelListState observes it via the addressable cache (Address(13302, me, "")), + // so — exactly like the 10009 list above — it must be stored replaceably or the Concord + // hub stays empty even after the event arrives. + is ConcordCommunityListEvent, + // The relay-signed NIP-29 39004 AV-participants addressable is durable group state. + is GroupParticipantsEvent, + is ExternalIdentitiesEvent, + is FileServersEvent, + is FollowListEvent, + is GeohashListEvent, + is GitRepositoryEvent, + is GitRepositoryStateEvent, + is UserGraspListEvent, + is RootSiteEvent, + is NamedSiteEvent, + is RootNappletEvent, + is NamedNappletEvent, + is RelayFeedsListEvent, + is KeyPackageEvent, + is KeyPackageRelayListEvent, + is LiveChessGameChallengeEvent, + is LiveChessGameAcceptEvent, + is LiveChessMoveEvent, + is LiveChessGameEndEvent, + is LiveChessDrawOfferEvent, + is HashtagListEvent, + is FavoriteAlgoFeedsListEvent, + is IndexerRelayListEvent, + is InteractiveStoryPrologueEvent, + is InteractiveStorySceneEvent, + is InteractiveStoryReadingStateEvent, + is InterestSetEvent, + is LabeledBookmarkListEvent, + is MeetingSpaceEvent, + is MeetingRoomEvent, + is MusicTrackEvent, + is MusicPlaylistEvent, + is AuthoredPodcastsEvent, + is FavoritePodcastsListEvent, + is Podcasting20EpisodeEvent, + is Podcasting20TrailerEvent, + is MuteListEvent, + is NNSEvent, + is PrivateOutboxRelayListEvent, + is ProfileBadgesEvent, + is ProxyRelayListEvent, + is PinListEvent, + is PeopleListEvent, + // Buzz addressable/replaceable state. + is PersonaEvent, + is TeamEvent, + is ManagedAgentEvent, + is AgentProfileEvent, + is EngramEvent, + is WorkflowDefEvent, + is EventReminderEvent, + is PushLeaseEvent, + is WindowBoundsEvent, + is ArchivedIdentitiesListEvent, + is RelayDiscoveryEvent, + is RelayMonitorEvent, + is RelaySetEvent, + is ReleaseArtifactSetEvent, + is SearchRelayListEvent, + is SoftwareApplicationEvent, + is TrustedRelayListEvent, + is TrustProviderListEvent, + is VideoHorizontalEvent, + is VideoVerticalEvent, + is WebBookmarkEvent, + is ExerciseTemplateEvent, + -> consumeBaseReplaceable(event, relay, wasVerified) - is ThreadEvent -> { - consumeRegularEvent(event, relay, wasVerified).also { - attachThreadToRelayGroupIfScoped(event, relay) - } - } - - is PollResponseEvent -> { - consume(event, relay, wasVerified) - } - - is RoadEventReportEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is RoadEventConfirmationEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is RelayDiscoveryEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is RelayMonitorEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ReactionEvent -> { - consume(event, relay, wasVerified) - } - - is LabelEvent -> { - consume(event, relay, wasVerified) - } - - is ContactCardEvent -> { - consume(event, relay, wasVerified) - } - - is RelaySetEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is ReportEvent -> { - consume(event, relay, wasVerified) - } - - is RepostEvent -> { - consume(event, relay, wasVerified) - } - - is ReleaseArtifactSetEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is SealedRumorEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is SearchRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is SoftwareApplicationEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is SoftwareAssetEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is StatusEvent -> { - consume(event, relay, wasVerified) - } - - is TextNoteEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is TextNoteModificationEvent -> { - consume(event, relay, wasVerified) - } - - is ConcordChatEditEvent -> { - consume(event, relay, wasVerified) - } - - is TorrentEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is TorrentCommentEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is TrustedRelayListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is TrustProviderListEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is VideoHorizontalEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is VideoNormalEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is VideoVerticalEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is VideoShortEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is VoiceEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is VoiceReplyEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is WakeUpEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is WebBookmarkEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is WelcomeEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is WikiNoteEvent -> { - consume(event, relay, wasVerified) - } - - is WorkoutRecordEvent -> { - consumeRegularEvent(event, relay, wasVerified) - } - - is ExerciseTemplateEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) - } - - is PaymentTargetsEvent -> { - consume(event, relay, wasVerified) - } + // ============================================================ + // Regular kinds with no per-kind logic: stored as plain notes. + // ============================================================ + is AudioHeaderEvent, + is BadgeAwardEvent, + is CallAnswerEvent, + is CallHangupEvent, + is CallIceCandidateEvent, + is CallOfferEvent, + is CallRejectEvent, + is CallRenegotiateEvent, + is CashuTokenEvent, + is CashuSpendingHistoryEvent, + is CashuMintQuoteEvent, + // NIP-87 Cashu mint discovery + recommendations: all three are kind 3xxxx + // (parameterized-replaceable per the spec) but neither CashuMintEvent / + // FedimintEvent / MintRecommendationEvent extends AddressableEvent in Quartz + // today, so consumeBaseReplaceable's `check(event is AddressableEvent)` would + // crash. Route them as regular events — downstream consumers + // (CashuMintDirectoryState, CashuWalletState) already dedupe by (pubKey, dTag) + // and keep the newest. + is CashuMintEvent, + is FedimintEvent, + is MintRecommendationEvent, + is ChatMessageEncryptedFileHeaderEvent, + is ChatMessageEvent, + is BirdDetectionEvent, + is CommentEvent, + // The NIP-29 9xxx moderation actions and join/leave requests are regular + // one-shot events the relay is authoritative for (it applies them and + // republishes the 39000/39001/39002); we store them so they're queryable, + // but we don't act on them client-side. + is PutUserEvent, + is RemoveUserEvent, + is EditMetadataEvent, + is DeleteEventEvent, + is UpdatePinListEvent, + is DeleteGroupEvent, + is CreateGroupEvent, + is CreateInviteEvent, + is JoinRequestEvent, + is LeaveRequestEvent, + is FhirResourceEvent, + is FileHeaderEvent, + is ProfileGalleryEntryEvent, + is FileStorageHeaderEvent, + is GoalEvent, + is GroupEvent, + is GitIssueEvent, + is GitReplyEvent, + is GitPatchEvent, + is GitPullRequestEvent, + is GitPullRequestUpdateEvent, + is GitStatusEvent, + is ChessGameEvent, + is JesterEvent, + is HighlightEvent, + is PodcastEpisodeEvent, + is NIP90StatusEvent, + is NIP90ContentDiscoveryResponseEvent, + is NIP90ContentDiscoveryRequestEvent, + is NIP90UserDiscoveryResponseEvent, + is NIP90UserDiscoveryRequestEvent, + is PictureEvent, + is PrivateDmEvent, + is PublicMessageEvent, + is RequestToVanishEvent, + is CodeSnippetEvent, + is ZapPollEvent, + is RoadEventReportEvent, + is RoadEventConfirmationEvent, + is SealedRumorEvent, + is SoftwareAssetEvent, + is TextNoteEvent, + is TorrentEvent, + is TorrentCommentEvent, + is VideoNormalEvent, + is VideoShortEvent, + is VoiceEvent, + is VoiceReplyEvent, + is WakeUpEvent, + is WelcomeEvent, + is WorkoutRecordEvent, + -> consumeRegularEvent(event, relay, wasVerified) else -> { Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false } From 2e90cc24ba14f35a01b7b4613761e1109b02c9ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 16:44:31 +0000 Subject: [PATCH 061/227] feat(quartz): move NIP-50 extension handling into the stores; add IEventStore seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IEventStore contract now defines the seams a store implementation needs instead of having the relay layer decide for it: - NIP-50 search extensions: LiveEventStore no longer strips key:value tokens before the store sees them. Filter.search reaches every store verbatim, and each implementation decides which extensions it supports — the store, not the middleware, is the only component that knows whether sort:rank is a directive or noise. The built-in SQLite and filesystem stores strip at their own boundary (their FTS engines would otherwise error on / literally match the tokens), preserving the NIP-50 'ignore unsupported extensions' behavior end to end. Extension-aware stores need no side channel to recover the raw string anymore. Fs delete now checks emptiness on the stripped filter so an extensions-only search cannot wipe the store. - Caller identity: new StoreQueryContext coroutine-context element, installed by LiveEventStore around every REQ/COUNT store call when the connection has NIP-42-authenticated pubkeys. Observer-relative stores (web-of-trust ranking, for-you relevance) read it off the coroutine context; ranking context only, never match-set changes. - Negentropy liveness: snapshotIdsForNegentropy gains an optional onProgress hook so mirrors syncing huge corpora get a running count through the interface type instead of a concrete-class overload. SQLite reports from its row loop; the interface default streams and reports too. - FtsReindexProgress.cursor documented as opaque and store-defined so resumable-reindex callers never assume id semantics. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011hH4RY2AUwfMZ54RkMiT45 --- quartz/RELAY.md | 28 ++-- .../cache/interning/InterningEventStore.kt | 3 +- .../relay/server/backend/LiveEventStore.kt | 62 ++++--- .../quartz/nip01Core/store/IEventStore.kt | 54 ++++++- .../nip01Core/store/ObservableEventStore.kt | 3 +- .../nip01Core/store/StoreQueryContext.kt | 65 ++++++++ .../nip01Core/store/sqlite/EventStore.kt | 3 +- .../nip01Core/store/sqlite/QueryBuilder.kt | 10 +- .../store/sqlite/SQLiteEventStore.kt | 41 +++-- .../relay/server/StoreQueryContextTest.kt | 152 ++++++++++++++++++ .../nip01Core/store/sqlite/SearchTest.kt | 23 +++ .../sqlite/SnapshotIdsForNegentropyTest.kt | 119 ++++++++++++++ .../quartz/nip01Core/store/fs/FsEventStore.kt | 21 ++- .../quartz/nip01Core/store/fs/FsSearchTest.kt | 41 +++++ 14 files changed, 571 insertions(+), 54 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/StoreQueryContext.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/StoreQueryContextTest.kt diff --git a/quartz/RELAY.md b/quartz/RELAY.md index 01bc5fc736..e5010ed069 100644 --- a/quartz/RELAY.md +++ b/quartz/RELAY.md @@ -259,15 +259,25 @@ q.domain // "example.com" q.nsfwIncluded // false (NIP-50 default is true when the token is absent) ``` -The SQLite store expects `Filter.search` to be plain FTS text: `:` is FTS5 -column-filter syntax, so a raw `include:spam` reaching MATCH raises "no such -column: include" instead of matching. Strip the tokens before querying with -`SearchQuery.stripExtensions(raw)` or `filter.strippingSearchExtensions()` — -an extensions-only query collapses to an empty search, which imposes no -constraint (NIP-50: unsupported extensions are ignored, not match-nothing). -The storage-backed server path (`NostrServer` / `LiveEventStore`) already does -this, so relays like geode comply out of the box; `EventSource` backends get -the raw string because a real search backend wants the extensions. +`Filter.search` reaches every backend **verbatim**, extension tokens included +— the relay layer never rewrites it. Which extensions are directives and which +are noise is a property of the store, so the `IEventStore` contract puts the +decision there: a store that implements an extension (rank profiles, trust +floors, observer-relative scoring) parses the raw string with +`SearchQuery.parse`; a store that doesn't must ignore the tokens per NIP-50 +(not match them as literal text, not return nothing). The built-in SQLite and +filesystem stores do the latter by stripping at their own boundary with +`filter.strippingSearchExtensions()` — FTS5 treats `:` as column-filter syntax, +so a raw `include:spam` reaching MATCH would raise "no such column: include" — +and an extensions-only query collapses to an empty search, which imposes no +constraint. Relays like geode therefore comply out of the box, and +`EventSource` backends likewise get the raw string. + +Observer-relative stores (web-of-trust ranking, "for-you" relevance) read the +caller's NIP-42-authenticated pubkeys from the coroutine context via +`StoreQueryContext` — `LiveEventStore` installs it around every REQ/COUNT store +call for authenticated connections. It is ranking context only: it may reorder +results, never change which events match. A search/redirector relay is just a custom policy (or, for computed results, a custom `IEventStore` whose `query` answers the REQ) that reads the parsed query: diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/interning/InterningEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/interning/InterningEventStore.kt index 2f87070657..c84544c3cf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/interning/InterningEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/interning/InterningEventStore.kt @@ -116,7 +116,8 @@ class InterningEventStore( override suspend fun snapshotIdsForNegentropy( filters: List, maxEntries: Int?, - ): List = inner.snapshotIdsForNegentropy(filters, maxEntries) + onProgress: ((collected: Int) -> Unit)?, + ): List = inner.snapshotIdsForNegentropy(filters, maxEntries, onProgress) override suspend fun delete(filter: Filter) = inner.delete(filter) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/LiveEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/LiveEventStore.kt index 9f5f8c4ecc..345a4337a5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/LiveEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/LiveEventStore.kt @@ -27,10 +27,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.FilterIndex import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IdAndTime import com.vitorpamplona.quartz.nip01Core.store.RawEvent -import com.vitorpamplona.quartz.nip50Search.strippingSearchExtensions +import com.vitorpamplona.quartz.nip01Core.store.StoreQueryContext import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.withContext import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.AtomicLong import kotlin.concurrent.atomics.AtomicReference @@ -50,14 +51,14 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi * match. This avoids the quadratic O(N_subscribers × N_filters_per_sub) per-event walk * that a SharedFlow-based broadcast would do. * - * NIP-50 `search` strings are handed to the store with their `key:value` - * extension tokens stripped ([strippingSearchExtensions]): the SQLite FTS - * backend treats `:` as column-filter syntax, so a raw `include:spam` - * would raise "no such column" instead of matching. Per NIP-50, an - * unsupported extension is ignored — an extensions-only search therefore - * becomes unconstrained, not match-nothing. Relays that *do* implement - * extensions serve search through an [EventSource] backend, which - * receives the raw string. + * NIP-50 `search` strings are handed to the store **verbatim**, extension + * tokens included: whether `include:spam` is a directive, ignorable noise, + * or poison for a text engine is a property of the store, so each + * [IEventStore] implementation makes that call itself (see the NIP-50 + * contract on [IEventStore] — the built-in SQLite and filesystem stores + * strip the tokens at their own boundary). This layer also installs a + * [StoreQueryContext] around each store call so observer-relative stores + * can read the connection's NIP-42 identity. * * @property store The underlying persistent storage for events. * @property ingest The group-commit writer pipeline. Accepted events fan out via the @@ -208,6 +209,25 @@ class LiveEventStore( } } + /** + * Runs [block] with a [StoreQueryContext] carrying the connection's + * NIP-42-authenticated pubkeys, so observer-relative stores can read + * the caller's identity from the coroutine context. Skipped entirely + * for unauthenticated connections — the element's contract is + * "present means non-empty". + */ + private suspend inline fun withCallerIdentity( + ctx: RequestContext, + crossinline block: suspend () -> R, + ): R { + val users = ctx.authenticatedUsers + return if (users.isEmpty()) { + block() + } else { + withContext(StoreQueryContext(users)) { block() } + } + } + /** * With deferred FTS, a search query must first drain the catch-up * backlog — that keeps NIP-50 results exactly as fresh as the @@ -248,9 +268,11 @@ class LiveEventStore( index.register(filters, sub) try { - store.query(filters.strippingSearchExtensions()) { event -> - seen.record(event.id) - onEach(event) + withCallerIdentity(ctx) { + store.query(filters) { event -> + seen.record(event.id) + onEach(event) + } } onEose() // Drop the dedupe set so the live path stops paying for @@ -295,9 +317,11 @@ class LiveEventStore( index.register(filters, sub) try { - store.rawQuery(filters.strippingSearchExtensions()) { raw -> - seen.record(raw.id) - onEachStored(raw) + withCallerIdentity(ctx) { + store.rawQuery(filters) { raw -> + seen.record(raw.id) + onEachStored(raw) + } } onEose() seen.release() @@ -312,7 +336,7 @@ class LiveEventStore( filters: List, ): Int { drainFtsIfSearching(filters) - return store.count(filters.strippingSearchExtensions()) + return withCallerIdentity(ctx) { store.count(filters) } } /** @@ -320,7 +344,7 @@ class LiveEventStore( * needs the full set of event ids matching the filter at the * moment the NEG-OPEN arrives, not a streamed/live result. */ - suspend fun snapshotQuery(filter: Filter): List = store.query(filter.strippingSearchExtensions()) + suspend fun snapshotQuery(filter: Filter): List = store.query(filter) /** * Multi-filter snapshot. Unions the per-filter results and @@ -333,7 +357,7 @@ class LiveEventStore( val seen = HashSet() val merged = ArrayList() for (f in filters) { - for (e in store.query(f.strippingSearchExtensions())) { + for (e in store.query(f)) { if (seen.add(e.id)) merged += e } } @@ -353,7 +377,7 @@ class LiveEventStore( override suspend fun snapshotIdsForNegentropy( filters: List, maxEntries: Int?, - ): List = store.snapshotIdsForNegentropy(filters.strippingSearchExtensions(), maxEntries) + ): List = store.snapshotIdsForNegentropy(filters, maxEntries) // ------------------------------------------------------------------ // NIP-77 snapshot cache diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index 690106500b..8f77817522 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -28,6 +28,35 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +/** + * Storage contract for Nostr events: insert, filter-query, count, delete, + * NIP-50 full-text search, and NIP-77 negentropy snapshots. + * + * ## NIP-50 `search` contract + * + * Query/count/delete filters arrive with [Filter.search] **verbatim as the + * client sent it**, `key:value` extension tokens included (`include:spam`, + * `domain:example.com`, `language:en`, …). No layer above the store rewrites + * the string, so each implementation decides which extensions it supports: + * + * - A store that interprets an extension (rank profiles, trust floors, + * observer-relative scoring, …) reads it from the raw string — parse it + * with [com.vitorpamplona.quartz.nip50Search.SearchQuery.parse]. + * - Extensions the store does NOT support must be **ignored, not matched as + * literal text and not treated as match-nothing** (NIP-50: relays "SHOULD + * ignore extensions they don't support"). Stores whose text engine would + * choke on the raw tokens strip them first with + * [com.vitorpamplona.quartz.nip50Search.strippingSearchExtensions] — this + * is what the built-in SQLite and filesystem stores do. An extensions-only + * search therefore collapses to an unconstrained query. + * + * ## Caller identity + * + * Ranked/observer-relative stores can read the caller's NIP-42-authenticated + * identity from the coroutine context via [StoreQueryContext]; the relay + * layer installs it around every REQ/COUNT-driven store call. It is ranking + * context only and absent for unauthenticated callers. + */ interface IEventStore : AutoCloseable { companion object { /** @@ -37,6 +66,12 @@ interface IEventStore : AutoCloseable { * small enough that a pause request is honoured promptly. */ const val DEFAULT_FTS_REINDEX_BATCH = 1000 + + /** + * Suggested [snapshotIdsForNegentropy] `onProgress` cadence: report + * the running count roughly every this many collected entries. + */ + const val NEGENTROPY_PROGRESS_EVERY = 1000 } /** @@ -173,6 +208,13 @@ interface IEventStore : AutoCloseable { * guard). The +1 sentinel lets the caller distinguish "exactly * capped" from "too many to fit". * + * [onProgress] is a liveness hook for corpora large enough that the + * walk takes minutes: implementations SHOULD invoke it every + * [NEGENTROPY_PROGRESS_EVERY]-ish collected entries with the running + * count. Callers must not rely on any particular cadence — a store + * that answers from an index may legitimately never call it. Pass + * `null` (the default) to opt out at zero cost. + * * Default implementation falls back to the full-decode path so * non-SQLite stores stay correct; SQLite overrides with a direct * `SELECT id, created_at` against the `query_by_created_at_id` @@ -182,8 +224,13 @@ interface IEventStore : AutoCloseable { suspend fun snapshotIdsForNegentropy( filters: List, maxEntries: Int? = null, + onProgress: ((collected: Int) -> Unit)? = null, ): List { - val all = query(filters).map { IdAndTime(it.createdAt, it.id) } + val all = ArrayList() + query(filters) { event -> + all.add(IdAndTime(event.createdAt, event.id)) + if (onProgress != null && all.size % NEGENTROPY_PROGRESS_EVERY == 0) onProgress(all.size) + } return if (maxEntries != null && all.size > maxEntries + 1) { all.subList(0, maxEntries + 1) } else { @@ -258,6 +305,11 @@ interface IEventStore : AutoCloseable { * * Process roughly [batchSize] events starting from [resumeFrom] * (`null` = from the beginning) and return a [FtsReindexProgress]. + * [resumeFrom] / [FtsReindexProgress.cursor] is an **opaque, + * store-defined string**: callers persist it and pass it back + * unchanged, and must never parse, order, or compare it (SQLite + * encodes a kind + row id; another store may carry an engine + * continuation token). * Drive it in a loop, feeding [FtsReindexProgress.cursor] back in, * until [FtsReindexProgress.done] is `true`: * diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt index ca0d372d6d..c314c4e3b2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt @@ -192,7 +192,8 @@ class ObservableEventStore( override suspend fun snapshotIdsForNegentropy( filters: List, maxEntries: Int?, - ): List = inner.snapshotIdsForNegentropy(filters, maxEntries) + onProgress: ((collected: Int) -> Unit)?, + ): List = inner.snapshotIdsForNegentropy(filters, maxEntries, onProgress) override suspend fun liveNegentropySnapshot(maxEntries: Int) = inner.liveNegentropySnapshot(maxEntries) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/StoreQueryContext.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/StoreQueryContext.kt new file mode 100644 index 0000000000..37d4524442 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/StoreQueryContext.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +/** + * The published caller-identity seam between a relay layer and an + * [IEventStore]: who is asking, carried on the coroutine context so it + * crosses the store boundary — and any decorator in between — without + * widening every `query`/`count` signature. + * + * The storage-backed relay path (`LiveEventStore`) installs this element + * around every REQ/COUNT-driven store call when the connection has + * NIP-42-authenticated pubkeys. A store whose results are + * observer-relative — web-of-trust ranking, "for-you" relevance, trust + * floors — reads it back: + * + * ``` + * val observer = coroutineContext[StoreQueryContext]?.observer + * ``` + * + * Contract: this is **ranking context only**. It may reorder or score + * results; it must never change *which* events match a filter — access + * control belongs to the relay policy layer, not the store. The element + * is absent for unauthenticated callers (and for direct store use outside + * a relay), so every read needs a null-tolerant fallback such as an + * operator-configured default observer. + */ +class StoreQueryContext( + /** + * The pubkeys authenticated on the calling connection via NIP-42, in + * no particular order. Never empty — the relay layer skips installing + * the element instead of installing an empty one. + */ + val authenticatedUsers: Set, +) : AbstractCoroutineContextElement(StoreQueryContext) { + companion object Key : CoroutineContext.Key + + /** + * Convenience for the common single-identity case: one of + * [authenticatedUsers], or `null` when the set is empty. + */ + val observer: HexKey? get() = authenticatedUsers.firstOrNull() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index 13545cef2e..50ffdd6e37 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -82,7 +82,8 @@ class EventStore( override suspend fun snapshotIdsForNegentropy( filters: List, maxEntries: Int?, - ): List = store.snapshotIdsForNegentropy(filters, maxEntries) + onProgress: ((collected: Int) -> Unit)?, + ): List = store.snapshotIdsForNegentropy(filters, maxEntries, onProgress) override suspend fun liveNegentropySnapshot(maxEntries: Int) = store.liveNegentropySnapshot(maxEntries) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt index 15c69596f7..4cd73ae9f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.isAddressable import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IdAndTime import com.vitorpamplona.quartz.nip01Core.store.RawEvent import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where @@ -258,6 +259,7 @@ class QueryBuilder( filters: List, db: SQLiteConnection, maxEntries: Int? = null, + onProgress: ((collected: Int) -> Unit)? = null, ): List { val inner = if (filters.size == 1) { @@ -278,7 +280,7 @@ class QueryBuilder( } else { inner } - return db.runIdAndTimeQuery(query) + return db.runIdAndTimeQuery(query, onProgress) } private fun toSnapshotIdsSql( @@ -454,7 +456,10 @@ class QueryBuilder( return QuerySpec(sql, clause.args) } - private fun SQLiteConnection.runIdAndTimeQuery(query: QuerySpec): List = + private fun SQLiteConnection.runIdAndTimeQuery( + query: QuerySpec, + onProgress: ((collected: Int) -> Unit)? = null, + ): List = prepare(query.sql).use { stmt -> query.args.forEachIndexed { index, arg -> stmt.bindText(index + 1, arg) @@ -462,6 +467,7 @@ class QueryBuilder( val results = ArrayList() while (stmt.step()) { results.add(IdAndTime(stmt.getLong(1), stmt.getText(0))) + if (onProgress != null && results.size % IEventStore.NEGENTROPY_PROGRESS_EVERY == 0) onProgress(results.size) } results } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index aee8f0c40f..515ca0b916 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.store.IdAndTime import com.vitorpamplona.quartz.nip01Core.store.RawEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip40Expiration.isExpired +import com.vitorpamplona.quartz.nip50Search.strippingSearchExtensions import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip77Negentropy.LiveNegentropyIndex @@ -537,48 +538,58 @@ class SQLiteEventStore( } } - suspend fun query(filter: Filter): List = pool.useReader { queryBuilder.query(filter, it) } + // Every filter-accepting read/delete strips NIP-50 extension tokens at + // this boundary: FTS5 treats `:` as column-filter syntax, so a raw + // `include:spam` reaching MATCH raises "no such column" instead of + // matching. This store implements no extensions, and NIP-50 says + // unsupported extensions are ignored — an extensions-only search + // therefore collapses to an unconstrained query, not match-nothing. + // Stores that DO interpret extensions receive the raw string from the + // relay layer and make their own call (see the IEventStore contract). - suspend fun query(filters: List): List = pool.useReader { queryBuilder.query(filters, it) } + suspend fun query(filter: Filter): List = pool.useReader { queryBuilder.query(filter.strippingSearchExtensions(), it) } + + suspend fun query(filters: List): List = pool.useReader { queryBuilder.query(filters.strippingSearchExtensions(), it) } suspend fun query( filter: Filter, onEach: (T) -> Unit, - ) = pool.useReader { queryBuilder.query(filter, it, onEach) } + ) = pool.useReader { queryBuilder.query(filter.strippingSearchExtensions(), it, onEach) } suspend fun query( filters: List, onEach: (T) -> Unit, - ) = pool.useReader { queryBuilder.query(filters, it, onEach) } + ) = pool.useReader { queryBuilder.query(filters.strippingSearchExtensions(), it, onEach) } - suspend fun rawQuery(filter: Filter): List = pool.useReader { queryBuilder.rawQuery(filter, it) } + suspend fun rawQuery(filter: Filter): List = pool.useReader { queryBuilder.rawQuery(filter.strippingSearchExtensions(), it) } - suspend fun rawQuery(filters: List): List = pool.useReader { queryBuilder.rawQuery(filters, it) } + suspend fun rawQuery(filters: List): List = pool.useReader { queryBuilder.rawQuery(filters.strippingSearchExtensions(), it) } suspend fun rawQuery( filter: Filter, onEach: (RawEvent) -> Unit, - ) = pool.useReader { queryBuilder.rawQuery(filter, it, onEach) } + ) = pool.useReader { queryBuilder.rawQuery(filter.strippingSearchExtensions(), it, onEach) } suspend fun rawQuery( filters: List, onEach: (RawEvent) -> Unit, - ) = pool.useReader { queryBuilder.rawQuery(filters, it, onEach) } + ) = pool.useReader { queryBuilder.rawQuery(filters.strippingSearchExtensions(), it, onEach) } - suspend fun planQuery(filter: Filter) = pool.useReader { queryBuilder.planQuery(filter, seedModule.hasher(it), it) } + suspend fun planQuery(filter: Filter) = pool.useReader { queryBuilder.planQuery(filter.strippingSearchExtensions(), seedModule.hasher(it), it) } - suspend fun planQuery(filters: List) = pool.useReader { queryBuilder.planQuery(filters, seedModule.hasher(it), it) } + suspend fun planQuery(filters: List) = pool.useReader { queryBuilder.planQuery(filters.strippingSearchExtensions(), seedModule.hasher(it), it) } - suspend fun count(filter: Filter): Int = pool.useReader { queryBuilder.count(filter, it) } + suspend fun count(filter: Filter): Int = pool.useReader { queryBuilder.count(filter.strippingSearchExtensions(), it) } - suspend fun count(filters: List): Int = pool.useReader { queryBuilder.count(filters, it) } + suspend fun count(filters: List): Int = pool.useReader { queryBuilder.count(filters.strippingSearchExtensions(), it) } suspend fun authorsMissingOutbox(): List = pool.useReader { queryBuilder.authorsMissingKind(AdvertisedRelayListEvent.KIND, it) } suspend fun snapshotIdsForNegentropy( filters: List, maxEntries: Int? = null, - ): List = pool.useReader { queryBuilder.snapshotIdsForNegentropy(filters, it, maxEntries) } + onProgress: ((collected: Int) -> Unit)? = null, + ): List = pool.useReader { queryBuilder.snapshotIdsForNegentropy(filters.strippingSearchExtensions(), it, maxEntries, onProgress) } // The invalidate() calls below run INSIDE useWriter: dropping the // index while still holding the mutex means no NEG-OPEN can seal a @@ -588,7 +599,7 @@ class SQLiteEventStore( suspend fun delete(filter: Filter) { pool.useWriter { - queryBuilder.delete(filter, it) + queryBuilder.delete(filter.strippingSearchExtensions(), it) // Delete-by-filter can't itemize what it removed; drop the // live index and let the next NEG-OPEN rebuild from one scan. liveNegentropyIndex?.invalidate() @@ -597,7 +608,7 @@ class SQLiteEventStore( suspend fun delete(filters: List) { pool.useWriter { - queryBuilder.delete(filters, it) + queryBuilder.delete(filters.strippingSearchExtensions(), it) liveNegentropyIndex?.invalidate() } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/StoreQueryContextTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/StoreQueryContextTest.kt new file mode 100644 index 0000000000..a39cdc5c24 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/StoreQueryContextTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.RawEvent +import com.vitorpamplona.quartz.nip01Core.store.StoreQueryContext +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.coroutines.coroutineContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The caller-identity seam: `LiveEventStore` must install a + * [StoreQueryContext] with the connection's NIP-42-authenticated pubkeys + * around every REQ-driven store call — and must NOT install one for + * unauthenticated connections — so observer-relative stores can read who + * is asking straight off the coroutine context. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class StoreQueryContextTest { + private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + private val sig = "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce" + private val relayUrl = NormalizedRelayUrl("wss://relay.example.com/") + + private fun hexId(n: Int): String = n.toString().padStart(64, '0') + + /** + * Delegates everything to a real SQLite store but records the + * [StoreQueryContext] visible during each query — both the decoding + * and the zero-decode replay path, since the session may use either. + */ + private class ContextRecordingStore( + private val inner: IEventStore, + ) : IEventStore by inner { + val contexts = mutableListOf() + + override suspend fun query( + filters: List, + onEach: (T) -> Unit, + ) { + contexts.add(coroutineContext[StoreQueryContext]) + inner.query(filters, onEach) + } + + override suspend fun rawQuery( + filters: List, + onEach: (RawEvent) -> Unit, + ) { + contexts.add(coroutineContext[StoreQueryContext]) + inner.rawQuery(filters, onEach) + } + } + + private fun createServer( + dispatcher: CoroutineDispatcher, + store: IEventStore, + policyBuilder: () -> IRelayPolicy, + ): NostrServer = + NostrServer( + store = store, + policyBuilder = policyBuilder, + parentContext = dispatcher, + ) + + @Test + fun unauthenticatedReqSeesNoStoreQueryContext() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = ContextRecordingStore(EventStore(null)) + val server = createServer(dispatcher, store) { EmptyPolicy } + val session = server.connect { } + + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + + assertEquals(1, store.contexts.size, "the REQ must have reached the store") + assertNull(store.contexts[0], "no NIP-42 auth → no StoreQueryContext element") + + server.close() + } + + @Test + fun authenticatedReqCarriesTheObserverToTheStore() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = ContextRecordingStore(EventStore(null)) + val server = createServer(dispatcher, store) { FullAuthPolicy(relayUrl) } + val messages = mutableListOf() + val session = server.connect { messages.add(it) } + + // NIP-42 handshake, mirroring NostrServerAuthTest. + val challenge = (OptimizedJsonMapper.fromJsonToMessage(messages[0]) as AuthMessage).challenge + val auth = + RelayAuthEvent( + id = hexId(99), + pubKey = pubkey, + createdAt = TimeUtils.now(), + tags = + arrayOf( + arrayOf("relay", relayUrl.url), + arrayOf("challenge", challenge), + ), + content = "", + sig = sig, + ) + session.receive("""["AUTH",${auth.toJson()}]""") + assertTrue(session.requestContext.authenticatedUsers.contains(pubkey)) + + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + + assertEquals(1, store.contexts.size, "the REQ must have reached the store") + val seen = store.contexts[0] + assertEquals(setOf(pubkey), seen?.authenticatedUsers) + assertEquals(pubkey, seen?.observer) + + server.close() + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt index 615e6fc47e..689466b310 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -98,6 +98,29 @@ class SearchTest : BaseDBTest() { db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) } + @Test + fun testSearchExtensionTokensAreIgnoredByTheStore() = + forEachDB { db -> + db.store.insertEvent(comment) + + // The store — not the relay layer — owns the NIP-50 extension + // decision now: a raw `include:spam` reaching FTS5 MATCH would + // raise "no such column: include", so the store strips the + // token at its own boundary and matches on the terms alone. + db.assertQuery(comment, Filter(search = "testing include:spam")) + + // Extensions-only search: unsupported extensions are ignored + // (NIP-50), so the constraint drops and the query is + // unconstrained — not match-nothing, not an FTS error. + db.assertQuery(comment, Filter(search = "include:spam language:en")) + + // Delete-by-filter must strip the same way, and an + // extensions-only search collapses to an empty filter, which + // deletes nothing (safe-by-default contract). + db.store.delete(Filter(search = "language:en")) + db.assertQuery(comment, Filter(search = "testing")) + } + @Test fun testFtsCleanedUpAfterReplaceableRotation() = forEachDB { db -> diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SnapshotIdsForNegentropyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SnapshotIdsForNegentropyTest.kt index fe1355160f..30d6a29b1a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SnapshotIdsForNegentropyTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SnapshotIdsForNegentropyTest.kt @@ -20,9 +20,13 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.FtsReindexProgress +import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -101,4 +105,119 @@ class SnapshotIdsForNegentropyTest : BaseDBTest() { val whole = db.snapshotIdsForNegentropy(listOf(filter), maxEntries = 100) assertEquals(30, whole.size) } + + @Test + fun reportsProgressWhileCollecting() = + runBlocking { + // Single store (not forEachDB): this exercises the row-loop + // cadence, which is indexing-strategy independent, and needs + // enough rows to cross the reporting interval twice. + val db = EventStore(dbName = null) + try { + val total = IEventStore.NEGENTROPY_PROGRESS_EVERY * 2 + 500 + db.batchInsert( + List(total) { i -> + Event( + id = i.toString(16).padStart(64, '0'), + pubKey = PUBKEY, + createdAt = 1_700_000_000L + i, + kind = 1, + tags = emptyArray(), + content = "e$i", + sig = SIG, + ) + }, + ) + + val ticks = mutableListOf() + val all = + db.snapshotIdsForNegentropy( + listOf(Filter(kinds = listOf(1))), + onProgress = { ticks.add(it) }, + ) + assertEquals(total, all.size) + assertEquals( + listOf(IEventStore.NEGENTROPY_PROGRESS_EVERY, IEventStore.NEGENTROPY_PROGRESS_EVERY * 2), + ticks, + "onProgress must tick the running count once per interval", + ) + + // Omitting the callback stays the zero-cost path. + assertEquals(total, db.snapshotIdsForNegentropy(listOf(Filter(kinds = listOf(1)))).size) + } finally { + db.close() + } + } + + @Test + fun interfaceDefaultReportsProgressForStoresWithoutAnOverride() = + runBlocking { + // A store that only streams events, so snapshotIdsForNegentropy + // resolves to the IEventStore default implementation. + val total = IEventStore.NEGENTROPY_PROGRESS_EVERY + 500 + val store = StreamOnlyStore(total) + + val ticks = mutableListOf() + val all = store.snapshotIdsForNegentropy(listOf(Filter()), onProgress = { ticks.add(it) }) + assertEquals(total, all.size) + assertEquals(listOf(IEventStore.NEGENTROPY_PROGRESS_EVERY), ticks) + + // maxEntries truncation still applies on the default path. + val capped = store.snapshotIdsForNegentropy(listOf(Filter()), maxEntries = 10) + assertEquals(11, capped.size) + } + + private companion object { + const val PUBKEY = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + const val SIG = "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce" + } + + /** Minimal [IEventStore]: streams [total] synthetic events, nothing else. */ + private class StreamOnlyStore( + private val total: Int, + ) : IEventStore { + override val relay = null + + @Suppress("UNCHECKED_CAST") + override suspend fun query( + filters: List, + onEach: (T) -> Unit, + ) { + repeat(total) { i -> + onEach(Event(i.toString(16).padStart(64, '0'), PUBKEY, 1_700_000_000L + i, 1, emptyArray(), "", SIG) as T) + } + } + + override suspend fun insert(event: Event) = error("unused") + + override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) = error("unused") + + override suspend fun query(filter: Filter): List = error("unused") + + override suspend fun query(filters: List): List = error("unused") + + override suspend fun query( + filter: Filter, + onEach: (T) -> Unit, + ) = error("unused") + + override suspend fun count(filter: Filter): Int = error("unused") + + override suspend fun count(filters: List): Int = error("unused") + + override suspend fun delete(filter: Filter) = error("unused") + + override suspend fun delete(filters: List) = error("unused") + + override suspend fun deleteExpiredEvents() = error("unused") + + override suspend fun reindexFullTextSearch() = error("unused") + + override suspend fun reindexFullTextSearch( + resumeFrom: String?, + batchSize: Int, + ): FtsReindexProgress = error("unused") + + override fun close() {} + } } diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt index 9062867baf..38cc465830 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip50Search.strippingSearchExtensions import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.utils.EventFactory import com.vitorpamplona.quartz.utils.TimeUtils @@ -299,15 +300,22 @@ open class FsEventStore( filter: Filter, onEach: (T) -> Unit, ) { - val limit = filter.limit ?: Int.MAX_VALUE + // Every read path (list/streaming query, count, delete-by-filter, + // negentropy snapshot) funnels through here, so this is the one + // place this store strips NIP-50 extension tokens: the FTS token + // index would otherwise require `include` / `spam` as literal AND + // terms. NIP-50 says unsupported extensions are ignored, so an + // extensions-only search collapses to an unconstrained query. + val stripped = filter.strippingSearchExtensions() + val limit = stripped.limit ?: Int.MAX_VALUE if (limit <= 0) return var emitted = 0 val seenIds = HashSet() - for (candidate in planner.plan(filter)) { + for (candidate in planner.plan(stripped)) { if (emitted >= limit) break if (!seenIds.add(candidate.id)) continue val event = readEvent(candidate.id) ?: continue - if (!filter.match(event)) continue + if (!stripped.match(event)) continue @Suppress("UNCHECKED_CAST") onEach(event as T) emitted++ @@ -348,7 +356,10 @@ open class FsEventStore( */ override suspend fun delete(filter: Filter) = lockManager.withWriteLock { - if (filter.isEmpty()) return@withWriteLock + // The emptiness check runs on the STRIPPED filter: an + // extensions-only search (`search = "language:en"`) strips to + // an empty filter, which must delete nothing — not everything. + if (filter.strippingSearchExtensions().isEmpty()) return@withWriteLock val ids = ArrayList() query(filter) { ids.add(it.id) } ids.forEach { deleteLocked(it) } @@ -357,7 +368,7 @@ open class FsEventStore( /** See [delete] for the empty-filter contract. */ override suspend fun delete(filters: List) = lockManager.withWriteLock { - val nonEmpty = filters.filterNot { it.isEmpty() } + val nonEmpty = filters.filterNot { it.strippingSearchExtensions().isEmpty() } if (nonEmpty.isEmpty()) return@withWriteLock val ids = HashSet() query(nonEmpty) { ids.add(it.id) } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt index 1dde055e7f..ccd9400ae9 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt @@ -359,4 +359,45 @@ class FsSearchTest { // And a search no longer finds it. assertEquals(emptyList(), store.query(Filter(search = "zzz")).map { it.id }) } + + // ------------------------------------------------------------------ + // NIP-50 extension tokens + // ------------------------------------------------------------------ + + @Test + fun `extension tokens are ignored, not matched as literal AND terms`() = + runBlocking { + val n = note("bitcoin rocks", ts = 100) + store.insert(n) + + // The store owns the extension decision: `include:spam` must be + // stripped at the store boundary, not required as the literal + // tokens `include` + `spam` (which would match nothing here). + assertEquals( + listOf(n.id), + store.query(Filter(search = "bitcoin include:spam")).map { it.id }, + ) + assertEquals(1, store.count(Filter(search = "bitcoin include:spam"))) + + // Extensions-only search collapses to an unconstrained query + // (NIP-50: unsupported extensions are ignored, not match-nothing). + assertEquals( + listOf(n.id), + store.query(Filter(search = "language:en")).map { it.id }, + ) + } + + @Test + fun `delete with an extensions-only search deletes nothing`() = + runBlocking { + val n = note("bitcoin rocks", ts = 100) + store.insert(n) + + // Stripped, `language:en` is an empty filter — and an empty + // filter must fall under delete's safe-by-default contract + // instead of matching (and wiping) the whole store. + store.delete(Filter(search = "language:en")) + + assertEquals(1, store.count(Filter())) + } } From 5311efe65ed388b31f5995e02ee0fa64ee25bdde Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 16:48:21 +0000 Subject: [PATCH 062/227] refactor: extract CachePruner and CacheSearch from LocalCache; move Dao out of ui Two read/reclaim policy clusters leave the LocalCache god object into sibling classes in the same package, each taking the cache as its only constructor dependency so the policies are testable in isolation: - CachePruner: cleanMemory/cleanObservers, the six prune passes (hidden/old/expired/superseded/replies+reactions), and the shared unlinkAndRemove removal primitive (with removeIfWrap and editedTargetIdOf). LocalCache.deleteNote and DecryptAndIndexProcessor now call pruner.unlinkAndRemove; MemoryTrimmingService drives cache.pruner.*. refreshDeletedNoteObservers becomes internal so the pruner can notify observers. - CacheSearch: findUsersStartingWith(username, account), findNotesStartingWith, and the three channel prefix searches, plus their private exclusion rules. Callers (SearchBarViewModel, AgentAttestationScreen, UserSuggestionState, BuzzNewDmViewModel) use cache.search.* directly - no delegating shims left behind. Also moves the Dao interface out of ui/actions/NewMessageTagger.kt into the model package where its implementor (LocalCache) and its types live, removing a model-layer interface defined in a UI file. All moved code is unchanged except for cache. qualification; behavior is identical. LocalCache.kt: 4554 -> 3921 lines. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../amethyst/model/CachePruner.kt | 492 +++++++++++++ .../amethyst/model/CacheSearch.kt | 266 +++++++ .../com/vitorpamplona/amethyst/model/Dao.kt | 37 + .../amethyst/model/LocalCache.kt | 661 +----------------- .../eventCache/MemoryTrimmingService.kt | 16 +- .../amethyst/ui/actions/NewMessageTagger.kt | 11 +- .../userSuggestions/UserSuggestionState.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../loggedIn/DecryptAndIndexProcessor.kt | 2 +- .../loggedIn/buzz/AgentAttestationScreen.kt | 5 +- .../loggedIn/buzz/BuzzNewDmViewModel.kt | 2 +- .../loggedIn/search/SearchBarViewModel.kt | 10 +- .../amethyst/NewMessageTaggerKeyParseTest.kt | 2 +- 13 files changed, 832 insertions(+), 676 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt new file mode 100644 index 0000000000..c906079d84 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt @@ -0,0 +1,492 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Memory-reclaim policy over the [LocalCache] stores: trims the soft caches, + * prunes hidden/old/expired/superseded events, and owns the shared + * [unlinkAndRemove] removal primitive that [LocalCache.deleteNote] also relies on. + * + * Pure policy — it holds no state of its own beyond the cache reference, so every + * function can be exercised against a populated cache in tests. Driven by + * `MemoryTrimmingService`. + */ +class CachePruner( + private val cache: LocalCache, +) { + fun cleanMemory() { + Log.d("LargeCache") { "Notes cleanup started. Current size: ${cache.notes.size()}" } + cache.notes.cleanUp() + Log.d("LargeCache") { "Notes cleanup completed. Remaining size: ${cache.notes.size()}" } + + Log.d("LargeCache") { "Addressables cleanup started. Current size: ${cache.addressables.size()}" } + cache.addressables.cleanUp() + Log.d("LargeCache") { "Addressables cleanup completed. Remaining size: ${cache.addressables.size()}" } + + Log.d("LargeCache") { "Users cleanup started. Current size: ${cache.users.size()}" } + cache.users.cleanUp() + Log.d("LargeCache") { "Users cleanup completed. Remaining size: ${cache.users.size()}" } + } + + fun cleanObservers() { + cache.notes.forEach { _, it -> it.clearFlow() } + cache.addressables.forEach { _, it -> it.clearFlow() } + } + + private fun pruneHiddenMessagesChannel( + channel: Channel, + account: Account, + ) { + val toBeRemoved = channel.pruneHiddenMessages(account) + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + unlinkAndRemove(it) + + childrenToBeRemoved.addAll(it.clearChildLinks()) + } + + unlinkAndRemove(childrenToBeRemoved) + + if (toBeRemoved.size > 100 || channel.notes.size() > 100) { + println( + "PRUNE: ${toBeRemoved.size} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", + ) + } + } + + fun pruneHiddenMessages(account: Account) { + cache.ephemeralChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } + + cache.geohashChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } + + cache.liveChatChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } + + cache.publicChatChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } + + cache.relayGroupChannels.forEach { _, channel -> + pruneHiddenMessagesChannel(channel, account) + } + } + + // 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by + // `NestsFeedFilter` so a presence still inside any feed's window + // can never be pruned. + private val presencePruneAgeSeconds = 20L * 60L + + private fun pruneOldMessagesChannel(channel: Channel) { + val toBeRemoved = channel.pruneOldMessages() + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + unlinkAndRemove(it) + + childrenToBeRemoved.addAll(it.clearChildLinks()) + } + + unlinkAndRemove(childrenToBeRemoved) + + // Audio-room presence is keyed separately from `notes` and + // never gets reaped by the top-N rule. Drop entries older + // than 2× the 10-min freshness window so the index doesn't + // grow unbounded with every author who ever heartbeat here. + if (channel is LiveActivitiesChannel) { + channel.pruneStalePresence(TimeUtils.now() - presencePruneAgeSeconds) + } + + if (toBeRemoved.size > 100 || channel.notes.size() > 100) { + println( + "PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", + ) + } + } + + fun pruneOldMessages() { + checkNotInMainThread() + + cache.ephemeralChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) + } + + cache.geohashChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) + } + + cache.liveChatChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) + } + + cache.publicChatChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) + } + + cache.relayGroupChannels.forEach { _, channel -> + pruneOldMessagesChannel(channel) + } + + cache.chatroomList.forEach { userHex, room -> + // History floors are pinned per scope on first advance; null means that window never paged + // history, so its cursors hold no position to misalign and nothing needs rewinding. Only the + // bands strictly BELOW a floor are this window's responsibility — a pruned message newer than + // the floor is the always-on live tail's concern, and rewinding history for it would needlessly + // re-page (and, for a busy room straddling the floor, mis-set the boundary). Hence the per-floor + // filter when accumulating below. + val giftWrapFloor = room.giftWrapHistory.floor + val accountNip04Floor = room.nip04History.floor + + room.rooms.map { key, chatroom -> + val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly() + + val childrenToBeRemoved = mutableListOf() + + // Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor. + // Gift wraps page by the OUTER wrap time (from the rumor-host index); NIP-04 by the event's + // own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor. + val giftWrapPruned = HashMap() + val accountNip04Pruned = HashMap() + val roomNip04Pruned = HashMap() + // chatroom.nip04History is lazy — only touch (allocate) it when this room actually drops a + // kind:4 message, so rooms that never paged conversation history pay nothing. + val roomNip04Floor = if (toBeRemoved.any { it.event is PrivateDmEvent }) chatroom.nip04History.floor else null + + toBeRemoved.forEach { note -> + when (val ev = note.event) { + is BaseDMGroupEvent -> + if (giftWrapFloor != null) { + val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt + if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) } + } + is PrivateDmEvent -> { + val until = ev.createdAt + if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.merge(it, until, ::maxOf) } + if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.merge(it, until, ::maxOf) } + } + } + + childrenToBeRemoved.addAll(removeIfWrap(note)) + unlinkAndRemove(note) + + childrenToBeRemoved.addAll(note.clearChildLinks()) + } + + unlinkAndRemove(childrenToBeRemoved) + + // Realign the windows so a relay that already paged past (or `done` below) the dropped band + // re-requests it on the next demand-advance instead of skipping the hole. + if (giftWrapPruned.isNotEmpty()) { + room.giftWrapHistory.rewindTo(giftWrapPruned) + Log.d("DMPagination") { "[giftwrap] window rewound after prune: ${giftWrapPruned.size} relay(s), newest pruned wrap @${giftWrapPruned.values.max()}" } + } + if (accountNip04Pruned.isNotEmpty()) { + room.nip04History.rewindTo(accountNip04Pruned) + Log.d("DMPagination") { "[rooms.nip04] window rewound after prune: ${accountNip04Pruned.size} relay(s), newest pruned @${accountNip04Pruned.values.max()}" } + } + if (roomNip04Pruned.isNotEmpty()) { + chatroom.nip04History.rewindTo(roomNip04Pruned) + Log.d("DMPagination") { "[convo.nip04] window rewound after prune of ${key.users.joinToString()}: ${roomNip04Pruned.size} relay(s), newest pruned @${roomNip04Pruned.values.max()}" } + } + + if (toBeRemoved.size > 1) { + println( + "PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept", + ) + } + } + } + } + + private fun removeIfWrap(note: Note): List { + val host = note.rumorHost ?: return emptyList() + + val children = mutableListOf() + cache.getNoteIfExists(host.id)?.let { hostNote -> + (hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId -> + cache.getNoteIfExists(sealId)?.let { sealNote -> + unlinkAndRemove(sealNote) + children.addAll(sealNote.clearChildLinks()) + } + } + unlinkAndRemove(hostNote) + children.addAll(hostNote.clearChildLinks()) + } + note.rumorHost = null + return children + } + + fun prunePastVersionsOfReplaceables() { + val toBeRemoved = + cache.notes.filter { _, note -> + val noteEvent = note.event + if (noteEvent is AddressableEvent) { + noteEvent.createdAt < + ( + cache.addressables + .get(noteEvent.address()) + ?.event + ?.createdAt ?: 0 + ) + } else { + false + } + } + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + val newerVersion = (it.event as? AddressableEvent)?.address()?.let { tag -> cache.addressables.get(tag) } + if (newerVersion != null) { + it.moveAllReferencesTo(newerVersion) + } + + unlinkAndRemove(it) + childrenToBeRemoved.addAll(it.clearChildLinks()) + } + + unlinkAndRemove(childrenToBeRemoved) + + if (toBeRemoved.size > 1) { + println("PRUNE: ${toBeRemoved.size} old version of addressables removed.") + } + } + + fun pruneRepliesAndReactions(accounts: Set) { + checkNotInMainThread() + + val toBeRemoved = + cache.notes.filter { _, note -> + ( + (note.event is TextNoteEvent && !note.isNewThread()) || + note.event is ReactionEvent || + note.event is LnZapEvent || + note.event is LnZapRequestEvent || + note.event is ReportEvent || + note.event is GenericRepostEvent + ) && + note.replyTo?.any { it.flowSet?.isInUse() == true } != true && + note.flowSet?.isInUse() != true && + // don't delete if observing. + note.author?.pubkeyHex !in + accounts && + // don't delete if it is the logged in account + note.event?.isTaggedUsers(accounts) != + true // don't delete if it's a notification to the logged in user + } + + val childrenToBeRemoved = mutableListOf() + + toBeRemoved.forEach { + unlinkAndRemove(it) + childrenToBeRemoved.addAll(it.clearChildLinks()) + } + + unlinkAndRemove(childrenToBeRemoved) + + if (toBeRemoved.size > 1) { + println("PRUNE: ${toBeRemoved.size} thread replies removed.") + } + } + + /** + * Unlinks [note] from everything in the cache that references it, then drops it + * from the notes map and notifies observers. This is the shared "unlink from + * above" half of removal, used by both the prune callers and [LocalCache.deleteNote]. + * + * It detaches the note from: + * - its parent notes (their replies/reactions/zaps/boosts/reports/labels maps); + * because event-level reports and torrent comments both carry the target in + * `replyTo`, [Note.removeNote] cleans those up here too; + * - its channels/gatherers (`inGatherers` is authoritative — `Channel.addNote` + * always registers the gatherer — and `getAnyChannel` is a belt-and-suspenders + * resolve so a note can never linger in a channel after leaving the cache); + * - the per-target indexes `replyTo` does NOT reach: user-level reports and + * reported addresses, contact cards, statuses, and poll responses. + * + * It deliberately does NOT touch the note's own children: prune callers collect + * them via [Note.clearChildLinks] and remove the subtree, while [LocalCache.deleteNote] + * keeps them and severs only their back-reference. Every per-target removal is + * idempotent, so the overlap between `replyTo` and the explicit indexes (e.g. an + * event-level report reachable both ways) is harmless. Addressable notes are + * dropped from the addressables map by the caller; this only removes from notes. + */ + fun unlinkAndRemove(note: Note) { + note.replyTo?.forEach { masterNote -> + masterNote.removeNote(note) + } + + note.inGatherers?.forEach { it.removeNote(note) } + + cache.getAnyChannel(note)?.removeNote(note) + + val noteEvent = note.event + + // Quote-repost boosts are tracked outside `replyTo` (see addQuoteBoosts), so + // detach this note from every quoted note's boosts here. + noteEvent?.taggedQuoteIds()?.forEach { quotedId -> + cache.getNoteIfExists(quotedId)?.removeBoost(note) + } + + // Edits (1010/3302/40003) are anchored on their target's Note.edits and carry no `replyTo` + // back-link, so the unlink above can't reach them — resolve the target by the edit's `e` tag + // and drop it there, or a deleted edit would keep overlaying its message. + editedTargetIdOf(noteEvent)?.let { cache.getNoteIfExists(it)?.removeEdit(note) } + + // OTS attestations (kind 1040) are likewise anchored on their target's Note.timestamps with + // no `replyTo` back-link — resolve the target by the `e` tag and drop the proof there. + if (noteEvent is OtsEvent) { + noteEvent.digestEventId()?.let { cache.getNoteIfExists(it)?.removeTimestamp(note) } + } + + if (noteEvent is ReportEvent) { + noteEvent.reportedAuthor().forEach { + cache.getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports -> + reports.removeReport(note) + reports.removeReportNamingUser(note) + } + } + + noteEvent.reportedPost().forEach { + cache.getNoteIfExists(it.eventId)?.removeReport(note) + } + + noteEvent.reportedAddresses().forEach { + cache.getAddressableNoteIfExists(it.address)?.removeReport(note) + } + } + + if (note is AddressableNote && noteEvent is ContactCardEvent) { + cache.getUserIfExists(noteEvent.aboutUser())?.cardsOrNull()?.removeCard(note) + } + + if (note is AddressableNote && noteEvent is StatusEvent) { + note.author?.statusStateOrNull()?.removeStatus(note) + } + + if (noteEvent is PollResponseEvent) { + noteEvent.poll()?.eventId?.let { + cache.getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(note) + } + } + + note.clearFlow() + + cache.notes.remove(note.idHex) + + cache.refreshDeletedNoteObservers(note) + } + + /** The id of the message/post an edit event targets (its `e` tag), across all three edit kinds. */ + private fun editedTargetIdOf(event: Event?): HexKey? = + when (event) { + is TextNoteModificationEvent -> event.editedNote()?.eventId + is ConcordChatEditEvent -> event.editedMessageId() + is StreamMessageEditEvent -> event.editedMessage() + else -> null + } + + fun unlinkAndRemove(nextToBeRemoved: List) { + nextToBeRemoved.forEach { note -> unlinkAndRemove(note) } + } + + fun pruneExpiredEvents() { + checkNotInMainThread() + + val now = TimeUtils.now() + val versionsToBeRemoved = cache.notes.filter { _, it -> it.event?.isExpirationBefore(now) == true } + val addressesToBeRemoved = cache.addressables.filter { _, it -> it.event?.isExpirationBefore(now) == true } + + val childrenToBeRemoved = mutableListOf() + + versionsToBeRemoved.forEach { + unlinkAndRemove(it) + childrenToBeRemoved.addAll(it.clearChildLinks()) + } + + addressesToBeRemoved.forEach { + unlinkAndRemove(it) + childrenToBeRemoved.addAll(it.clearChildLinks()) + } + + unlinkAndRemove(childrenToBeRemoved) + + if (versionsToBeRemoved.size > 1 || addressesToBeRemoved.size > 1) { + println("PRUNE: ${versionsToBeRemoved.size} events and ${addressesToBeRemoved.size} expired.") + } + } + + fun pruneHiddenEvents(account: Account) { + checkNotInMainThread() + + val childrenToBeRemoved = mutableListOf() + + val toBeRemoved = + account.hiddenUsers.flow.value.hiddenUsers.flatMap { userHex -> + (cache.notes.filter { _, it -> it.event?.pubKey == userHex } + cache.addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet() + } + + toBeRemoved.forEach { + unlinkAndRemove(it) + childrenToBeRemoved.addAll(it.clearChildLinks()) + } + + unlinkAndRemove(childrenToBeRemoved) + + println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt new file mode 100644 index 0000000000..3af6813384 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CacheSearch.kt @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel +import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.tagValueContains +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag +import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.utils.DualCase +import kotlinx.coroutines.CancellationException + +/** + * Prefix/content search over the [LocalCache] stores: users, notes, and the + * public-chat / ephemeral / live-activity channel maps. Pure read-side policy — + * no state beyond the cache reference — so ranking and filtering rules can be + * tested against a populated cache. + */ +class CacheSearch( + private val cache: LocalCache, +) { + fun findUsersStartingWith( + username: String, + forAccount: Account?, + ): List { + if (username.isBlank()) return emptyList() + + checkNotInMainThread() + + val key = decodePublicKeyAsHexOrNull(username) + + if (key != null) { + val user = cache.getUserIfExists(key) + if (user != null) { + return listOfNotNull(user) + } + } + + val dualCase = + listOf( + DualCase(username.lowercase(), username.uppercase()), + ) + + val finds = + cache.users.filter { _, user: User -> + val metadata = user.metadataOrNull() + if (metadata == null) { + user.pubkeyHex.startsWith(username, true) || + user.pubkeyNpub().startsWith(username, true) + } else { + ( + metadata.anyNameOrAddressContains(dualCase) || + user.pubkeyHex.startsWith(username, true) || + user.pubkeyNpub().startsWith(username, true) + ) && + (forAccount == null || (!forAccount.isHidden(user) && !metadata.anyPropertyContains(forAccount.hiddenUsers.flow.value.hiddenWordsCase))) + } + } + + val findsFollowing = finds.associateWith { forAccount?.isFollowing(it) == true } + val anyNameStartsWith = finds.associateWith { it.metadataOrNull()?.anyNameStartsWith(dualCase) == true } + val anyAddressStartsWith = finds.associateWith { it.metadataOrNull()?.anyAddressStartsWith(dualCase) == true } + val displayNames = finds.associateWith { it.toBestDisplayName().lowercase() } + + return finds.sortedWith( + compareBy( + { findsFollowing[it] == false }, + { anyNameStartsWith[it] == false }, + { anyAddressStartsWith[it] == false }, + { displayNames[it] }, + { it.pubkeyHex }, + ), + ) + } + + /** + * Will return true if supplied note is one of events to be excluded from + * search results. + */ + private fun excludeNoteEventFromSearchResults(note: Note): Boolean = + ( + note.event is GenericRepostEvent || + note.event is RepostEvent || + note.event is CommunityPostApprovalEvent || + note.event is ReactionEvent || + note.event is LnZapEvent || + note.event is LnZapRequestEvent || + note.event is FileHeaderEvent || + note.event is MetadataEvent || + note.event is ContactListEvent || + note.event is AppSpecificDataEvent + ) + + /** + * Tag names whose values should not match text searches: the `client` tag + * names the app that published the event (searching for "Amethyst" would + * otherwise return every event posted through Amethyst), and `p`/`e`/`a`/`alt` + * values are ids or descriptions of other events, not content of this one. + */ + private val excludedTagNamesFromSearch = + setOf( + ClientTag.TAG_NAME, + PTag.TAG_NAME, + ETag.TAG_NAME, + ATag.TAG_NAME, + AltTag.TAG_NAME, + ) + + fun findNotesStartingWith( + text: String, + hiddenUsers: HiddenUsersState, + ): List { + checkNotInMainThread() + + if (text.isBlank()) return emptyList() + + val key = decodeEventIdAsHexOrNull(text) + + if (key != null) { + val note = cache.getNoteIfExists(key) + val noteEvent = note?.event + val newNote = + if (noteEvent is AddressableEvent) { + val addressableNote = cache.getAddressableNoteIfExists(noteEvent.address()) + if (addressableNote?.event?.id == note.idHex) { + addressableNote + } else { + note + } + } else { + note + } + + if ((newNote != null) && !excludeNoteEventFromSearchResults(newNote)) { + return listOfNotNull(newNote) + } + } + + return cache.notes.filter { _, note -> + if (note.event is AddressableEvent) { + return@filter false + } + + if (excludeNoteEventFromSearchResults(note)) { + return@filter false + } + + if (note.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true || + note.idHex.startsWith(text, true) + ) { + return@filter !note.isHiddenFor(hiddenUsers.flow.value) + } + + if (note.event?.isContentEncoded() == false) { + return@filter if (!note.isHiddenFor(hiddenUsers.flow.value)) { + note.event?.content?.contains(text, true) ?: false + } else { + false + } + } + + return@filter false + } + + cache.addressables.filter { _, addressable -> + if (excludeNoteEventFromSearchResults(addressable)) { + return@filter false + } + + if (addressable.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true || + addressable.idHex.startsWith(text, true) + ) { + return@filter !addressable.isHiddenFor(hiddenUsers.flow.value) + } + + if (addressable.event?.isContentEncoded() == false) { + return@filter if (!addressable.isHiddenFor(hiddenUsers.flow.value)) { + addressable.event?.content?.contains(text, true) ?: false + } else { + false + } + } + + return@filter false + } + } + + fun findPublicChatChannelsStartingWith(text: String): List { + if (text.isBlank()) return emptyList() + + val key = decodeEventIdAsHexOrNull(text) + if (key != null) { + cache.getPublicChatChannelIfExists(key)?.let { + return listOf(it) + } + } + + return cache.publicChatChannels.filter { _, channel -> + channel.anyNameStartsWith(text) + } + } + + fun findEphemeralChatChannelsStartingWith(text: String): List { + if (text.isBlank()) return emptyList() + + return cache.ephemeralChannels.filter { _, channel -> + channel.anyNameStartsWith(text) + } + } + + fun findLiveActivityChannelsStartingWith(text: String): List { + if (text.isBlank()) return emptyList() + + try { + val parsed = Nip19Parser.uriToRoute(text)?.entity + if (parsed is NAddress && parsed.kind == LiveActivitiesEvent.KIND) { + return listOf(cache.getOrCreateLiveChannel(parsed.address())) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + } + + return cache.liveChatChannels.filter { _, channel -> + channel.anyNameStartsWith(text) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt new file mode 100644 index 0000000000..c1e3443d94 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * The minimal get-or-create surface of the event cache, used by callers (like + * `NewMessageTagger`) that resolve user/note references while composing without + * needing the full [LocalCache] API. + */ +interface Dao { + fun getOrCreateUser(hex: HexKey): User + + fun getOrCreateNote(hex: HexKey): Note + + fun getOrCreateAddressableNote(address: Address): AddressableNote? +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index e7cdefae15..7ffa080688 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -52,11 +52,9 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.service.nwc.NwcPaymentTracker import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.LocalCache.observeEvents -import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver import com.vitorpamplona.amethyst.service.BundledInsert import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.note.dateFormatter import com.vitorpamplona.quartz.buzz.aeEngrams.EngramEvent import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent @@ -184,7 +182,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isAddressable import com.vitorpamplona.quartz.nip01Core.core.isRegular import com.vitorpamplona.quartz.nip01Core.core.isReplaceable -import com.vitorpamplona.quartz.nip01Core.core.tagValueContains import com.vitorpamplona.quartz.nip01Core.crypto.checkSignature import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider @@ -202,8 +199,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -211,7 +206,6 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -219,9 +213,6 @@ import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds -import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull -import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.entities.Entity import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed @@ -261,7 +252,6 @@ import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent @@ -278,7 +268,6 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent -import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent @@ -374,7 +363,6 @@ import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent -import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent @@ -410,7 +398,6 @@ import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEven import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent -import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -545,6 +532,15 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { */ val observables = FilterIndex() + /** + * Memory-reclaim policy (soft-cache trims + hidden/old/expired/superseded event + * pruning) and the shared [CachePruner.unlinkAndRemove] removal primitive. + */ + val pruner = CachePruner(this) + + /** Prefix/content search over users, notes, and channels. */ + val search = CacheSearch(this) + fun Filter.match(note: Note): Boolean { val event = note.event return if (event != null) { @@ -1031,7 +1027,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { // quoted note. Count it as a boost so it shows in the quoted note's repost // counter alongside kind:6/kind:16 reposts. The quoted note is deliberately // kept out of `replyTo` so the quote still renders as a root post in the home - // feed (see Note.isNewThread); deletion cleanup lives in unlinkAndRemove. + // feed (see Note.isNewThread); deletion cleanup lives in CachePruner.unlinkAndRemove. addQuoteBoosts(event, note, replyTo) refreshNewNoteObservers(note) @@ -1635,14 +1631,14 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { * * Removal has two halves: unlinking the note from everything that points AT it * (its parents, channels, and the per-user report/card/status/poll indexes — - * all handled by [unlinkAndRemove]); and dealing with the note's OWN children + * all handled by [CachePruner.unlinkAndRemove]); and dealing with the note's OWN children * (the notes that point at IT). The delete path and the prune path share the * first half and differ only on the second: * - delete (here): the children are independent events and stay in the cache; * [Note.detachFromChildren] only severs their back-reference so the removed * shell can neither leak (held alive by a child's `replyTo`) nor be later * resurrected by `computeReplyTo` as a second Note for the same id. - * - prune (see [unlinkAndRemove] callers): the whole child subtree is removed. + * - prune (see [CachePruner.unlinkAndRemove] callers): the whole child subtree is removed. * * Rumors additionally drop the envelope notes that delivered them. */ @@ -1651,7 +1647,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { deleteNote.detachFromChildren() - unlinkAndRemove(deleteNote) + pruner.unlinkAndRemove(deleteNote) } /** @@ -3116,637 +3112,8 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { return false } - fun findUsersStartingWith( - username: String, - forAccount: Account?, - ): List { - if (username.isBlank()) return emptyList() - - checkNotInMainThread() - - val key = decodePublicKeyAsHexOrNull(username) - - if (key != null) { - val user = getUserIfExists(key) - if (user != null) { - return listOfNotNull(user) - } - } - - val dualCase = - listOf( - DualCase(username.lowercase(), username.uppercase()), - ) - - val finds = - users.filter { _, user: User -> - val metadata = user.metadataOrNull() - if (metadata == null) { - user.pubkeyHex.startsWith(username, true) || - user.pubkeyNpub().startsWith(username, true) - } else { - ( - metadata.anyNameOrAddressContains(dualCase) || - user.pubkeyHex.startsWith(username, true) || - user.pubkeyNpub().startsWith(username, true) - ) && - (forAccount == null || (!forAccount.isHidden(user) && !metadata.anyPropertyContains(forAccount.hiddenUsers.flow.value.hiddenWordsCase))) - } - } - - val findsFollowing = finds.associateWith { forAccount?.isFollowing(it) == true } - val anyNameStartsWith = finds.associateWith { it.metadataOrNull()?.anyNameStartsWith(dualCase) == true } - val anyAddressStartsWith = finds.associateWith { it.metadataOrNull()?.anyAddressStartsWith(dualCase) == true } - val displayNames = finds.associateWith { it.toBestDisplayName().lowercase() } - - return finds.sortedWith( - compareBy( - { findsFollowing[it] == false }, - { anyNameStartsWith[it] == false }, - { anyAddressStartsWith[it] == false }, - { displayNames[it] }, - { it.pubkeyHex }, - ), - ) - } - - /** - * Will return true if supplied note is one of events to be excluded from - * search results. - */ - private fun excludeNoteEventFromSearchResults(note: Note): Boolean = - ( - note.event is GenericRepostEvent || - note.event is RepostEvent || - note.event is CommunityPostApprovalEvent || - note.event is ReactionEvent || - note.event is LnZapEvent || - note.event is LnZapRequestEvent || - note.event is FileHeaderEvent || - note.event is MetadataEvent || - note.event is ContactListEvent || - note.event is AppSpecificDataEvent - ) - - /** - * Tag names whose values should not match text searches: the `client` tag - * names the app that published the event (searching for "Amethyst" would - * otherwise return every event posted through Amethyst), and `p`/`e`/`a`/`alt` - * values are ids or descriptions of other events, not content of this one. - */ - private val excludedTagNamesFromSearch = - setOf( - ClientTag.TAG_NAME, - PTag.TAG_NAME, - ETag.TAG_NAME, - ATag.TAG_NAME, - AltTag.TAG_NAME, - ) - - fun findNotesStartingWith( - text: String, - hiddenUsers: HiddenUsersState, - ): List { - checkNotInMainThread() - - if (text.isBlank()) return emptyList() - - val key = decodeEventIdAsHexOrNull(text) - - if (key != null) { - val note = getNoteIfExists(key) - val noteEvent = note?.event - val newNote = - if (noteEvent is AddressableEvent) { - val addressableNote = getAddressableNoteIfExists(noteEvent.address()) - if (addressableNote?.event?.id == note.idHex) { - addressableNote - } else { - note - } - } else { - note - } - - if ((newNote != null) && !excludeNoteEventFromSearchResults(newNote)) { - return listOfNotNull(newNote) - } - } - - return notes.filter { _, note -> - if (note.event is AddressableEvent) { - return@filter false - } - - if (excludeNoteEventFromSearchResults(note)) { - return@filter false - } - - if (note.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true || - note.idHex.startsWith(text, true) - ) { - return@filter !note.isHiddenFor(hiddenUsers.flow.value) - } - - if (note.event?.isContentEncoded() == false) { - return@filter if (!note.isHiddenFor(hiddenUsers.flow.value)) { - note.event?.content?.contains(text, true) ?: false - } else { - false - } - } - - return@filter false - } + - addressables.filter { _, addressable -> - if (excludeNoteEventFromSearchResults(addressable)) { - return@filter false - } - - if (addressable.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true || - addressable.idHex.startsWith(text, true) - ) { - return@filter !addressable.isHiddenFor(hiddenUsers.flow.value) - } - - if (addressable.event?.isContentEncoded() == false) { - return@filter if (!addressable.isHiddenFor(hiddenUsers.flow.value)) { - addressable.event?.content?.contains(text, true) ?: false - } else { - false - } - } - - return@filter false - } - } - - fun findPublicChatChannelsStartingWith(text: String): List { - if (text.isBlank()) return emptyList() - - val key = decodeEventIdAsHexOrNull(text) - if (key != null) { - getPublicChatChannelIfExists(key)?.let { - return listOf(it) - } - } - - return publicChatChannels.filter { _, channel -> - channel.anyNameStartsWith(text) - } - } - - fun findEphemeralChatChannelsStartingWith(text: String): List { - if (text.isBlank()) return emptyList() - - return ephemeralChannels.filter { _, channel -> - channel.anyNameStartsWith(text) - } - } - - fun findLiveActivityChannelsStartingWith(text: String): List { - if (text.isBlank()) return emptyList() - - try { - val parsed = Nip19Parser.uriToRoute(text)?.entity - if (parsed is NAddress && parsed.kind == LiveActivitiesEvent.KIND) { - return listOf(getOrCreateLiveChannel(parsed.address())) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - } - - return liveChatChannels.filter { _, channel -> - channel.anyNameStartsWith(text) - } - } - fun getPeopleListNotesFor(user: User): List = addressables.filter(PeopleListEvent.KIND, user.pubkeyHex) - fun cleanMemory() { - Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" } - notes.cleanUp() - Log.d("LargeCache") { "Notes cleanup completed. Remaining size: ${notes.size()}" } - - Log.d("LargeCache") { "Addressables cleanup started. Current size: ${addressables.size()}" } - addressables.cleanUp() - Log.d("LargeCache") { "Addressables cleanup completed. Remaining size: ${addressables.size()}" } - - Log.d("LargeCache") { "Users cleanup started. Current size: ${users.size()}" } - users.cleanUp() - Log.d("LargeCache") { "Users cleanup completed. Remaining size: ${users.size()}" } - } - - fun cleanObservers() { - notes.forEach { _, it -> it.clearFlow() } - addressables.forEach { _, it -> it.clearFlow() } - } - - fun pruneHiddenMessagesChannel( - channel: Channel, - account: Account, - ) { - val toBeRemoved = channel.pruneHiddenMessages(account) - - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - unlinkAndRemove(it) - - childrenToBeRemoved.addAll(it.clearChildLinks()) - } - - unlinkAndRemove(childrenToBeRemoved) - - if (toBeRemoved.size > 100 || channel.notes.size() > 100) { - println( - "PRUNE: ${toBeRemoved.size} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", - ) - } - } - - fun pruneHiddenMessages(account: Account) { - ephemeralChannels.forEach { _, channel -> - pruneHiddenMessagesChannel(channel, account) - } - - geohashChannels.forEach { _, channel -> - pruneHiddenMessagesChannel(channel, account) - } - - liveChatChannels.forEach { _, channel -> - pruneHiddenMessagesChannel(channel, account) - } - - publicChatChannels.forEach { _, channel -> - pruneHiddenMessagesChannel(channel, account) - } - - relayGroupChannels.forEach { _, channel -> - pruneHiddenMessagesChannel(channel, account) - } - } - - // 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by - // `NestsFeedFilter` so a presence still inside any feed's window - // can never be pruned. - private val PRESENCE_PRUNE_AGE_SECONDS = 20L * 60L - - fun pruneOldMessagesChannel(channel: Channel) { - val toBeRemoved = channel.pruneOldMessages() - - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - unlinkAndRemove(it) - - childrenToBeRemoved.addAll(it.clearChildLinks()) - } - - unlinkAndRemove(childrenToBeRemoved) - - // Audio-room presence is keyed separately from `notes` and - // never gets reaped by the top-N rule. Drop entries older - // than 2× the 10-min freshness window so the index doesn't - // grow unbounded with every author who ever heartbeat here. - if (channel is LiveActivitiesChannel) { - channel.pruneStalePresence(TimeUtils.now() - PRESENCE_PRUNE_AGE_SECONDS) - } - - if (toBeRemoved.size > 100 || channel.notes.size() > 100) { - println( - "PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", - ) - } - } - - fun pruneOldMessages() { - checkNotInMainThread() - - ephemeralChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - geohashChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - liveChatChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - publicChatChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - relayGroupChannels.forEach { _, channel -> - pruneOldMessagesChannel(channel) - } - - chatroomList.forEach { userHex, room -> - // History floors are pinned per scope on first advance; null means that window never paged - // history, so its cursors hold no position to misalign and nothing needs rewinding. Only the - // bands strictly BELOW a floor are this window's responsibility — a pruned message newer than - // the floor is the always-on live tail's concern, and rewinding history for it would needlessly - // re-page (and, for a busy room straddling the floor, mis-set the boundary). Hence the per-floor - // filter when accumulating below. - val giftWrapFloor = room.giftWrapHistory.floor - val accountNip04Floor = room.nip04History.floor - - room.rooms.map { key, chatroom -> - val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly() - - val childrenToBeRemoved = mutableListOf() - - // Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor. - // Gift wraps page by the OUTER wrap time (from the rumor-host index); NIP-04 by the event's - // own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor. - val giftWrapPruned = HashMap() - val accountNip04Pruned = HashMap() - val roomNip04Pruned = HashMap() - // chatroom.nip04History is lazy — only touch (allocate) it when this room actually drops a - // kind:4 message, so rooms that never paged conversation history pay nothing. - val roomNip04Floor = if (toBeRemoved.any { it.event is PrivateDmEvent }) chatroom.nip04History.floor else null - - toBeRemoved.forEach { note -> - when (val ev = note.event) { - is BaseDMGroupEvent -> - if (giftWrapFloor != null) { - val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt - if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) } - } - is PrivateDmEvent -> { - val until = ev.createdAt - if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.merge(it, until, ::maxOf) } - if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.merge(it, until, ::maxOf) } - } - } - - childrenToBeRemoved.addAll(removeIfWrap(note)) - unlinkAndRemove(note) - - childrenToBeRemoved.addAll(note.clearChildLinks()) - } - - unlinkAndRemove(childrenToBeRemoved) - - // Realign the windows so a relay that already paged past (or `done` below) the dropped band - // re-requests it on the next demand-advance instead of skipping the hole. - if (giftWrapPruned.isNotEmpty()) { - room.giftWrapHistory.rewindTo(giftWrapPruned) - Log.d("DMPagination") { "[giftwrap] window rewound after prune: ${giftWrapPruned.size} relay(s), newest pruned wrap @${giftWrapPruned.values.max()}" } - } - if (accountNip04Pruned.isNotEmpty()) { - room.nip04History.rewindTo(accountNip04Pruned) - Log.d("DMPagination") { "[rooms.nip04] window rewound after prune: ${accountNip04Pruned.size} relay(s), newest pruned @${accountNip04Pruned.values.max()}" } - } - if (roomNip04Pruned.isNotEmpty()) { - chatroom.nip04History.rewindTo(roomNip04Pruned) - Log.d("DMPagination") { "[convo.nip04] window rewound after prune of ${key.users.joinToString()}: ${roomNip04Pruned.size} relay(s), newest pruned @${roomNip04Pruned.values.max()}" } - } - - if (toBeRemoved.size > 1) { - println( - "PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept", - ) - } - } - } - } - - fun removeIfWrap(note: Note): List { - val host = note.rumorHost ?: return emptyList() - - val children = mutableListOf() - getNoteIfExists(host.id)?.let { hostNote -> - (hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId -> - getNoteIfExists(sealId)?.let { sealNote -> - unlinkAndRemove(sealNote) - children.addAll(sealNote.clearChildLinks()) - } - } - unlinkAndRemove(hostNote) - children.addAll(hostNote.clearChildLinks()) - } - note.rumorHost = null - return children - } - - fun prunePastVersionsOfReplaceables() { - val toBeRemoved = - notes.filter { _, note -> - val noteEvent = note.event - if (noteEvent is AddressableEvent) { - noteEvent.createdAt < - (addressables.get(noteEvent.address())?.event?.createdAt ?: 0) - } else { - false - } - } - - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - val newerVersion = (it.event as? AddressableEvent)?.address()?.let { tag -> addressables.get(tag) } - if (newerVersion != null) { - it.moveAllReferencesTo(newerVersion) - } - - unlinkAndRemove(it) - childrenToBeRemoved.addAll(it.clearChildLinks()) - } - - unlinkAndRemove(childrenToBeRemoved) - - if (toBeRemoved.size > 1) { - println("PRUNE: ${toBeRemoved.size} old version of addressables removed.") - } - } - - fun pruneRepliesAndReactions(accounts: Set) { - checkNotInMainThread() - - val toBeRemoved = - notes.filter { _, note -> - ( - (note.event is TextNoteEvent && !note.isNewThread()) || - note.event is ReactionEvent || - note.event is LnZapEvent || - note.event is LnZapRequestEvent || - note.event is ReportEvent || - note.event is GenericRepostEvent - ) && - note.replyTo?.any { it.flowSet?.isInUse() == true } != true && - note.flowSet?.isInUse() != true && - // don't delete if observing. - note.author?.pubkeyHex !in - accounts && - // don't delete if it is the logged in account - note.event?.isTaggedUsers(accounts) != - true // don't delete if it's a notification to the logged in user - } - - val childrenToBeRemoved = mutableListOf() - - toBeRemoved.forEach { - unlinkAndRemove(it) - childrenToBeRemoved.addAll(it.clearChildLinks()) - } - - unlinkAndRemove(childrenToBeRemoved) - - if (toBeRemoved.size > 1) { - println("PRUNE: ${toBeRemoved.size} thread replies removed.") - } - } - - /** - * Unlinks [note] from everything in the cache that references it, then drops it - * from the [notes] map and notifies observers. This is the shared "unlink from - * above" half of removal, used by both the prune callers and [deleteNote]. - * - * It detaches the note from: - * - its parent notes (their replies/reactions/zaps/boosts/reports/labels maps); - * because event-level reports and torrent comments both carry the target in - * `replyTo`, [Note.removeNote] cleans those up here too; - * - its channels/gatherers (`inGatherers` is authoritative — `Channel.addNote` - * always registers the gatherer — and `getAnyChannel` is a belt-and-suspenders - * resolve so a note can never linger in a channel after leaving the cache); - * - the per-target indexes `replyTo` does NOT reach: user-level reports and - * reported addresses, contact cards, statuses, and poll responses. - * - * It deliberately does NOT touch the note's own children: prune callers collect - * them via [Note.clearChildLinks] and remove the subtree, while [deleteNote] - * keeps them and severs only their back-reference. Every per-target removal is - * idempotent, so the overlap between `replyTo` and the explicit indexes (e.g. an - * event-level report reachable both ways) is harmless. Addressable notes are - * dropped from the [addressables] map by the caller; this only removes from [notes]. - */ - private fun unlinkAndRemove(note: Note) { - note.replyTo?.forEach { masterNote -> - masterNote.removeNote(note) - } - - note.inGatherers?.forEach { it.removeNote(note) } - - getAnyChannel(note)?.removeNote(note) - - val noteEvent = note.event - - // Quote-repost boosts are tracked outside `replyTo` (see addQuoteBoosts), so - // detach this note from every quoted note's boosts here. - noteEvent?.taggedQuoteIds()?.forEach { quotedId -> - getNoteIfExists(quotedId)?.removeBoost(note) - } - - // Edits (1010/3302/40003) are anchored on their target's Note.edits and carry no `replyTo` - // back-link, so the unlink above can't reach them — resolve the target by the edit's `e` tag - // and drop it there, or a deleted edit would keep overlaying its message. - editedTargetIdOf(noteEvent)?.let { getNoteIfExists(it)?.removeEdit(note) } - - // OTS attestations (kind 1040) are likewise anchored on their target's Note.timestamps with - // no `replyTo` back-link — resolve the target by the `e` tag and drop the proof there. - if (noteEvent is OtsEvent) { - noteEvent.digestEventId()?.let { getNoteIfExists(it)?.removeTimestamp(note) } - } - - if (noteEvent is ReportEvent) { - noteEvent.reportedAuthor().forEach { - getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports -> - reports.removeReport(note) - reports.removeReportNamingUser(note) - } - } - - noteEvent.reportedPost().forEach { - getNoteIfExists(it.eventId)?.removeReport(note) - } - - noteEvent.reportedAddresses().forEach { - getAddressableNoteIfExists(it.address)?.removeReport(note) - } - } - - if (note is AddressableNote && noteEvent is ContactCardEvent) { - getUserIfExists(noteEvent.aboutUser())?.cardsOrNull()?.removeCard(note) - } - - if (note is AddressableNote && noteEvent is StatusEvent) { - note.author?.statusStateOrNull()?.removeStatus(note) - } - - if (noteEvent is PollResponseEvent) { - noteEvent.poll()?.eventId?.let { - getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(note) - } - } - - note.clearFlow() - - notes.remove(note.idHex) - - refreshDeletedNoteObservers(note) - } - - /** The id of the message/post an edit event targets (its `e` tag), across all three edit kinds. */ - private fun editedTargetIdOf(event: Event?): HexKey? = - when (event) { - is TextNoteModificationEvent -> event.editedNote()?.eventId - is ConcordChatEditEvent -> event.editedMessageId() - is StreamMessageEditEvent -> event.editedMessage() - else -> null - } - - fun unlinkAndRemove(nextToBeRemoved: List) { - nextToBeRemoved.forEach { note -> unlinkAndRemove(note) } - } - - fun pruneExpiredEvents() { - checkNotInMainThread() - - val now = TimeUtils.now() - val versionsToBeRemoved = notes.filter { _, it -> it.event?.isExpirationBefore(now) == true } - val addressesToBeRemoved = addressables.filter { _, it -> it.event?.isExpirationBefore(now) == true } - - val childrenToBeRemoved = mutableListOf() - - versionsToBeRemoved.forEach { - unlinkAndRemove(it) - childrenToBeRemoved.addAll(it.clearChildLinks()) - } - - addressesToBeRemoved.forEach { - unlinkAndRemove(it) - childrenToBeRemoved.addAll(it.clearChildLinks()) - } - - unlinkAndRemove(childrenToBeRemoved) - - if (versionsToBeRemoved.size > 1 || addressesToBeRemoved.size > 1) { - println("PRUNE: ${versionsToBeRemoved.size} events and ${addressesToBeRemoved.size} expired.") - } - } - - fun pruneHiddenEvents(account: Account) { - checkNotInMainThread() - - val childrenToBeRemoved = mutableListOf() - - val toBeRemoved = - account.hiddenUsers.flow.value.hiddenUsers.flatMap { userHex -> - (notes.filter { _, it -> it.event?.pubKey == userHex } + addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet() - } - - toBeRemoved.forEach { - unlinkAndRemove(it) - childrenToBeRemoved.addAll(it.clearChildLinks()) - } - - unlinkAndRemove(childrenToBeRemoved) - - println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden") - } - override fun markAsSeen( eventId: String, relay: NormalizedRelayUrl, @@ -3799,7 +3166,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { live.newNote(newNote) } - private fun refreshDeletedNoteObservers(newNote: Note) { + internal fun refreshDeletedNoteObservers(newNote: Note) { // Deletes don't have a filterable shape — every observer // might hold this note in its result set, so iterate them // all. The index doesn't help here. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt index 0a70fd4923..82566a44f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt @@ -54,21 +54,21 @@ class MemoryTrimmingService( ) { // Tier 1: always run — cheap housekeeping; cleanObservers only removes flows that are // not currently held by the UI, so it is safe and inexpensive at any pressure level. - cache.cleanMemory() - cache.cleanObservers() - cache.pruneExpiredEvents() - cache.prunePastVersionsOfReplaceables() + cache.pruner.cleanMemory() + cache.pruner.cleanObservers() + cache.pruner.pruneExpiredEvents() + cache.pruner.prunePastVersionsOfReplaceables() if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) { // Tier 2: real reclaim pressure — drop events from muted/blocked users, old // messages, and unobserved reactions. account.forEach { - cache.pruneHiddenEvents(it) - cache.pruneHiddenMessages(it) + cache.pruner.pruneHiddenEvents(it) + cache.pruner.pruneHiddenMessages(it) } val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet() - cache.pruneOldMessages() - cache.pruneRepliesAndReactions(accounts) + cache.pruner.pruneOldMessages() + cache.pruner.pruneRepliesAndReactions(accounts) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index 78c89dc8e0..6a6085c555 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -21,10 +21,9 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Dao import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -258,11 +257,3 @@ class NewMessageTagger( return null } } - -interface Dao { - fun getOrCreateUser(hex: HexKey): User - - fun getOrCreateNote(hex: HexKey): Note - - fun getOrCreateAddressableNote(address: Address): AddressableNote? -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 4cc64d396a..a4cb62e6a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -190,7 +190,7 @@ class UserSuggestionState( if (prefix != null) { logTime("UserSuggestionState Search $prefix version $version") { rankPriorityFirst( - account.cache.findUsersStartingWith(prefix, account), + account.cache.search.findUsersStartingWith(prefix, account), priorityPubkeys(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 9d372376ed..49c9ef5be0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Dao import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.UiSettingsFlow @@ -88,7 +89,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismis import com.vitorpamplona.amethyst.service.pow.powKindLabelRes import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler -import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 42f9e17572..d897298812 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -308,7 +308,7 @@ class GiftWrapEventHandler( // already folded the state they carried, so drop the durable wrap note now // to keep LocalCache from growing without bound. if (event is EphemeralGiftWrapEvent) { - cache.unlinkAndRemove(listOf(eventNote)) + cache.pruner.unlinkAndRemove(listOf(eventNote)) } return } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt index a6a5f16670..55fe5a3a12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt @@ -436,7 +436,10 @@ private fun AgentKeyPicker( delay(150) suggestions = withContext(Dispatchers.IO) { - LocalCache.findUsersStartingWith(query.trim(), accountViewModel.account).map { it.pubkeyHex }.take(8) + LocalCache.search + .findUsersStartingWith(query.trim(), accountViewModel.account) + .map { it.pubkeyHex } + .take(8) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt index 1930e11780..248dbc896f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt @@ -118,7 +118,7 @@ class BuzzNewDmViewModel : ViewModel() { val me = account.userProfile().pubkeyHex val already = _participants.value.toSet() val ranked = - LocalCache + LocalCache.search .findUsersStartingWith(text.trim(), account) .asSequence() .map { it.pubkeyHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 03af2a7865..8bd8d2db7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -268,7 +268,7 @@ class SearchBarViewModel( } if (term.isBlank()) return@combine emptyList() - val users = LocalCache.findUsersStartingWith(term, account) + val users = LocalCache.search.findUsersStartingWith(term, account) if (follows != null) users.filter { it.pubkeyHex in follows } else users }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) @@ -285,7 +285,7 @@ class SearchBarViewModel( ) { term, _, currentScope, order, follows -> if (currentScope == SearchScope.PEOPLE) return@combine emptyList() - val raw = LocalCache.findNotesStartingWith(term, account.hiddenUsers) + val raw = LocalCache.search.findNotesStartingWith(term, account.hiddenUsers) val filtered = if (follows != null) raw.filter { it.author?.pubkeyHex in follows } else raw when (order) { @@ -317,7 +317,7 @@ class SearchBarViewModel( invalidations, scope, ) { term, _, currentScope -> - if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findPublicChatChannelsStartingWith(term) + if (currentScope != SearchScope.ALL) emptyList() else LocalCache.search.findPublicChatChannelsStartingWith(term) }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) @@ -327,7 +327,7 @@ class SearchBarViewModel( invalidations, scope, ) { term, _, currentScope -> - if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findEphemeralChatChannelsStartingWith(term) + if (currentScope != SearchScope.ALL) emptyList() else LocalCache.search.findEphemeralChatChannelsStartingWith(term) }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) @@ -337,7 +337,7 @@ class SearchBarViewModel( invalidations, scope, ) { term, _, currentScope -> - if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findLiveActivityChannelsStartingWith(term) + if (currentScope != SearchScope.ALL) emptyList() else LocalCache.search.findLiveActivityChannelsStartingWith(term) }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt index d548db09c3..dba6e87238 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst +import com.vitorpamplona.amethyst.model.Dao import com.vitorpamplona.amethyst.model.LocalCache.getOrCreateAddressableNoteInternal import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip19Bech32.entities.NNote From 7936cab9d5a37fd54ec1288e0fe38c877d936c6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 16:54:52 +0000 Subject: [PATCH 063/227] refactor: extract EventBroadcaster from Account Moves the sign-and-publish choke point out of Account into an EventBroadcaster class: relay-set computation (outbox model, hints, channel home relays, broadcast lists, DM inboxes, the recursive linked-event descent) plus every publish path (sendAutomatic, sendMyPublicAndPrivateOutbox, sendLiterallyEverywhere, broadcast, signAndSendPrivately*, signAndComputeBroadcast, signAnonymouslyAndBroadcast, republishEventsTo). Account keeps one-line delegates so its 85+ internal call sites and all external callers are unchanged; upcoming Account*Actions extractions will call the broadcaster directly. Moved code is unchanged except for account. qualification. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../vitorpamplona/amethyst/model/Account.kt | 401 ++-------------- .../amethyst/model/EventBroadcaster.kt | 431 ++++++++++++++++++ 2 files changed, 475 insertions(+), 357 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index acd17cb96f..af6721ed5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -226,17 +226,12 @@ import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event 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.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider -import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll @@ -274,7 +269,6 @@ import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner import com.vitorpamplona.quartz.nip13Pow.signer.PoWNostrSigner import com.vitorpamplona.quartz.nip17Dm.NIP17Factory -import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group @@ -323,12 +317,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent -import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent -import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -345,12 +334,10 @@ import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapTemplateConversion import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureMeta @@ -366,7 +353,6 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag -import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent @@ -802,6 +788,13 @@ class Account( // own chat bubbles. val chatDeliveryTracker = ChatDeliveryTracker(client) + /** + * Relay routing + sign-and-publish choke point: computes which relays an event + * should go to (outbox model, hints, channels, broadcast lists) and owns every + * publish path. All Account send helpers delegate here. + */ + val broadcaster = EventBroadcaster(this) + val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore, marmotKeyPackageStore) } @@ -1929,239 +1922,57 @@ class Account( relaysItCameFrom } - private fun computeRelayListForLinkedUser(user: User): Set = - if (user == userProfile()) { - notificationRelays.flow.value - } else { - user.inboxRelays()?.ifEmpty { null }?.toSet() - ?: (cache.relayHints.hintsForKey(user.pubkeyHex).toSet() + user.allUsedRelays()) - } + // ------------------------------------------------------------------ + // Broadcast / relay-routing delegates (logic lives in EventBroadcaster). + // ------------------------------------------------------------------ - private fun computeRelayListForLinkedUser(pubkey: HexKey): Set = - if (pubkey == userProfile().pubkeyHex) { - notificationRelays.flow.value - } else { - cache - .getUserIfExists(pubkey) - ?.inboxRelays() - ?.ifEmpty { null } - ?.toSet() - ?: cache.relayHints.hintsForKey(pubkey).toSet() - } + fun computeRelayListToBroadcast(event: Event): Set = broadcaster.computeRelayListToBroadcast(event) - private fun computeRelaysForChannels(event: Event): Set = cache.getAnyChannel(event)?.relays() ?: emptySet() + fun computeRelayListToBroadcast(note: Note): Set = broadcaster.computeRelayListToBroadcast(note) - // Personal events the user stores just for themselves — drafts, app settings, bookmark - // lists — and channel/community events that already declare their own home relays - // should not be replicated to the user's broadcasting relays. Channel/community events - // that don't define any home relays fall through to broadcast, since there's nowhere - // else for them to land. - private fun wantsBroadcastRelays(event: Event): Boolean { - if (event is DraftWrapEvent || - event is AppSpecificDataEvent || - event is BookmarkListEvent || - event is OldBookmarkListEvent || - event is LabeledBookmarkListEvent - ) { - return false - } - if (event is PollEvent && event.relays().isNotEmpty()) return false - if (event is MeetingSpaceEvent && event.allRelayUrls().isNotEmpty()) return false - if (event is MeetingRoomEvent && event.allRelayUrls().isNotEmpty()) return false - if (event is LiveActivitiesEvent && event.allRelayUrls().isNotEmpty()) return false + suspend fun broadcast(note: Note) = broadcaster.broadcast(note) - val channelRelays = cache.getAnyChannel(event)?.relays() - if (channelRelays != null && channelRelays.isNotEmpty()) return false + fun sendAutomatic(events: List) = broadcaster.sendAutomatic(events) - return true - } + fun sendAutomatic(event: Event?) = broadcaster.sendAutomatic(event) - fun computeRelayListToBroadcast(event: Event): Set = computeRelayListToBroadcast(event, mutableSetOf()) + fun sendMyPublicAndPrivateOutbox(event: Event?) = broadcaster.sendMyPublicAndPrivateOutbox(event) - private fun computeRelayListToBroadcast( - event: Event, - visited: MutableSet, - ): Set { - // a-tagged events can form cycles; without this the two recursive descents stack-overflow. - if (!visited.add(event.id)) return emptySet() + fun sendMyPublicAndPrivateOutbox(events: List) = broadcaster.sendMyPublicAndPrivateOutbox(events) - if (event is GiftWrapEvent) { - val receiver = event.recipientPubKey() - return if (receiver != null) { - val relayList = - cache - .getOrCreateUser(receiver) - .dmInboxRelayList() - ?.relays() - ?.ifEmpty { null } - relayList?.toSet() ?: computeRelayListForLinkedUser(receiver) - } else { - emptySet() - } - } - // Seals, inner DM messages, and unsigned rumors never get broadcast - // relays: they only travel inside gift wraps. - if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) { - return emptySet() - } + fun sendLiterallyEverywhere(event: Event) = broadcaster.sendLiterallyEverywhere(event) - val includeBroadcast = wantsBroadcastRelays(event) - val broadcastRelays = if (includeBroadcast) broadcastRelayList.flow.value else emptySet() + suspend fun signAndSendPrivately( + template: EventTemplate, + relayList: Set, + ) = broadcaster.signAndSendPrivately(template, relayList) - if (event is MetadataEvent || event is AdvertisedRelayListEvent) { - // everywhere - return followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value + broadcastRelays - } + suspend fun signWithAndSendPrivately( + template: EventTemplate, + signer: NostrSigner, + relayList: Set, + ): T = broadcaster.signWithAndSendPrivately(template, signer, relayList) - val relayList = mutableSetOf() - relayList.addAll(broadcastRelays) + suspend fun signAndSendPrivatelyOrBroadcast( + template: EventTemplate, + relayList: (T) -> List?, + ): T = broadcaster.signAndSendPrivatelyOrBroadcast(template, relayList) - val author = cache.getUserIfExists(event.pubKey) + suspend fun signAndComputeBroadcast( + template: EventTemplate, + broadcast: List = emptyList(), + ): T = broadcaster.signAndComputeBroadcast(template, broadcast) - if (author != null) { - if (author == userProfile()) { - if (includeBroadcast) { - relayList.addAll(outboxRelays.flow.value) - } else { - // outboxRelays mixes in the broadcast list; for personal/channel events - // we want the user's NIP-65 / private / local outbox without it. - relayList.addAll(nip65RelayList.outboxFlow.value) - relayList.addAll(privateStorageRelayList.flow.value) - relayList.addAll(localRelayList.flow.value) - } - } else { - val relays = - author.outboxRelays()?.ifEmpty { null } - ?: author.allUsedRelaysOrNull() - ?: cache.relayHints.hintsForKey(author.pubkeyHex) + suspend fun signAnonymouslyAndBroadcast( + template: EventTemplate, + broadcast: List = emptyList(), + anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()), + ): T = broadcaster.signAnonymouslyAndBroadcast(template, broadcast, anonymousSigner) - relayList.addAll(relays) - } - } else { - relayList.addAll(cache.relayHints.hintsForKey(event.pubKey)) - } - - if (event is PubKeyHintProvider) { - event.pubKeyHints().forEach { - relayList.add(it.relay) - } - event.linkedPubKeys().forEach { pubkey -> - relayList.addAll(computeRelayListForLinkedUser(pubkey)) - } - } - - if (event is EventHintProvider) { - event.eventHints().forEach { - relayList.add(it.relay) - } - event.linkedEventIds().forEach { eventId -> - cache.getNoteIfExists(eventId)?.let { linkedNote -> - val linkedNoteAuthor = linkedNote.author - - if (linkedNoteAuthor != null) { - relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor)) - } else { - relayList.addAll(linkedNote.relays.toSet()) - } - - linkedNote.event?.let { linkedEvent -> - relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited)) - } - } - } - } - - if (event is AddressHintProvider) { - event.addressHints().forEach { - relayList.add(it.relay) - } - event.linkedAddressIds().forEach { addressId -> - cache.getAddressableNoteIfExists(addressId)?.let { linkedNote -> - val linkedNoteAuthor = linkedNote.author - - if (linkedNoteAuthor != null) { - relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor)) - } else { - relayList.addAll(linkedNote.relays.toSet()) - } - - linkedNote.event?.let { linkedEvent -> - relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited)) - } - } - } - } - - if (event is PollEvent) { - relayList.addAll(event.relays()) - } - - if (event is MeetingSpaceEvent) { - relayList.addAll(event.allRelayUrls()) - } - - if (event is MeetingRoomEvent) { - relayList.addAll(event.allRelayUrls()) - } - - if (event is LiveActivitiesEvent) { - relayList.addAll(event.allRelayUrls()) - } - - relayList.addAll(computeRelaysForChannels(event)) - - return relayList - } - - fun computeRelayListToBroadcast(note: Note): Set { - val noteEvent = note.event - return if (noteEvent != null) { - computeRelayListToBroadcast(noteEvent) - } else { - note.relays.toSet() - } - } - - suspend fun broadcast(note: Note) { - note.event?.let { noteEvent -> - val host = note.rumorHost - if (host != null) { - // Rumors are rebroadcast as their delivering envelope: the - // cached copy is content-stripped, so download it and send it. - // A just-sent note has no relays until its self-wrap echoes - // back — fall back to our own DM inbox relays. Bare seals - // (kind 13) carry no p tag, so that filter is wrap-only. - val relays = note.relays.ifEmpty { dmRelays.flow.value.toList() } - val filter = - if (host.kind == SealedRumorEvent.KIND) { - Filter( - kinds = listOf(host.kind), - ids = listOf(host.id), - ) - } else { - Filter( - kinds = listOf(host.kind), - tags = mapOf("p" to listOf(pubKey)), - ids = listOf(host.id), - ) - } - client - .fetchFirst( - filters = relays.associateWith { _ -> listOf(filter) }, - )?.let { downloadedEvent -> - val toRelays = computeRelayListToBroadcast(downloadedEvent) - client.publish(downloadedEvent, toRelays) - } - } else if (noteEvent.sig.isEmpty()) { - // Rumor with no known wrap: publishing it would disclose the - // private content to relays even though they reject the - // missing signature. - return - } else { - client.publish(noteEvent, computeRelayListToBroadcast(note)) - } - } - } + fun republishEventsTo( + events: List, + relays: Set, + ) = broadcaster.republishEventsTo(events, relays) fun upgradeAttestations() = otsState.upgradeAttestationsIfNeeded(::sendAutomatic) @@ -3748,14 +3559,6 @@ class Account( client.publish(signedEvent, relays) } - fun sendAutomatic(events: List) = events.forEach { sendAutomatic(it) } - - fun sendAutomatic(event: Event?) { - if (event == null) return - cache.justConsumeMyOwnEvent(event) - client.publish(event, computeRelayListToBroadcast(event)) - } - suspend fun sendWebBookmark( url: String, title: String?, @@ -3974,24 +3777,6 @@ class Account( client.publish(signedEvent, outboxRelays.flow.value) } - fun sendMyPublicAndPrivateOutbox(event: Event?) { - if (event == null) return - cache.justConsumeMyOwnEvent(event) - client.publish(event, outboxRelays.flow.value) - } - - fun sendMyPublicAndPrivateOutbox(events: List) { - events.forEach { - client.publish(it, outboxRelays.flow.value) - cache.justConsumeMyOwnEvent(it) - } - } - - fun sendLiterallyEverywhere(event: Event) { - client.publish(event, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value) - cache.justConsumeMyOwnEvent(event) - } - suspend fun pollRespond( event: PollEvent, responses: Set, @@ -4224,96 +4009,6 @@ class Account( signAndComputeBroadcast(template) } - suspend fun signAndSendPrivately( - template: EventTemplate, - relayList: Set, - ) { - val event = signer.sign(template) - cache.justConsumeMyOwnEvent(event) - client.publish(event, relayList) - } - - /** - * Sign [template] with an arbitrary [signer] (e.g. a per-geohash ephemeral - * identity that is deliberately NOT this account's key) and publish to exactly - * [relayList]. Used by geohash location chat, where authorship inside a cell - * must not be linkable to the user's npub. - */ - suspend fun signWithAndSendPrivately( - template: EventTemplate, - signer: NostrSigner, - relayList: Set, - ): T { - val event = signer.sign(template) - cache.justConsumeMyOwnEvent(event) - if (relayList.isNotEmpty()) client.publish(event, relayList) - return event - } - - suspend fun signAndSendPrivatelyOrBroadcast( - template: EventTemplate, - relayList: (T) -> List?, - ): T { - val event = signer.sign(template) - cache.justConsumeMyOwnEvent(event) - val relays = relayList(event) - val targets = - if (!relays.isNullOrEmpty()) { - relays.toSet() - } else { - computeRelayListToBroadcast(event) - } - chatDeliveryTracker.trackPublic(event.id, targets) - client.publish(event, targets) - return event - } - - suspend fun signAndComputeBroadcast( - template: EventTemplate, - broadcast: List = emptyList(), - ): T { - val event = signer.sign(template) - cache.justConsumeMyOwnEvent(event) - val note = - if (event is AddressableEvent) { - cache.getOrCreateAddressableNote(event.address()) - } else { - cache.getOrCreateNote(event.id) - } - - val relayList = computeRelayListToBroadcast(note) - - client.publish(event, relayList) - - broadcast.forEach { client.publish(it, relayList) } - - return event - } - - suspend fun signAnonymouslyAndBroadcast( - template: EventTemplate, - broadcast: List = emptyList(), - anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()), - ): T { - val event = anonymousSigner.sign(template) - - cache.justConsumeMyOwnEvent(event) - val note = - if (event is AddressableEvent) { - cache.getOrCreateAddressableNote(event.address()) - } else { - cache.getOrCreateNote(event.id) - } - - val relayList = computeRelayListToBroadcast(note) - - client.publish(event, relayList) - - broadcast.forEach { client.publish(it, relayList) } - - return event - } - /** * Creates a post event without sending it. * Returns the event, target relays, and extra events to broadcast. @@ -5988,14 +5683,6 @@ class Account( .mapNotNull { it.event } /** Publishes the given events to each of the given relays. No-op if either list is empty. */ - fun republishEventsTo( - events: List, - relays: Set, - ) { - if (relays.isEmpty() || events.isEmpty()) return - events.forEach { client.publish(it, relays) } - } - suspend fun requestToVanish( relays: List, reason: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt new file mode 100644 index 0000000000..33ed7a5369 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt @@ -0,0 +1,431 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent + +/** + * The sign-and-publish choke point for an [Account]: computes the relay set an + * event should be broadcast to (NIP-65 outbox model, relay hints, channel home + * relays, broadcast lists, DM inboxes) and owns every publish path - automatic, + * outbox-only, everywhere, private-relay-list, anonymous, and rebroadcast. + * + * Feature orchestration on [Account] (and the Account*Actions classes) should + * funnel every publish through this class instead of calling the relay client + * directly. + */ +class EventBroadcaster( + private val account: Account, +) { + private fun computeRelayListForLinkedUser(user: User): Set = + if (user == account.userProfile()) { + account.notificationRelays.flow.value + } else { + user.inboxRelays()?.ifEmpty { null }?.toSet() + ?: ( + account.cache.relayHints + .hintsForKey(user.pubkeyHex) + .toSet() + user.allUsedRelays() + ) + } + + private fun computeRelayListForLinkedUser(pubkey: HexKey): Set = + if (pubkey == account.userProfile().pubkeyHex) { + account.notificationRelays.flow.value + } else { + account.cache + .getUserIfExists(pubkey) + ?.inboxRelays() + ?.ifEmpty { null } + ?.toSet() + ?: account.cache.relayHints + .hintsForKey(pubkey) + .toSet() + } + + private fun computeRelaysForChannels(event: Event): Set = account.cache.getAnyChannel(event)?.relays() ?: emptySet() + + // Personal events the user stores just for themselves — drafts, app settings, bookmark + // lists — and channel/community events that already declare their own home relays + // should not be replicated to the user's broadcasting relays. Channel/community events + // that don't define any home relays fall through to broadcast, since there's nowhere + // else for them to land. + private fun wantsBroadcastRelays(event: Event): Boolean { + if (event is DraftWrapEvent || + event is AppSpecificDataEvent || + event is BookmarkListEvent || + event is OldBookmarkListEvent || + event is LabeledBookmarkListEvent + ) { + return false + } + if (event is PollEvent && event.relays().isNotEmpty()) return false + if (event is MeetingSpaceEvent && event.allRelayUrls().isNotEmpty()) return false + if (event is MeetingRoomEvent && event.allRelayUrls().isNotEmpty()) return false + if (event is LiveActivitiesEvent && event.allRelayUrls().isNotEmpty()) return false + + val channelRelays = account.cache.getAnyChannel(event)?.relays() + if (channelRelays != null && channelRelays.isNotEmpty()) return false + + return true + } + + fun computeRelayListToBroadcast(event: Event): Set = computeRelayListToBroadcast(event, mutableSetOf()) + + private fun computeRelayListToBroadcast( + event: Event, + visited: MutableSet, + ): Set { + // a-tagged events can form cycles; without this the two recursive descents stack-overflow. + if (!visited.add(event.id)) return emptySet() + + if (event is GiftWrapEvent) { + val receiver = event.recipientPubKey() + return if (receiver != null) { + val relayList = + account.cache + .getOrCreateUser(receiver) + .dmInboxRelayList() + ?.relays() + ?.ifEmpty { null } + relayList?.toSet() ?: computeRelayListForLinkedUser(receiver) + } else { + emptySet() + } + } + // Seals, inner DM messages, and unsigned rumors never get broadcast + // relays: they only travel inside gift wraps. + if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) { + return emptySet() + } + + val includeBroadcast = wantsBroadcastRelays(event) + val broadcastRelays = if (includeBroadcast) account.broadcastRelayList.flow.value else emptySet() + + if (event is MetadataEvent || event is AdvertisedRelayListEvent) { + // everywhere + return account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value + broadcastRelays + } + + val relayList = mutableSetOf() + relayList.addAll(broadcastRelays) + + val author = account.cache.getUserIfExists(event.pubKey) + + if (author != null) { + if (author == account.userProfile()) { + if (includeBroadcast) { + relayList.addAll(account.outboxRelays.flow.value) + } else { + // account.outboxRelays mixes in the broadcast list; for personal/channel events + // we want the user's NIP-65 / private / local outbox without it. + relayList.addAll(account.nip65RelayList.outboxFlow.value) + relayList.addAll(account.privateStorageRelayList.flow.value) + relayList.addAll(account.localRelayList.flow.value) + } + } else { + val relays = + author.outboxRelays()?.ifEmpty { null } + ?: author.allUsedRelaysOrNull() + ?: account.cache.relayHints.hintsForKey(author.pubkeyHex) + + relayList.addAll(relays) + } + } else { + relayList.addAll(account.cache.relayHints.hintsForKey(event.pubKey)) + } + + if (event is PubKeyHintProvider) { + event.pubKeyHints().forEach { + relayList.add(it.relay) + } + event.linkedPubKeys().forEach { pubkey -> + relayList.addAll(computeRelayListForLinkedUser(pubkey)) + } + } + + if (event is EventHintProvider) { + event.eventHints().forEach { + relayList.add(it.relay) + } + event.linkedEventIds().forEach { eventId -> + account.cache.getNoteIfExists(eventId)?.let { linkedNote -> + val linkedNoteAuthor = linkedNote.author + + if (linkedNoteAuthor != null) { + relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor)) + } else { + relayList.addAll(linkedNote.relays.toSet()) + } + + linkedNote.event?.let { linkedEvent -> + relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited)) + } + } + } + } + + if (event is AddressHintProvider) { + event.addressHints().forEach { + relayList.add(it.relay) + } + event.linkedAddressIds().forEach { addressId -> + account.cache.getAddressableNoteIfExists(addressId)?.let { linkedNote -> + val linkedNoteAuthor = linkedNote.author + + if (linkedNoteAuthor != null) { + relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor)) + } else { + relayList.addAll(linkedNote.relays.toSet()) + } + + linkedNote.event?.let { linkedEvent -> + relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited)) + } + } + } + } + + if (event is PollEvent) { + relayList.addAll(event.relays()) + } + + if (event is MeetingSpaceEvent) { + relayList.addAll(event.allRelayUrls()) + } + + if (event is MeetingRoomEvent) { + relayList.addAll(event.allRelayUrls()) + } + + if (event is LiveActivitiesEvent) { + relayList.addAll(event.allRelayUrls()) + } + + relayList.addAll(computeRelaysForChannels(event)) + + return relayList + } + + fun computeRelayListToBroadcast(note: Note): Set { + val noteEvent = note.event + return if (noteEvent != null) { + computeRelayListToBroadcast(noteEvent) + } else { + note.relays.toSet() + } + } + + suspend fun broadcast(note: Note) { + note.event?.let { noteEvent -> + val host = note.rumorHost + if (host != null) { + // Rumors are rebroadcast as their delivering envelope: the + // cached copy is content-stripped, so download it and send it. + // A just-sent note has no relays until its self-wrap echoes + // back — fall back to our own DM inbox relays. Bare seals + // (kind 13) carry no p tag, so that filter is wrap-only. + val relays = + note.relays.ifEmpty { + account.dmRelays.flow.value + .toList() + } + val filter = + if (host.kind == SealedRumorEvent.KIND) { + Filter( + kinds = listOf(host.kind), + ids = listOf(host.id), + ) + } else { + Filter( + kinds = listOf(host.kind), + tags = mapOf("p" to listOf(account.pubKey)), + ids = listOf(host.id), + ) + } + account.client + .fetchFirst( + filters = relays.associateWith { _ -> listOf(filter) }, + )?.let { downloadedEvent -> + val toRelays = computeRelayListToBroadcast(downloadedEvent) + account.client.publish(downloadedEvent, toRelays) + } + } else if (noteEvent.sig.isEmpty()) { + // Rumor with no known wrap: publishing it would disclose the + // private content to relays even though they reject the + // missing signature. + return + } else { + account.client.publish(noteEvent, computeRelayListToBroadcast(note)) + } + } + } + + fun sendAutomatic(events: List) = events.forEach { sendAutomatic(it) } + + fun sendAutomatic(event: Event?) { + if (event == null) return + account.cache.justConsumeMyOwnEvent(event) + account.client.publish(event, computeRelayListToBroadcast(event)) + } + + fun sendMyPublicAndPrivateOutbox(event: Event?) { + if (event == null) return + account.cache.justConsumeMyOwnEvent(event) + account.client.publish(event, account.outboxRelays.flow.value) + } + + fun sendMyPublicAndPrivateOutbox(events: List) { + events.forEach { + account.client.publish(it, account.outboxRelays.flow.value) + account.cache.justConsumeMyOwnEvent(it) + } + } + + fun sendLiterallyEverywhere(event: Event) { + account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value) + account.cache.justConsumeMyOwnEvent(event) + } + + suspend fun signAndSendPrivately( + template: EventTemplate, + relayList: Set, + ) { + val event = account.signer.sign(template) + account.cache.justConsumeMyOwnEvent(event) + account.client.publish(event, relayList) + } + + /** + * Sign [template] with an arbitrary [signer] (e.g. a per-geohash ephemeral + * identity that is deliberately NOT this account's key) and publish to exactly + * [relayList]. Used by geohash location chat, where authorship inside a cell + * must not be linkable to the user's npub. + */ + suspend fun signWithAndSendPrivately( + template: EventTemplate, + signer: NostrSigner, + relayList: Set, + ): T { + val event = signer.sign(template) + account.cache.justConsumeMyOwnEvent(event) + if (relayList.isNotEmpty()) account.client.publish(event, relayList) + return event + } + + suspend fun signAndSendPrivatelyOrBroadcast( + template: EventTemplate, + relayList: (T) -> List?, + ): T { + val event = account.signer.sign(template) + account.cache.justConsumeMyOwnEvent(event) + val relays = relayList(event) + val targets = + if (!relays.isNullOrEmpty()) { + relays.toSet() + } else { + computeRelayListToBroadcast(event) + } + account.chatDeliveryTracker.trackPublic(event.id, targets) + account.client.publish(event, targets) + return event + } + + suspend fun signAndComputeBroadcast( + template: EventTemplate, + broadcast: List = emptyList(), + ): T { + val event = account.signer.sign(template) + account.cache.justConsumeMyOwnEvent(event) + val note = + if (event is AddressableEvent) { + account.cache.getOrCreateAddressableNote(event.address()) + } else { + account.cache.getOrCreateNote(event.id) + } + + val relayList = computeRelayListToBroadcast(note) + + account.client.publish(event, relayList) + + broadcast.forEach { account.client.publish(it, relayList) } + + return event + } + + suspend fun signAnonymouslyAndBroadcast( + template: EventTemplate, + broadcast: List = emptyList(), + anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()), + ): T { + val event = anonymousSigner.sign(template) + + account.cache.justConsumeMyOwnEvent(event) + val note = + if (event is AddressableEvent) { + account.cache.getOrCreateAddressableNote(event.address()) + } else { + account.cache.getOrCreateNote(event.id) + } + + val relayList = computeRelayListToBroadcast(note) + + account.client.publish(event, relayList) + + broadcast.forEach { account.client.publish(it, relayList) } + + return event + } + + fun republishEventsTo( + events: List, + relays: Set, + ) { + if (relays.isEmpty() || events.isEmpty()) return + events.forEach { account.client.publish(it, relays) } + } +} From f2e7a07a97474b53aad3bea2e3e774319057adf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 16:58:36 +0000 Subject: [PATCH 064/227] fix(relay): make the per-connection auth set a copy-on-write snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit finding on the StoreQueryContext seam: RelaySession kept authenticatedUsers as a plain mutable LinkedHashSet and handed out a live view through RequestContext. A store (or EventSource) reading the set during a long REQ replay — exactly what StoreQueryContext invites — could race a concurrent NIP-42 AUTH commit on another coroutine: iteration vs. add on an unsynchronized set is a ConcurrentModificationException or a torn read. The engine now swaps an immutable Set behind a @Volatile field on each AUTH (the only writer), so every read is a consistent snapshot and holding one across a replay is safe. AUTH is rare; the copy is off the hot path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011hH4RY2AUwfMZ54RkMiT45 --- .../quartz/nip01Core/relay/server/RelaySession.kt | 15 +++++++++++++-- .../relay/server/backend/RequestContext.kt | 5 ++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 7aa1e208d6..630ddec74a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -53,6 +53,7 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.launch +import kotlin.concurrent.Volatile import kotlin.concurrent.atomics.AtomicLong import kotlin.concurrent.atomics.ExperimentalAtomicApi @@ -82,8 +83,18 @@ class RelaySession( * The authenticated-identity store for this connection. The engine is the * only writer (committed in [handleAuth] on a successful NIP-42 AUTH); the * policy and the data plane read it through [requestContext]. + * + * Copy-on-write on purpose: readers run concurrently with the engine — + * a REQ replay coroutine can hold the set (via `StoreQueryContext` or an + * `EventSource` reading [RequestContext.authenticatedUsers]) while a + * later AUTH frame commits a new identity. Each read hands out the + * current **immutable** set, so an in-flight query keeps a consistent + * snapshot instead of racing a mutating `LinkedHashSet`; `@Volatile` + * makes the swapped reference visible across threads. AUTH is rare, so + * the copy costs nothing on the hot path. */ - private val authenticatedUsers = mutableSetOf() + @Volatile + private var authenticatedUsers = setOf() /** * The per-connection scope. Handed to the [policy] at connect (so gating @@ -261,7 +272,7 @@ class RelaySession( // Single, engine-side commit into the connection scope — after the full // chain approved and a verifying policy voted to record. - if (record) authenticatedUsers.add(cmd.event.pubKey) + if (record) authenticatedUsers = authenticatedUsers + cmd.event.pubKey send(OkMessage(cmd.event.id, true, "")) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/RequestContext.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/RequestContext.kt index b492e80017..7948ce61cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/RequestContext.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/RequestContext.kt @@ -63,7 +63,10 @@ interface RequestContext { * The pubkeys that have authenticated on this connection via NIP-42. Empty * when the connection is unauthenticated. Backed by the engine-owned scope * and read live, so a REQ that arrives after a successful AUTH sees the - * freshly recorded pubkey(s). + * freshly recorded pubkey(s). Each read returns an **immutable snapshot** + * (the engine swaps the set copy-on-write on AUTH), so holding one across + * a long replay is safe — it just won't grow if another AUTH lands + * mid-query. */ val authenticatedUsers: Set } From 7d91931b7579b4b838b4f816049d216a2f68c35e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 1 Aug 2026 12:58:01 -0400 Subject: [PATCH 065/227] rtt-open is the transport's handshake, not our own queueing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was measured from onConnecting to onConnected, which includes the time the call sat in the client's dispatcher queue. Under a 16,507-relay fan-out that queue dominates everything else: published records showed a median rtt-open of 33.5 SECONDS and a max of 90, against a true minimum of 140ms. That is the field aggregators rank relays by, so it was worse than publishing nothing — a signed claim that healthy relays are slow, when the slowness was ours. pingMillis already carried the right number and was being handed to the listener unused: BasicOkHttpWebSocket computes it as receivedResponseAtMillis - sentRequestAtMillis, so it starts when the upgrade request actually goes out and excludes everything before it. Zero or negative means the transport could not time the handshake, and then no timing is published rather than a fabricated one — the same rule the rest of this class already followed. Co-Authored-By: Claude Opus 5 (1M context) --- .../reachability/RelayObserver.kt | 20 +++++++++++----- .../reachability/RelayObserverTest.kt | 23 +++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt index bde310489c..1e293e3d05 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt @@ -73,10 +73,6 @@ class RelayObserver : RelayConnectionListener { class Observation( val url: NormalizedRelayUrl, ) { - // Monotonic marks, not wall clock: these measure durations, and a clock - // step mid-connection must not produce a negative or wild latency. - @Volatile var connectingAt: TimeSource.Monotonic.ValueTimeMark? = null - @Volatile var rttOpenMs: Long? = null @Volatile var firstReqAt: TimeSource.Monotonic.ValueTimeMark? = null @@ -124,7 +120,6 @@ class RelayObserver : RelayConnectionListener { override fun onConnecting(relay: IRelayClient) { val o = of(relay) - o.connectingAt = TimeSource.Monotonic.markNow() // Cleared, not kept: a reconnect is a fresh attempt, and carrying an old // error forward would report a working relay as broken for as long as the // process lives after one bad minute. @@ -140,7 +135,20 @@ class RelayObserver : RelayConnectionListener { val o = of(relay) o.reachable = true o.error = null - o.connectingAt?.let { o.rttOpenMs = it.elapsedNow().inWholeMilliseconds.coerceAtLeast(0) } + // The TRANSPORT's number, not ours. pingMillis is + // receivedResponseAtMillis - sentRequestAtMillis, so it starts when the + // upgrade request actually goes out and excludes everything before it. + // + // Timing onConnecting -> onConnected instead measures our own dispatcher + // queue as if it were the relay's latency. Under a 16,507-relay fan-out + // that queue dominates: published records showed a median rtt-open of + // 33.5 SECONDS and a max of 90, against a true minimum of 140ms. That is + // the field aggregators rank relays by, so it was worse than publishing + // nothing — a slow-looking relay that is not slow. + // + // Zero or negative means the transport could not time it; no timing is + // published rather than a fabricated one. + o.rttOpenMs = pingMillis.toLong().takeIf { it > 0 } o.touch() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt index dbda038e0f..6522dd5c64 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt @@ -72,6 +72,29 @@ class RelayObserverTest { // ---- what we measured --------------------------------------------------- + @Test + fun `rtt-open is the transport handshake rather than our own queueing`() { + // pingMillis is receivedResponseAtMillis - sentRequestAtMillis: it starts + // when the upgrade request goes out, so it excludes time the call spent + // queued in the client's dispatcher. Timing the enqueue instead published + // our own backlog as the relay's latency — a median of 33.5 SECONDS on a + // 16,507-relay fan-out, against a true minimum of 140ms — into the field + // aggregators rank relays by. + val o = RelayObserver() + o.onConnected(client(url), 140, false) + assertEquals(140L, o.only().rttOpenMs) + } + + @Test + fun `a handshake the transport could not time publishes no time`() { + val o = RelayObserver() + o.onConnected(client(url), 0, false) + + val obs = o.only() + assertTrue(obs.reachable, "it opened, and that much is known") + assertNull(obs.rttOpenMs, "unmeasurable is not zero") + } + @Test fun `an opened connection is timed rather than assumed`() { val o = RelayObserver() From c0932e033057c5297d818c6dd380e1ea41adf340 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:05:00 +0000 Subject: [PATCH 066/227] refactor: extract AccountConcordActions from Account Moves the ~1,000-line Concord orchestration cluster (join/create/invite flows, channel messages/reactions/edits/typing, roles and moderation, refound/rekey/stranded-recovery, metadata + channel management, control-plane sync) into AccountConcordActions, exposed as account.concord. The two Concord file-level constants move with it. Rumor ingestion (consumeConcordRumorGated, refreshConcordChannelIndex) stays on Account since ConcordSessionManager is constructed with it, as do the cross-feature sendMinichatReply and the read-path isConcordBanned policy. External callers (Concord screens, AccountViewModel forwarders, note action menus) now call account.concord.* directly - no delegating shims. Moved code is unchanged except for account. qualification. Account.kt: 6228 -> 4935 lines so far in this series. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../vitorpamplona/amethyst/model/Account.kt | 992 +-------------- .../amethyst/model/AccountConcordActions.kt | 1065 +++++++++++++++++ .../ui/components/ConcordInviteCard.kt | 2 +- .../amethyst/ui/note/NoteQuickActionMenu.kt | 4 +- .../ui/note/elements/NoteActionSections.kt | 4 +- .../ui/screen/loggedIn/AccountViewModel.kt | 24 +- .../concord/ConcordChannelListScreen.kt | 8 +- .../concord/ConcordChannelScreen.kt | 2 +- .../concord/ConcordCreateScreen.kt | 2 +- .../concord/ConcordEditScreen.kt | 2 +- .../concord/ConcordInviteScreen.kt | 2 +- .../datasource/ConcordChannelPreviewLoader.kt | 4 +- .../datasource/ConcordChannelSubscription.kt | 4 +- .../send/ConcordNewMessageViewModel.kt | 4 +- 14 files changed, 1102 insertions(+), 1017 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index af6721ed5f..0508c6e857 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -25,9 +25,6 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.actions.ConcordActions -import com.vitorpamplona.amethyst.commons.actions.ConcordModeration -import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore @@ -163,7 +160,6 @@ import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent import com.vitorpamplona.quartz.buzz.dm.DmHideEvent import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent @@ -184,21 +180,8 @@ import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE -import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry -import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent -import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot -import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId -import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity -import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions -import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity -import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity -import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite -import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus -import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary -import com.vitorpamplona.quartz.concord.crypto.GroupKey -import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -228,14 +211,10 @@ import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event 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.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults @@ -402,24 +381,12 @@ import kotlinx.coroutines.sync.withLock import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.math.BigDecimal -import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.cancellation.CancellationException import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured" -/** Name of the default Concord community Admin role minted by "Make admin". */ -private const val CONCORD_ADMIN_ROLE = "Admin" - -/** - * How often a joined Concord community's stored invite link is re-resolved to check whether - * we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is - * rare and silent, so this trades detection latency for not turning the revision tick into a - * relay-fetch loop. - */ -private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L - @OptIn(DelicateCoroutinesApi::class) @Stable class Account( @@ -788,6 +755,9 @@ class Account( // own chat bubbles. val chatDeliveryTracker = ChatDeliveryTracker(client) + /** Concord community orchestration (join/create/messages/moderation). */ + val concord = AccountConcordActions(this) + /** * Relay routing + sign-and-publish choke point: computes which relays an event * should go to (outbox model, hints, channels, broadcast lists) and owns every @@ -1994,245 +1964,6 @@ class Account( suspend fun unfollow(channel: RelayGroupChannel) = sendMyPublicAndPrivateOutbox(relayGroupList.unfollow(channel)) - /** - * Add a joined Concord community (secret-bearing entry) to the private kind-13302 - * list, and announce a self-signed Guestbook JOIN so this member is visible to - * whoever later refounds the community (CORD-06 re-keys the Guestbook membership). - */ - suspend fun joinConcordCommunity( - entry: ConcordCommunityListEntry, - inviteCreator: HexKey? = null, - inviteLabel: String? = null, - ) { - sendMyPublicAndPrivateOutbox(concordChannelList.follow(entry)) - announceConcordGuestbookJoin(entry, inviteCreator, inviteLabel) - } - - /** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */ - private suspend fun announceConcordGuestbookJoin( - entry: ConcordCommunityListEntry, - inviteCreator: HexKey?, - inviteLabel: String?, - ) { - if (!isWriteable()) return - val guestbook = ConcordActions.guestbookPlane(entry.root.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch) - val wrap = ConcordActions.buildGuestbookJoin(signer, guestbook, TimeUtils.now(), inviteCreator, inviteLabel) - concordSessions.ingest(wrap) - val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (relays.isNotEmpty()) client.publish(wrap, relays) - } - - /** - * Create a new Concord community: mint its genesis (metadata + #general), - * publish the owner-signed genesis wraps to [relays] (or our outbox), and add - * the secret-bearing entry to the kind-13302 joined list. Returns the new - * community id, or null if not writeable. - */ - suspend fun createConcordCommunity( - name: String, - description: String? = null, - relays: List = emptyList(), - icon: ImagePointer? = null, - ): String? { - if (!isWriteable()) return null - val relayUrls = relays.ifEmpty { outboxRelays.flow.value.map { it.url } } - val community = ConcordActions.createCommunity(signer, name, TimeUtils.now(), description, relayUrls, icon) - - val publishTo = relayUrls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { outboxRelays.flow.value } - community.genesisWraps.forEach { client.publish(it, publishTo) } - - joinConcordCommunity( - ConcordCommunityListEntry( - id = community.communityIdHex, - owner = community.ownerPubKey, - ownerSalt = community.ownerSalt.toHexKey(), - root = community.communityRoot.toHexKey(), - rootEpoch = community.rootEpoch, - relays = relayUrls, - name = name, - addedAt = TimeUtils.now() * 1000, - ), - ) - return community.communityIdHex - } - - /** - * Mint a shareable invite link for a joined community and publish its - * kind-33301 public bundle to the community relays. Returns the `…/invite/…` - * URL, or null if the community isn't joined or isn't writeable. - */ - suspend fun mintConcordInvite( - communityId: String, - base: String = "https://amethyst.social", - ): String? { - if (!isWriteable()) return null - val entry = concordChannelList.liveCommunities.value.firstOrNull { it.id == communityId } ?: return null - val invite = - ConcordActions.inviteFor( - communityIdHex = entry.id, - ownerPubKey = entry.owner, - ownerSaltHex = entry.ownerSalt, - communityRootHex = entry.root, - rootEpoch = entry.rootEpoch, - name = entry.name, - relays = entry.relays, - ) - val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays) - - val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { outboxRelays.flow.value } - if (publishTo.isNotEmpty()) client.publish(minted.bundleEvent, publishTo) - return minted.url - } - - /** Drop a joined Concord community from the private kind-13302 list by its id. */ - suspend fun leaveConcordCommunity(communityId: String) = sendMyPublicAndPrivateOutbox(concordChannelList.unfollow(communityId)) - - /** - * Redeem a Concord invite link (`…/invite/#`): parse it, fetch - * the kind-33301 public bundle from the link's relays (+ our outbox), unlock it - * with the fragment token, and add the resulting secret-bearing entry to the - * kind-13302 joined list. - * - * Returns a [ConcordInviteResult] that separates the failure modes so the UI can - * both explain what went wrong and decide whether a retry could ever help — a - * bundle we can't open (e.g. minted by a newer client) must not strand the user - * on a spinner that retries forever. - * - * A bundle whose `expires_at` has passed is rejected with - * [ConcordInviteResult.Expired]. Expiry is resolved inside - * [ConcordActions.classifyInvite], so it is enforced on every redeem path rather - * than being a field nobody reads. - * - * **This must only ever be called from an explicit user action.** It contacts - * relay URLs carried in the link (chosen by whoever minted it) and publishes a - * Guestbook JOIN signed by this account, so calling it on deep-link arrival would - * leak the user's IP and enroll them without consent — see `ConcordInviteScreen`. - * - * If the resolved community is already in the joined list, this returns - * [ConcordInviteResult.Joined] without re-following or re-announcing a Guestbook - * JOIN, so reopening an old invite for a community you're already in simply takes - * you to it. - */ - suspend fun joinConcordViaInvite(url: String): ConcordInviteResult { - if (!isWriteable()) return ConcordInviteResult.InvalidLink - val parsed = ConcordActions.parseInviteLink(url) ?: return ConcordInviteResult.InvalidLink - - val relays = - (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet() - if (relays.isEmpty()) return ConcordInviteResult.NotReachable - - val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } - val wraps = client.fetchAll(filters = filters) - - // Resolve the coordinate per CORD-05 §2 (newest wins; a vsk=9 tombstone revokes even over a - // stale openable copy) so we honour revocation and can tell the user *why* a link won't open - // instead of stranding them on a spinner that retries a link we can never redeem. - val bundle = - when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { - is InviteBundleStatus.Live -> status.invite - is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired - InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked - InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible - InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable - } - - // Already a member? Just take the user to the community. Re-following and re-announcing a - // Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an - // old invite is reopened, so short-circuit to Joined — the screen forwards to the community - // either way ("take me there", not "join again"). - if (concordChannelList.liveCommunities.value.any { it.id == bundle.communityId }) { - return ConcordInviteResult.Joined(bundle.communityId) - } - - val entry = - ConcordCommunityListEntry( - id = bundle.communityId, - owner = bundle.owner, - ownerSalt = bundle.ownerSalt, - root = bundle.communityRoot, - rootEpoch = bundle.rootEpoch, - relays = bundle.relays, - name = bundle.name, - addedAt = TimeUtils.now() * 1000, - // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a - // Refounding that leaves us out of the recipient set is recoverable later. See - // recoverStrandedConcordCommunities(). - inviteRef = ConcordActions.bareInviteRef(url), - ) - joinConcordCommunity(entry) - return ConcordInviteResult.Joined(bundle.communityId) - } - - /** - * Post [text] to a Concord channel: derive the channel plane key, build an - * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), - * fold it locally for an instant echo, and publish it to the community's relays. - * The `p` tag is ephemeral, so this never routes through the DM outbox — it goes - * straight to the community relay set. Returns false if not writeable or the - * community isn't currently joined/folded. - */ - suspend fun sendConcordChannelMessage( - communityId: String, - channelIdHex: String, - text: String, - replyTo: Note? = null, - replyMode: ReplyMode = ReplyMode.INLINE, - imetas: List = emptyList(), - ): Boolean { - if (!isWriteable()) return false - val session = concordSessions.sessionFor(communityId) ?: return false - val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - - // NIP-30 custom-emoji tags for any `:shortcode:` the user typed, so the message renders the - // custom image everywhere (the kind-9 rumor carries them; recipients render via the tags). - val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray() - - val parent = replyTo?.event - val wrap = - when { - // A minichat reply is a kind-1111 thread comment (carrying encrypted image imetas when - // the user attached media); an inline reply is a kind-9 message quoting the parent; a - // fresh post is a plain kind-9 message. - parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() -> - ConcordActions.buildChannelImageReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags) - parent != null && replyMode == ReplyMode.MINICHAT -> - ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) - parent != null -> - ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) - else -> - ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags) - } - trackConcordDelivery(entry, channelKey, wrap) - publishConcordWrap(entry, wrap) - return true - } - - /** - * Send a channel message carrying encrypted image attachments ([imetas], built by the composer - * from the encrypted upload) — Armada's `encryptAttachments` shape. The ciphertext URLs are - * appended to [text] and each rides as a NIP-92 `imeta` with `aes-gcm` decryption params. With no - * attachments this is just a plain [sendConcordChannelMessage]. - */ - suspend fun sendConcordChannelImageMessage( - communityId: String, - channelIdHex: String, - text: String, - imetas: List, - ): Boolean { - if (imetas.isEmpty()) return sendConcordChannelMessage(communityId, channelIdHex, text) - if (!isWriteable()) return false - val session = concordSessions.sessionFor(communityId) ?: return false - val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message. - val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray() - val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags) - trackConcordDelivery(entry, channelKey, wrap) - publishConcordWrap(entry, wrap) - return true - } - /** * Post [text] into [rootNote]'s minichat — a kind-1111 thread reply rooted at that * message. Resolves the chat context from the note's gatherer; today it drives the @@ -2248,7 +1979,7 @@ class Account( val gatherers = rootNote.inGatherers gatherers?.firstNotNullOfOrNull { it as? ConcordChannel }?.let { concord -> - return sendConcordChannelMessage( + return this.concord.sendConcordChannelMessage( concord.channelId.communityId, concord.channelId.channelId, text, @@ -2347,717 +2078,6 @@ class Account( return (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") } - /** - * React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors - * [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's - * channel/epoch, wraps it on the plane, and publishes it — so the reaction stays - * inside the encrypted channel (never a plaintext public kind-7 that would leak - * the message id). [note] must be a Concord channel message (carries a - * [ConcordChannel] gatherer). - */ - suspend fun reactToConcordMessage( - note: Note, - reaction: String, - ): Boolean { - if (!isWriteable()) return false - val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false - val target = note.event ?: return false - val communityId = channel.channelId.communityId - val channelIdHex = channel.channelId.channelId - val entry = concordSessions.sessionFor(communityId)?.entry ?: return false - - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - // A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to - // resolve to an image on the other side; a plain unicode/`+` reaction yields no tags. - val emojiTags = emoji.findEmojiTags(reaction).map { it.toTagArray() }.toTypedArray() - val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags) - publishConcordWrap(entry, wrap) - return true - } - - /** - * Edit my own Concord channel message [note] to [newText]. Mirrors - * [reactToConcordMessage]: builds a kind-3302 [ChannelChat.edit] rumor bound to the - * message's channel/epoch, wraps it on the plane, and publishes it — so the edit stays - * inside the encrypted channel (a public edit would e-tag the private rumor id onto - * public relays). The receiving side overlays the newest edit onto the target message; - * only the *original author's* edits are applied, so we gate to my own kind-9 messages. - * Returns false if [note] isn't an editable Concord message I authored. - */ - suspend fun editConcordChannelMessage( - note: Note, - newText: String, - ): Boolean { - if (!isWriteable()) return false - val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false - val target = note.event ?: return false - // Edits only apply to plain kind-9 messages, and only the author may edit their own. - if (target !is ChatEvent || target.pubKey != signer.pubKey) return false - - val communityId = channel.channelId.communityId - val channelIdHex = channel.channelId.channelId - val entry = concordSessions.sessionFor(communityId)?.entry ?: return false - - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the new text, same as a fresh message. - val emojiTags = emoji.findEmojiTags(newText).map { it.toTagArray() }.toTypedArray() - val wrap = ConcordActions.buildChannelEdit(signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags) - publishConcordWrap(entry, wrap) - return true - } - - /** - * Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at - * most every few seconds while composing. Not folded locally (we never show our own typing); - * ephemeral, so relays broadcast but never store it. - */ - suspend fun sendConcordTyping( - communityId: String, - channelIdHex: String, - ) { - if (!isWriteable()) return - val entry = concordSessions.sessionFor(communityId)?.entry ?: return - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - val wrap = ConcordActions.buildChannelTyping(signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now()) - val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (relays.isNotEmpty()) client.publish(wrap, relays) - } - - /** Instant local echo (the session folds it back as a Note) + publish to the community relays. */ - private fun publishConcordWrap( - entry: ConcordCommunityListEntry, - wrap: Event, - ) { - concordSessions.ingest(wrap) - val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (relays.isNotEmpty()) client.publish(wrap, relays) - } - - /** - * Registers an own Concord channel message with the delivery tracker so its chat - * bubble shows relay-acceptance ticks. Relays OK the encrypted [wrap], but the feed - * shows the inner rumor, so we re-open the wrap (we just built it, so this always - * succeeds) to key the tracker by the rumor id the bubble is drawn from. Reactions - * and typing wraps skip this — they never become a feed row. - */ - private fun trackConcordDelivery( - entry: ConcordCommunityListEntry, - channelKey: GroupKey, - wrap: Event, - ) { - val rumorId = ConcordStreamEnvelope.openOrNull(wrap, channelKey)?.rumor?.id ?: return - val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - chatDeliveryTracker.trackWrappedPublic(rumorId, wrap.id, relays) - } - - // ── Concord roles & moderation (CORD-04) ───────────────────────────────── - // Each publishes a Control Plane edition; authority is enforced at fold time by - // every client's AuthorityResolver, so a call by someone who doesn't outrank the - // target is simply dropped on fold. Owner-authored calls always take effect. - - /** Grant [member] exactly [roleIds] (empty list revokes their roles). */ - suspend fun grantConcordRole( - communityId: String, - member: HexKey, - roleIds: List, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** The default community Admin role: position 1, holding every management + moderation permission. */ - private fun concordAdminRole() = - RoleEntity( - name = CONCORD_ADMIN_ROLE, - position = 1, - permissions = - ConcordPermissions - .of( - ConcordPermissions.MANAGE_ROLES, - ConcordPermissions.MANAGE_CHANNELS, - ConcordPermissions.MANAGE_METADATA, - ConcordPermissions.KICK, - ConcordPermissions.BAN, - ConcordPermissions.MANAGE_MESSAGES, - ConcordPermissions.CREATE_INVITE, - ).toWire(), - ) - - /** - * If [note] is a Concord channel message whose author the OWNER may toggle - * "admin" on, returns `(communityId, memberHex, isAlreadyAdmin)`. Only the owner - * qualifies — the Admin role sits at position 1 and the resolver requires the - * granter to *strictly* outrank it, which only the owner (rank 0) does. Null for - * the owner's own note, the owner as target, or a non-owner actor. - */ - fun concordAdminTarget(note: Note): Triple? { - val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null - val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null - if (author == signer.pubKey) return null - val communityId = channel.channelId.communityId - val state = concordSessions.sessionFor(communityId)?.state?.value ?: return null - if (state.authority.isOwner(author) || !state.authority.isOwner(signer.pubKey)) return null - val adminRoleId = - state.roles.entries - .firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } - ?.key - val isAdmin = adminRoleId != null && adminRoleId in state.authority.rolesOf(author) - return Triple(communityId, author, isAdmin) - } - - /** Promote [member] to the community Admin role, defining that role first if it doesn't exist yet. */ - suspend fun makeConcordAdmin( - communityId: String, - member: HexKey, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val cp = session.controlPlaneKey() - - val existing = - session.state.value - ?.roles - ?.entries - ?.firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } - val roleIdHex = - existing?.key ?: run { - val roleId = RandomInstance.bytes(32) - val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, roleWrap) - roleId.toHexKey() - } - - val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, grantWrap) - return true - } - - /** Revoke all roles from [member] (demote an admin back to a plain member). */ - suspend fun removeConcordAdmin( - communityId: String, - member: HexKey, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, grantWrap) - return true - } - - /** - * If [note] is a Concord channel message whose author this account is allowed to - * ban — the actor outranks the target and holds the BAN permission, and the target - * is neither the owner nor the actor — returns `(communityId, memberHex)`. Null - * otherwise, so the UI offers Ban only where we are willing to act. - * - * The rank half is ours alone. CORD-04 rank-gates role grants (`canActOn`) but the - * BANLIST is a single whole-list entity, so neither this client's fold nor Armada's - * rank-checks the *contents* of a banlist edition — both gate only on the author's - * BAN bit (Armada: `banlistGate` → `isAuthorized(.., Permissions.BAN)`, while its - * role path uses the rank-aware `canActOnPosition`). A moderator's ban of an admin - * above them is therefore *accepted* by every client today. Since we cannot refuse - * such a ban without diverging from Armada, we at least refuse to author one — this - * restricts what we write, never what we accept, so it cannot split consensus. - * Enforcing it on the fold needs a spec change; see the QA plan's open findings. - */ - fun concordBanTarget(note: Note): Pair? { - val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null - val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null - if (author == signer.pubKey) return null - val communityId = channel.channelId.communityId - val authority = - concordSessions - .sessionFor(communityId) - ?.state - ?.value - ?.authority ?: return null - if (authority.isOwner(author)) return null - // The owner short-circuits rather than going through canActOn: canActOn starts at - // hasPermission, which is false while banned, and a rogue BAN holder *can* currently put - // the owner on the banlist (see the KDoc) — routing the owner through it would let them be - // locked out of moderating their own community. - val canBan = authority.isOwner(signer.pubKey) || authority.canActOn(signer.pubKey, author, ConcordPermissions.BAN) - return if (canBan) communityId to author else null - } - - /** Add [member] to the community banlist. */ - suspend fun banConcordMember( - communityId: String, - member: HexKey, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** Remove [member] from the community banlist. */ - suspend fun unbanConcordMember( - communityId: String, - member: HexKey, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - // ── Concord refounding / rekey (CORD-06) ────────────────────────────────── - // A ban is a soft removal — the banned member still holds the room key and can - // still decrypt traffic; every client just declines to *show* their posts. A - // Refounding is the hard removal: it rotates the community_root, so a removed - // member's key stops working for anything published afterwards. - - /** - * Remove [removed] from the community absolutely (CORD-06 Refounding): ban them, - * roll the `community_root`, re-key every retained member (Guestbook membership ∪ - * observed authors ∪ the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted - * Control Plane under the new root. A removed member keeps the prior root (so - * their history stays readable) but receives no blob, so they can never decrypt - * anything published after the rotation. - * - * Requires ownership or the BAN permission; returns false otherwise (or if the - * community isn't joined/writeable, or a target is the owner). - */ - suspend fun refoundConcordCommunity( - communityId: String, - removed: Set, - ): Boolean { - if (!isWriteable()) return false - val session = concordSessions.sessionFor(communityId) ?: return false - val state = session.state.value ?: return false - val authority = state.authority - val iCanBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN) - if (!iCanBan) return false - val removedLower = removed.mapTo(HashSet()) { it.lowercase() } - if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false - - // 1. Ban the removed members on the current Control Plane so the compacted snapshot — - // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally - // first, so each subsequent edition chains onto the updated banlist head. - for (target in removedLower) { - val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, banWrap) - } - - // 2. Recipient set: everyone we're keeping, minus the removed and the already-banned. - // Uses allMembers() — Guestbook joins ∪ OBSERVED AUTHORS ∪ roster ∪ owner — not just the - // Guestbook set. Most members never send a Guestbook Join (Amethyst announces one, other - // clients need not), so building the set without observed authors silently expelled every - // member who had only ever posted: they hold no role, receive no blob, and the Refounding - // strands them. That mainly hit cross-client communities, where Armada members are the - // bulk of the roster. - // - // Still a floor, not a census (see allMembers): a member who joined without a Guestbook - // motion, holds no role, and has never posted leaves no trace to find, so a Refounding - // cannot re-key them. Stranded recovery is what gets those members back. - val recipients = - (session.allMembers() + signer.pubKey) - .mapTo(HashSet()) { it.lowercase() } - .apply { - removeAll(removedLower) - removeAll(authority.bannedMembers()) - }.toList() - - // 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs. - val entry = session.entry - val newRoot = RandomInstance.bytes(32) - val build = - ConcordActions.buildRefounding( - rotatorSigner = signer, - communityId = communityId, - priorRoot = entry.root.hexToByteArray(), - newRoot = newRoot, - rootEpoch = entry.rootEpoch, - priorControlWraps = session.controlPlaneWraps(), - priorControlKey = session.controlPlaneKey(), - recipientsXOnly = recipients, - createdAt = TimeUtils.now(), - ) - - // 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs - // (the key that unlocks it) to the community relays. - val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (publishTo.isNotEmpty()) { - build.controlWraps.forEach { client.publish(it, publishTo) } - build.rekeyWraps.forEach { client.publish(it, publishTo) } - } - - // 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and - // re-folds the compacted Control Plane (with the ban), dropping the removed members. - adoptConcordRoot(entry, newRoot, build.newEpoch) - return true - } - - // Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered - // in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not - // adopted — and re-published — twice on successive revision ticks. - private val adoptedConcordRotations = java.util.Collections.synchronizedSet(HashSet()) - - /** - * Persist a rotated access root/epoch for [entry], keeping the prior root as a - * [HeldRoot], and re-announce our Guestbook membership at the new epoch so the - * fresh epoch's Guestbook re-seeds (a later Refounding re-keys that membership — - * without this, cascading removals would lose everyone but the roster). No-op if - * this exact rotation was already adopted. - */ - private suspend fun adoptConcordRoot( - entry: ConcordCommunityListEntry, - newRoot: ByteArray, - newEpoch: Long, - ) { - if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return - val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch } - val next = - ConcordCommunityListEntry( - id = entry.id, - owner = entry.owner, - ownerSalt = entry.ownerSalt, - root = newRoot.toHexKey(), - rootEpoch = newEpoch, - heldRoots = held, - privateChannels = entry.privateChannels, - relays = entry.relays, - name = entry.name, - addedAt = entry.addedAt, - // The invite_ref anchor must survive a rotation, or the *next* Refounding we're left - // out of would be unrecoverable. - inviteRef = entry.inviteRef, - excludedAtEpoch = entry.excludedAtEpoch, - // Unknown keys another client wrote (Armada's list is `[k: string]: unknown`) - // must survive our rotation write, or we delete their data on every rekey. - residue = entry.residue, - ) - sendMyPublicAndPrivateOutbox(concordChannelList.follow(next)) - announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) - } - - /** - * Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for - * each joined community, look for our new root among the kind-3303 wraps seen at - * our next base-rekey address. If a role-authorized rotator (owner or a current, - * non-banned BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the - * session rebuilds at the new epoch and its next-rekey address moves on, so a stale - * wrap never re-triggers. Called on every Concord revision tick. - * - * Authority is the roster, never key possession: any non-banned BAN-holder may - * rotate, including for the owner. The owner deliberately does NOT refuse a root - * authored by someone else — refusing would strand the owner alone on the dead - * epoch whenever an admin legitimately rotates, and would diverge from Armada, - * which forks a community across clients. Self-escalation to BAN is prevented - * upstream by the role rank gate in AuthorityResolver. - * - * A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list, - * so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the - * owner included) by omission — nothing on this receive path can prevent it. The - * cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves - * the invite link the membership was joined through and merges forward. - */ - private suspend fun drainConcordRekeys() { - if (!isWriteable()) return - for (session in concordSessions.sessions()) { - val wraps = session.pendingBaseRekeyWraps() - if (wraps.isEmpty()) continue - val entry = session.entry - val received = - ConcordActions.openBaseRekey( - wraps = wraps, - baseRekey = session.nextBaseRekeyKey(), - recipientSigner = signer, - priorRoot = entry.root.hexToByteArray(), - rootEpoch = entry.rootEpoch, - ) ?: continue - if (received.newEpoch <= entry.rootEpoch) continue - val authority = session.state.value?.authority ?: continue - - // hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder - // who has themselves been banned could still rotate the whole community. - val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN) - if (!authorized) continue - adoptConcordRoot(entry, received.newRoot, received.newEpoch) - } - } - - // Last time we re-resolved each community's invite_ref, so the recovery sweep rides the - // Concord revision tick (which fires on every structural change) without turning it into a - // relay-fetch loop. - private val lastConcordRecoveryCheck = ConcurrentHashMap() - - /** - * Stranded recovery (CORD-05/06 receive path). A Refounding carries only - * `(newRoot, newEpoch, rotator)` — **no recipient list** — so a member simply left - * out of the rekey recipient set receives nothing and sits on the dead epoch - * forever while everyone else moves on. This happens to any member, the owner - * included, and [drainConcordRekeys] cannot prevent it: there is no message to - * miss detecting. - * - * The way back is the invite link the membership was joined through - * ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and - * carried through every rotation by [adoptConcordRoot]). The community keeps - * re-minting its bundle at that same addressable coordinate, so a bundle there at - * a **strictly higher** epoch than ours proves we were left behind — and carries - * the new root. Same or lower epoch is a no-op. Memberships with no link (direct - * invites, legacy entries) are inert here; that is expected, not an error. - * - * The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps - * both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the - * entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays - * derivable). We then re-announce the Guestbook at the new epoch, exactly as an - * ordinary rotation does, so the recovered member is visible to whoever refounds - * next instead of being silently dropped again. - * - * Called on the Concord revision tick, but rate-limited per community - * ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup. - */ - private suspend fun recoverStrandedConcordCommunities() { - if (!isWriteable()) return - val now = TimeUtils.nowMillis() - for (entry in concordChannelList.liveCommunities.value) { - val inviteRef = entry.inviteRef ?: continue - val last = lastConcordRecoveryCheck[entry.id] - if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue - lastConcordRecoveryCheck[entry.id] = now - - val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue - val relays = - ( - parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + - entry.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } - ).toSet() - if (relays.isEmpty()) continue - - val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } - val wraps = client.fetchAll(filters = filters) - // Only a live bundle recovers: an expired/revoked link is not a rotation we missed. - val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue - - val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue - if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue - Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}") - sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged)) - announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null) - } - } - - /** - * Replace the community metadata (name / icon / description / relays) with a new - * Control-Plane edition. Honored on fold only when this account holds - * MANAGE_METADATA (or is the owner); dropped otherwise, like every other edition. - */ - suspend fun editConcordMetadata( - communityId: String, - name: String, - description: String?, - icon: ImagePointer?, - banner: ImagePointer?, - relays: List, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays) - val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** - * Create a new public text channel in [communityId] (CORD-03/04 channel edition). Honored at fold - * only when this account holds MANAGE_CHANNELS (or is the owner); the button should be gated on - * the same predicate. The channel id is a fresh random 32-byte entity id. - */ - suspend fun createConcordChannel( - communityId: String, - name: String, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - val channelId = RandomInstance.bytes(32) - val channel = ChannelEntity(name = name.trim()) - val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** Rename an existing channel (chains the next channel edition onto its head). MANAGE_CHANNELS only. */ - suspend fun renameConcordChannel( - communityId: String, - channelIdHex: String, - name: String, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - // Carry the standing definition forward and change only the name. A ChannelEntity built from - // scratch defaults `private` and `voice` to false, so renaming a private channel used to - // publish an edition declaring it PUBLIC — and a voice channel became a text channel. - val standing = - session.state.value - ?.channels - ?.get(channelIdHex) - ?.definition - val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false) - val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** Delete (tombstone) a channel — terminal; its id is never reused. MANAGE_CHANNELS only. */ - suspend fun deleteConcordChannel( - communityId: String, - channelIdHex: String, - name: String, - ): Boolean { - val session = concordSessions.sessionFor(communityId) ?: return false - if (!isWriteable()) return false - // Same as rename: preserve the standing flags so a tombstone does not also silently - // reclassify the channel it retires. - val standing = - session.state.value - ?.channels - ?.get(channelIdHex) - ?.definition - val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true) - val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) - publishConcordWrap(session.entry, wrap) - return true - } - - /** - * Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from - * the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT - * joining. Returns the [CommunityInvite] (name, relays, community coordinates) so a - * card can show what the link opens, or null if the link is invalid/unreadable. - */ - suspend fun peekConcordInvite(url: String): CommunityInvite? { - val parsed = ConcordActions.parseInviteLink(url) ?: return null - val relays = - (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet() - if (relays.isEmpty()) return null - val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } - val wraps = client.fetchAll(filters = filters) - return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } - } - - /** - * Bootstrap the Concord hub from the network: fetch this account's kind-13302 - * joined-communities list and fold the newest into [LocalCache], so communities - * we joined on another Concord client with this key surface here. - * - * We query a wide relay set because different Concord clients publish this - * private list to different places: the reference clients (Armada/Vector) push - * it to the Concord **stock relays** (e.g. relay.ditto.pub), while a user may - * also have copied it onto their **own** outbox/read relays. Our normal account - * subscription never asks for kind 13302, so without this explicit fetch a - * community joined on Armada would never appear — even if the list sits on the - * user's own outbox. - * - * Read-only import: kind 13302 is replaceable, so folding an older copy is a - * no-op and this is safe to call on every hub open. Merging our own edits with - * a foreign writer's is a separate concern (newest-wins replaceable). - * - * [extraRelays] are additional relays to query — the bootstrap relays saved on the - * bottom-bar tabs of pinned communities. A community's private list frequently lives - * only on the community's own relays (never the user's outbox), so a community pinned - * to the bottom bar would otherwise never surface when opened cold. - */ - suspend fun importConcordCommunities(extraRelays: Set = emptySet()) { - val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } - val relays = (stock + mineRelays.flow.value + outboxRelays.flow.value + extraRelays).toSet() - if (relays.isEmpty()) return - val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(signer.pubKey)) - // Stock relays like relay.ditto.pub can be slow (~10–20s to first response), so give - // the fetch a generous window to drain every relay before we pick the newest copy. - val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = 30_000L) - val newest = events.filterIsInstance().maxByOrNull { it.createdAt } - val entryCount = newest?.let { runCatching { it.decrypt(signer).size }.getOrElse { -1 } } ?: 0 - Log.d( - "Concord", - "importConcordCommunities: queried ${relays.size} relays, fetched ${events.size} 13302 event(s), " + - "newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}", - ) - newest?.let { cache.justConsumeMyOwnEvent(it) } - } - - /** - * One-shot warm of every channel of [entries] so a community's channel list and the Messages inbox - * fill in without the user opening each channel one by one. Per channel, a channel read before is - * caught up from its last-read time (accurate unread badge + the missed messages ready when it - * opens) while a channel never read pulls only its single newest wrap for a preview — see - * [ConcordSubscriptionPlanner.channelPreviewFilters]. - * - * This is deliberately **not** a live subscription: every wrap the drain pulls flows through the - * global cache connector (`CacheClientConnector` → `LocalCache.justConsume` → `concordSessions.ingest`), - * so it lands in the channel's message store the previews/unread counts read — and the always-on - * plane subscription ([RelaySubscriptionsCoordinator.concordChannels]) keeps them fresh afterward. - * So this only needs to run when a community's channels first fold (the account preload) or its - * screen is opened. One drain per call: all [entries]' per-channel filters are grouped by relay. - */ - suspend fun warmConcordChannelPreviews(entries: List) { - val filters = - entries.flatMap { entry -> - val state = concordSessions.sessionFor(entry.id)?.state?.value ?: return@flatMap emptyList() - ConcordSubscriptionPlanner.channelPreviewFilters( - entry, - state, - lastReadFor = { channelIdHex -> - loadLastRead(concordChannelLastReadRoute(entry.id, channelIdHex)) - }, - accountPubKey = userProfile().pubkeyHex, - ) - } - if (filters.isEmpty()) return - val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } } - client.fetchAll(filters = byRelay, timeoutMs = 20_000L) - } - - /** - * COMPLETE-mode Control-Plane sync — Armada's plane-sweep discipline for the one plane that must - * never fold on a truncated edition set. - * - * The Control Plane defines the channel list, the roster and the banlist, so a *partial* fold - * silently drops channels or mis-renders membership. Two ways that happens, both closed here: - * - **Forward-cursor gap:** the live plane subscription advances a `since` cursor, so an edition - * with a `created_at` below the high-water mark that we never actually ingested — an unban - * published while we were offline, a CORD-06 compaction re-wrap under a newly-held epoch — is - * never asked for again and stays invisible. This sweep uses **no `since`**: it re-fetches the - * whole plane every run. - * - **Per-filter cap:** a relay caps a REQ's result (~100/filter on relay.dreamith.to), which can - * crop a busy Control Plane. This **pages past the cap** ([fetchAllPagesFromPool] walks `until` - * cursors until a plane is drained), so the fold sees every edition regardless of the cap. - * - * Current + every held-prior epoch's Control Plane is swept (the anti-rollback floor folds from the - * priors). Wraps ingest through the global cache connector → [concordSessions] like every other - * Concord drain; AUTH is the shared stream-key handler. Merging communities that share a relay into - * one filter is safe here precisely because we page — the cap no longer truncates. The live control - * subscription still carries brand-new editions in real time; this is the periodic completeness pass. - */ - suspend fun syncConcordControlPlanes(entries: List) { - if (entries.isEmpty()) return - val authorsByRelay = HashMap>() - for (entry in entries) { - for (sub in ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry))) { - for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) - } - } - if (authorsByRelay.isEmpty()) return - // No `since`, no `limit` → fetchAllPages treats each filter as unbounded and pages until a - // plane is fully drained (empty page), so the whole Control Plane lands regardless of the cap. - val byRelay = authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors.toList())) } - var drained = 0 - client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ } - Log.d("Concord", "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)") - } - - // ── NIP-29 relay-group actions ─────────────────────────────────────────── - // All group commands are published ONLY to the group's host relay, where - // relay29 authorizes them. The relay is the source of truth; the kind-10009 - // list is our own cross-device bookkeeping of what we joined. - - /** Send a kind 9021 join request to the group's host relay and remember it. */ suspend fun joinRelayGroup( channel: RelayGroupChannel, code: String? = null, @@ -5855,10 +4875,10 @@ class Account( concordSessions.revision.sample(500).collect { refreshConcordChannelIndex() // A revision also bumps when a base-rotation rekey lands; adopt ours if present. - runCatching { drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) } + runCatching { concord.drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) } // A rotation we were *excluded* from produces no rekey to drain, so it can only be // found by re-resolving the invite link we joined through. Rate-limited internally. - runCatching { recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) } + runCatching { concord.recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt new file mode 100644 index 0000000000..4b65c4848f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -0,0 +1,1065 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus +import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +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.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.concurrent.ConcurrentHashMap + +/** Name of the default Concord community Admin role minted by "Make admin". */ +private const val CONCORD_ADMIN_ROLE = "Admin" + +/** + * How often a joined Concord community's stored invite link is re-resolved to check whether + * we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is + * rare and silent, so this trades detection latency for not turning the revision tick into a + * relay-fetch loop. + */ +private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L + +/** + * Concord (encrypted communities) orchestration for an [Account]: join/create/ + * invite flows, channel messages/reactions/edits/typing, roles and moderation, + * community refound/recovery, metadata and channel management, and control-plane + * sync. Event building lives in the commons `ConcordActions`/`ConcordModeration` + * objects; this class wires them to the account's signer, session manager, + * channel list, and relay client. Rumor ingestion stays on [Account] + * (`consumeConcordRumorGated`), which is wired into `ConcordSessionManager`. + */ +class AccountConcordActions( + private val account: Account, +) { + /** + * Add a joined Concord community (secret-bearing entry) to the private kind-13302 + * list, and announce a self-signed Guestbook JOIN so this member is visible to + * whoever later refounds the community (CORD-06 re-keys the Guestbook membership). + */ + suspend fun joinConcordCommunity( + entry: ConcordCommunityListEntry, + inviteCreator: HexKey? = null, + inviteLabel: String? = null, + ) { + account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(entry)) + announceConcordGuestbookJoin(entry, inviteCreator, inviteLabel) + } + + /** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */ + private suspend fun announceConcordGuestbookJoin( + entry: ConcordCommunityListEntry, + inviteCreator: HexKey?, + inviteLabel: String?, + ) { + if (!account.isWriteable()) return + val guestbook = ConcordActions.guestbookPlane(entry.root.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch) + val wrap = ConcordActions.buildGuestbookJoin(account.signer, guestbook, TimeUtils.now(), inviteCreator, inviteLabel) + account.concordSessions.ingest(wrap) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) account.client.publish(wrap, relays) + } + + /** + * Create a new Concord community: mint its genesis (metadata + #general), + * publish the owner-signed genesis wraps to [relays] (or our outbox), and add + * the secret-bearing entry to the kind-13302 joined list. Returns the new + * community id, or null if not writeable. + */ + suspend fun createConcordCommunity( + name: String, + description: String? = null, + relays: List = emptyList(), + icon: ImagePointer? = null, + ): String? { + if (!account.isWriteable()) return null + val relayUrls = + relays.ifEmpty { + account.outboxRelays.flow.value + .map { it.url } + } + val community = ConcordActions.createCommunity(account.signer, name, TimeUtils.now(), description, relayUrls, icon) + + val publishTo = relayUrls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + community.genesisWraps.forEach { account.client.publish(it, publishTo) } + + joinConcordCommunity( + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = relayUrls, + name = name, + addedAt = TimeUtils.now() * 1000, + ), + ) + return community.communityIdHex + } + + /** + * Mint a shareable invite link for a joined community and publish its + * kind-33301 public bundle to the community relays. Returns the `…/invite/…` + * URL, or null if the community isn't joined or isn't writeable. + */ + suspend fun mintConcordInvite( + communityId: String, + base: String = "https://amethyst.social", + ): String? { + if (!account.isWriteable()) return null + val entry = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } ?: return null + val invite = + ConcordActions.inviteFor( + communityIdHex = entry.id, + ownerPubKey = entry.owner, + ownerSaltHex = entry.ownerSalt, + communityRootHex = entry.root, + rootEpoch = entry.rootEpoch, + name = entry.name, + relays = entry.relays, + ) + val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays) + + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo) + return minted.url + } + + /** Drop a joined Concord community from the private kind-13302 list by its id. */ + suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.unfollow(communityId)) + + /** + * Redeem a Concord invite link (`…/invite/#`): parse it, fetch + * the kind-33301 public bundle from the link's relays (+ our outbox), unlock it + * with the fragment token, and add the resulting secret-bearing entry to the + * kind-13302 joined list. + * + * Returns a [ConcordInviteResult] that separates the failure modes so the UI can + * both explain what went wrong and decide whether a retry could ever help — a + * bundle we can't open (e.g. minted by a newer client) must not strand the user + * on a spinner that retries forever. + * + * A bundle whose `expires_at` has passed is rejected with + * [ConcordInviteResult.Expired]. Expiry is resolved inside + * [ConcordActions.classifyInvite], so it is enforced on every redeem path rather + * than being a field nobody reads. + * + * **This must only ever be called from an explicit user action.** It contacts + * relay URLs carried in the link (chosen by whoever minted it) and publishes a + * Guestbook JOIN signed by this account, so calling it on deep-link arrival would + * leak the user's IP and enroll them without consent — see `ConcordInviteScreen`. + * + * If the resolved community is already in the joined list, this returns + * [ConcordInviteResult.Joined] without re-following or re-announcing a Guestbook + * JOIN, so reopening an old invite for a community you're already in simply takes + * you to it. + */ + suspend fun joinConcordViaInvite(url: String): ConcordInviteResult { + if (!account.isWriteable()) return ConcordInviteResult.InvalidLink + val parsed = ConcordActions.parseInviteLink(url) ?: return ConcordInviteResult.InvalidLink + + val relays = + (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + account.outboxRelays.flow.value).toSet() + if (relays.isEmpty()) return ConcordInviteResult.NotReachable + + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = account.client.fetchAll(filters = filters) + + // Resolve the coordinate per CORD-05 §2 (newest wins; a vsk=9 tombstone revokes even over a + // stale openable copy) so we honour revocation and can tell the user *why* a link won't open + // instead of stranding them on a spinner that retries a link we can never redeem. + val bundle = + when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { + is InviteBundleStatus.Live -> status.invite + is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired + InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked + InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible + InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable + } + + // Already a member? Just take the user to the community. Re-following and re-announcing a + // Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an + // old invite is reopened, so short-circuit to Joined — the screen forwards to the community + // either way ("take me there", not "join again"). + if (account.concordChannelList.liveCommunities.value + .any { it.id == bundle.communityId } + ) { + return ConcordInviteResult.Joined(bundle.communityId) + } + + val entry = + ConcordCommunityListEntry( + id = bundle.communityId, + owner = bundle.owner, + ownerSalt = bundle.ownerSalt, + root = bundle.communityRoot, + rootEpoch = bundle.rootEpoch, + relays = bundle.relays, + name = bundle.name, + addedAt = TimeUtils.now() * 1000, + // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a + // Refounding that leaves us out of the recipient set is recoverable later. See + // recoverStrandedConcordCommunities(). + inviteRef = ConcordActions.bareInviteRef(url), + ) + joinConcordCommunity(entry) + return ConcordInviteResult.Joined(bundle.communityId) + } + + /** + * Post [text] to a Concord channel: derive the channel plane key, build an + * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), + * fold it locally for an instant echo, and publish it to the community's relays. + * The `p` tag is ephemeral, so this never routes through the DM outbox — it goes + * straight to the community relay set. Returns false if not writeable or the + * community isn't currently joined/folded. + */ + suspend fun sendConcordChannelMessage( + communityId: String, + channelIdHex: String, + text: String, + replyTo: Note? = null, + replyMode: ReplyMode = ReplyMode.INLINE, + imetas: List = emptyList(), + ): Boolean { + if (!account.isWriteable()) return false + val session = account.concordSessions.sessionFor(communityId) ?: return false + val entry = session.entry + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + + // NIP-30 custom-emoji tags for any `:shortcode:` the user typed, so the message renders the + // custom image everywhere (the kind-9 rumor carries them; recipients render via the tags). + val emojiTags = + account.emoji + .findEmojiTags(text) + .map { it.toTagArray() } + .toTypedArray() + + val parent = replyTo?.event + val wrap = + when { + // A minichat reply is a kind-1111 thread comment (carrying encrypted image imetas when + // the user attached media); an inline reply is a kind-9 message quoting the parent; a + // fresh post is a plain kind-9 message. + parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() -> + ConcordActions.buildChannelImageReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags) + parent != null && replyMode == ReplyMode.MINICHAT -> + ConcordActions.buildChannelReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + parent != null -> + ConcordActions.buildChannelInlineReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + else -> + ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags) + } + trackConcordDelivery(entry, channelKey, wrap) + publishConcordWrap(entry, wrap) + return true + } + + /** + * Send a channel message carrying encrypted image attachments ([imetas], built by the composer + * from the encrypted upload) — Armada's `encryptAttachments` shape. The ciphertext URLs are + * appended to [text] and each rides as a NIP-92 `imeta` with `aes-gcm` decryption params. With no + * attachments this is just a plain [sendConcordChannelMessage]. + */ + suspend fun sendConcordChannelImageMessage( + communityId: String, + channelIdHex: String, + text: String, + imetas: List, + ): Boolean { + if (imetas.isEmpty()) return sendConcordChannelMessage(communityId, channelIdHex, text) + if (!account.isWriteable()) return false + val session = account.concordSessions.sessionFor(communityId) ?: return false + val entry = session.entry + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message. + val emojiTags = + account.emoji + .findEmojiTags(text) + .map { it.toTagArray() } + .toTypedArray() + val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags) + trackConcordDelivery(entry, channelKey, wrap) + publishConcordWrap(entry, wrap) + return true + } + + /** + * React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors + * [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's + * channel/epoch, wraps it on the plane, and publishes it — so the reaction stays + * inside the encrypted channel (never a plaintext public kind-7 that would leak + * the message id). [note] must be a Concord channel message (carries a + * [ConcordChannel] gatherer). + */ + suspend fun reactToConcordMessage( + note: Note, + reaction: String, + ): Boolean { + if (!account.isWriteable()) return false + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false + val target = note.event ?: return false + val communityId = channel.channelId.communityId + val channelIdHex = channel.channelId.channelId + val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return false + + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to + // resolve to an image on the other side; a plain unicode/`+` reaction yields no tags. + val emojiTags = + account.emoji + .findEmojiTags(reaction) + .map { it.toTagArray() } + .toTypedArray() + val wrap = ConcordActions.buildChannelReaction(account.signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags) + publishConcordWrap(entry, wrap) + return true + } + + /** + * Edit my own Concord channel message [note] to [newText]. Mirrors + * [reactToConcordMessage]: builds a kind-3302 [ChannelChat.edit] rumor bound to the + * message's channel/epoch, wraps it on the plane, and publishes it — so the edit stays + * inside the encrypted channel (a public edit would e-tag the private rumor id onto + * public relays). The receiving side overlays the newest edit onto the target message; + * only the *original author's* edits are applied, so we gate to my own kind-9 messages. + * Returns false if [note] isn't an editable Concord message I authored. + */ + suspend fun editConcordChannelMessage( + note: Note, + newText: String, + ): Boolean { + if (!account.isWriteable()) return false + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false + val target = note.event ?: return false + // Edits only apply to plain kind-9 messages, and only the author may edit their own. + if (target !is ChatEvent || target.pubKey != account.signer.pubKey) return false + + val communityId = channel.channelId.communityId + val channelIdHex = channel.channelId.channelId + val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return false + + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the new text, same as a fresh message. + val emojiTags = + account.emoji + .findEmojiTags(newText) + .map { it.toTagArray() } + .toTypedArray() + val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags) + publishConcordWrap(entry, wrap) + return true + } + + /** + * Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at + * most every few seconds while composing. Not folded locally (we never show our own typing); + * ephemeral, so relays broadcast but never store it. + */ + suspend fun sendConcordTyping( + communityId: String, + channelIdHex: String, + ) { + if (!account.isWriteable()) return + val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now()) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) account.client.publish(wrap, relays) + } + + /** Instant local echo (the session folds it back as a Note) + publish to the community relays. */ + private fun publishConcordWrap( + entry: ConcordCommunityListEntry, + wrap: Event, + ) { + account.concordSessions.ingest(wrap) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) account.client.publish(wrap, relays) + } + + /** + * Registers an own Concord channel message with the delivery tracker so its chat + * bubble shows relay-acceptance ticks. Relays OK the encrypted [wrap], but the feed + * shows the inner rumor, so we re-open the wrap (we just built it, so this always + * succeeds) to key the tracker by the rumor id the bubble is drawn from. Reactions + * and typing wraps skip this — they never become a feed row. + */ + private fun trackConcordDelivery( + entry: ConcordCommunityListEntry, + channelKey: GroupKey, + wrap: Event, + ) { + val rumorId = ConcordStreamEnvelope.openOrNull(wrap, channelKey)?.rumor?.id ?: return + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + account.chatDeliveryTracker.trackWrappedPublic(rumorId, wrap.id, relays) + } + + // ── Concord roles & moderation (CORD-04) ───────────────────────────────── + // Each publishes a Control Plane edition; authority is enforced at fold time by + // every client's AuthorityResolver, so a call by someone who doesn't outrank the + // target is simply dropped on fold. Owner-authored calls always take effect. + + /** Grant [member] exactly [roleIds] (empty list revokes their roles). */ + suspend fun grantConcordRole( + communityId: String, + member: HexKey, + roleIds: List, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val wrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** The default community Admin role: position 1, holding every management + moderation permission. */ + private fun concordAdminRole() = + RoleEntity( + name = CONCORD_ADMIN_ROLE, + position = 1, + permissions = + ConcordPermissions + .of( + ConcordPermissions.MANAGE_ROLES, + ConcordPermissions.MANAGE_CHANNELS, + ConcordPermissions.MANAGE_METADATA, + ConcordPermissions.KICK, + ConcordPermissions.BAN, + ConcordPermissions.MANAGE_MESSAGES, + ConcordPermissions.CREATE_INVITE, + ).toWire(), + ) + + /** + * If [note] is a Concord channel message whose author the OWNER may toggle + * "admin" on, returns `(communityId, memberHex, isAlreadyAdmin)`. Only the owner + * qualifies — the Admin role sits at position 1 and the resolver requires the + * granter to *strictly* outrank it, which only the owner (rank 0) does. Null for + * the owner's own note, the owner as target, or a non-owner actor. + */ + fun concordAdminTarget(note: Note): Triple? { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null + val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null + if (author == account.signer.pubKey) return null + val communityId = channel.channelId.communityId + val state = + account.concordSessions + .sessionFor(communityId) + ?.state + ?.value ?: return null + if (state.authority.isOwner(author) || !state.authority.isOwner(account.signer.pubKey)) return null + val adminRoleId = + state.roles.entries + .firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } + ?.key + val isAdmin = adminRoleId != null && adminRoleId in state.authority.rolesOf(author) + return Triple(communityId, author, isAdmin) + } + + /** Promote [member] to the community Admin role, defining that role first if it doesn't exist yet. */ + suspend fun makeConcordAdmin( + communityId: String, + member: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = session.controlPlaneKey() + + val existing = + session.state.value + ?.roles + ?.entries + ?.firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } + val roleIdHex = + existing?.key ?: run { + val roleId = RandomInstance.bytes(32) + val roleWrap = ConcordModeration.defineRole(account.signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, roleWrap) + roleId.toHexKey() + } + + val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, grantWrap) + return true + } + + /** Revoke all roles from [member] (demote an admin back to a plain member). */ + suspend fun removeConcordAdmin( + communityId: String, + member: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val grantWrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, grantWrap) + return true + } + + /** + * If [note] is a Concord channel message whose author this account is allowed to + * ban — the actor outranks the target and holds the BAN permission, and the target + * is neither the owner nor the actor — returns `(communityId, memberHex)`. Null + * otherwise, so the UI offers Ban only where we are willing to act. + * + * The rank half is ours alone. CORD-04 rank-gates role grants (`canActOn`) but the + * BANLIST is a single whole-list entity, so neither this client's fold nor Armada's + * rank-checks the *contents* of a banlist edition — both gate only on the author's + * BAN bit (Armada: `banlistGate` → `isAuthorized(.., Permissions.BAN)`, while its + * role path uses the rank-aware `canActOnPosition`). A moderator's ban of an admin + * above them is therefore *accepted* by every client today. Since we cannot refuse + * such a ban without diverging from Armada, we at least refuse to author one — this + * restricts what we write, never what we accept, so it cannot split consensus. + * Enforcing it on the fold needs a spec change; see the QA plan's open findings. + */ + fun concordBanTarget(note: Note): Pair? { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null + val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null + if (author == account.signer.pubKey) return null + val communityId = channel.channelId.communityId + val authority = + account.concordSessions + .sessionFor(communityId) + ?.state + ?.value + ?.authority ?: return null + if (authority.isOwner(author)) return null + // The owner short-circuits rather than going through canActOn: canActOn starts at + // hasPermission, which is false while banned, and a rogue BAN holder *can* currently put + // the owner on the banlist (see the KDoc) — routing the owner through it would let them be + // locked out of moderating their own community. + val canBan = authority.isOwner(account.signer.pubKey) || authority.canActOn(account.signer.pubKey, author, ConcordPermissions.BAN) + return if (canBan) communityId to author else null + } + + /** Add [member] to the community banlist. */ + suspend fun banConcordMember( + communityId: String, + member: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val wrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Remove [member] from the community banlist. */ + suspend fun unbanConcordMember( + communityId: String, + member: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val wrap = ConcordModeration.unban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + // ── Concord refounding / rekey (CORD-06) ────────────────────────────────── + // A ban is a soft removal — the banned member still holds the room key and can + // still decrypt traffic; every client just declines to *show* their posts. A + // Refounding is the hard removal: it rotates the community_root, so a removed + // member's key stops working for anything published afterwards. + + /** + * Remove [removed] from the community absolutely (CORD-06 Refounding): ban them, + * roll the `community_root`, re-key every retained member (Guestbook membership ∪ + * observed authors ∪ the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted + * Control Plane under the new root. A removed member keeps the prior root (so + * their history stays readable) but receives no blob, so they can never decrypt + * anything published after the rotation. + * + * Requires ownership or the BAN permission; returns false otherwise (or if the + * community isn't joined/writeable, or a target is the owner). + */ + suspend fun refoundConcordCommunity( + communityId: String, + removed: Set, + ): Boolean { + if (!account.isWriteable()) return false + val session = account.concordSessions.sessionFor(communityId) ?: return false + val state = session.state.value ?: return false + val authority = state.authority + val iCanBan = authority.isOwner(account.signer.pubKey) || authority.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.BAN) + if (!iCanBan) return false + val removedLower = removed.mapTo(HashSet()) { it.lowercase() } + if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false + + // 1. Ban the removed members on the current Control Plane so the compacted snapshot — + // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally + // first, so each subsequent edition chains onto the updated banlist head. + for (target in removedLower) { + val banWrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, banWrap) + } + + // 2. Recipient set: everyone we're keeping, minus the removed and the already-banned. + // Uses allMembers() — Guestbook joins ∪ OBSERVED AUTHORS ∪ roster ∪ owner — not just the + // Guestbook set. Most members never send a Guestbook Join (Amethyst announces one, other + // clients need not), so building the set without observed authors silently expelled every + // member who had only ever posted: they hold no role, receive no blob, and the Refounding + // strands them. That mainly hit cross-client communities, where Armada members are the + // bulk of the roster. + // + // Still a floor, not a census (see allMembers): a member who joined without a Guestbook + // motion, holds no role, and has never posted leaves no trace to find, so a Refounding + // cannot re-key them. Stranded recovery is what gets those members back. + val recipients = + (session.allMembers() + account.signer.pubKey) + .mapTo(HashSet()) { it.lowercase() } + .apply { + removeAll(removedLower) + removeAll(authority.bannedMembers()) + }.toList() + + // 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs. + val entry = session.entry + val newRoot = RandomInstance.bytes(32) + val build = + ConcordActions.buildRefounding( + rotatorSigner = account.signer, + communityId = communityId, + priorRoot = entry.root.hexToByteArray(), + newRoot = newRoot, + rootEpoch = entry.rootEpoch, + priorControlWraps = session.controlPlaneWraps(), + priorControlKey = session.controlPlaneKey(), + recipientsXOnly = recipients, + createdAt = TimeUtils.now(), + ) + + // 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs + // (the key that unlocks it) to the community relays. + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (publishTo.isNotEmpty()) { + build.controlWraps.forEach { account.client.publish(it, publishTo) } + build.rekeyWraps.forEach { account.client.publish(it, publishTo) } + } + + // 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and + // re-folds the compacted Control Plane (with the ban), dropping the removed members. + adoptConcordRoot(entry, newRoot, build.newEpoch) + return true + } + + // Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered + // in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not + // adopted — and re-published — twice on successive revision ticks. + private val adoptedConcordRotations = java.util.Collections.synchronizedSet(HashSet()) + + /** + * Persist a rotated access root/epoch for [entry], keeping the prior root as a + * [HeldRoot], and re-announce our Guestbook membership at the new epoch so the + * fresh epoch's Guestbook re-seeds (a later Refounding re-keys that membership — + * without this, cascading removals would lose everyone but the roster). No-op if + * this exact rotation was already adopted. + */ + private suspend fun adoptConcordRoot( + entry: ConcordCommunityListEntry, + newRoot: ByteArray, + newEpoch: Long, + ) { + if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return + val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch } + val next = + ConcordCommunityListEntry( + id = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + root = newRoot.toHexKey(), + rootEpoch = newEpoch, + heldRoots = held, + privateChannels = entry.privateChannels, + relays = entry.relays, + name = entry.name, + addedAt = entry.addedAt, + // The invite_ref anchor must survive a rotation, or the *next* Refounding we're left + // out of would be unrecoverable. + inviteRef = entry.inviteRef, + excludedAtEpoch = entry.excludedAtEpoch, + // Unknown keys another client wrote (Armada's list is `[k: string]: unknown`) + // must survive our rotation write, or we delete their data on every rekey. + residue = entry.residue, + ) + account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(next)) + announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) + } + + /** + * Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for + * each joined community, look for our new root among the kind-3303 wraps seen at + * our next base-rekey address. If a role-authorized rotator (owner or a current, + * non-banned BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the + * session rebuilds at the new epoch and its next-rekey address moves on, so a stale + * wrap never re-triggers. Called on every Concord revision tick. + * + * Authority is the roster, never key possession: any non-banned BAN-holder may + * rotate, including for the owner. The owner deliberately does NOT refuse a root + * authored by someone else — refusing would strand the owner alone on the dead + * epoch whenever an admin legitimately rotates, and would diverge from Armada, + * which forks a community across clients. Self-escalation to BAN is prevented + * upstream by the role rank gate in AuthorityResolver. + * + * A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list, + * so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the + * owner included) by omission — nothing on this receive path can prevent it. The + * cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves + * the invite link the membership was joined through and merges forward. + */ + internal suspend fun drainConcordRekeys() { + if (!account.isWriteable()) return + for (session in account.concordSessions.sessions()) { + val wraps = session.pendingBaseRekeyWraps() + if (wraps.isEmpty()) continue + val entry = session.entry + val received = + ConcordActions.openBaseRekey( + wraps = wraps, + baseRekey = session.nextBaseRekeyKey(), + recipientSigner = account.signer, + priorRoot = entry.root.hexToByteArray(), + rootEpoch = entry.rootEpoch, + ) ?: continue + if (received.newEpoch <= entry.rootEpoch) continue + val authority = session.state.value?.authority ?: continue + + // hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder + // who has themselves been banned could still rotate the whole community. + val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN) + if (!authorized) continue + adoptConcordRoot(entry, received.newRoot, received.newEpoch) + } + } + + // Last time we re-resolved each community's invite_ref, so the recovery sweep rides the + // Concord revision tick (which fires on every structural change) without turning it into a + // relay-fetch loop. + private val lastConcordRecoveryCheck = ConcurrentHashMap() + + /** + * Stranded recovery (CORD-05/06 receive path). A Refounding carries only + * `(newRoot, newEpoch, rotator)` — **no recipient list** — so a member simply left + * out of the rekey recipient set receives nothing and sits on the dead epoch + * forever while everyone else moves on. This happens to any member, the owner + * included, and [drainConcordRekeys] cannot prevent it: there is no message to + * miss detecting. + * + * The way back is the invite link the membership was joined through + * ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and + * carried through every rotation by [adoptConcordRoot]). The community keeps + * re-minting its bundle at that same addressable coordinate, so a bundle there at + * a **strictly higher** epoch than ours proves we were left behind — and carries + * the new root. Same or lower epoch is a no-op. Memberships with no link (direct + * invites, legacy entries) are inert here; that is expected, not an error. + * + * The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps + * both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the + * entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays + * derivable). We then re-announce the Guestbook at the new epoch, exactly as an + * ordinary rotation does, so the recovered member is visible to whoever refounds + * next instead of being silently dropped again. + * + * Called on the Concord revision tick, but rate-limited per community + * ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup. + */ + internal suspend fun recoverStrandedConcordCommunities() { + if (!account.isWriteable()) return + val now = TimeUtils.nowMillis() + for (entry in account.concordChannelList.liveCommunities.value) { + val inviteRef = entry.inviteRef ?: continue + val last = lastConcordRecoveryCheck[entry.id] + if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue + lastConcordRecoveryCheck[entry.id] = now + + val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue + val relays = + ( + parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + + entry.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + ).toSet() + if (relays.isEmpty()) continue + + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = account.client.fetchAll(filters = filters) + // Only a live bundle recovers: an expired/revoked link is not a rotation we missed. + val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue + + val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue + if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue + Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}") + account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged)) + announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null) + } + } + + /** + * Replace the community metadata (name / icon / description / relays) with a new + * Control-Plane edition. Honored on fold only when this account holds + * MANAGE_METADATA (or is the owner); dropped otherwise, like every other edition. + */ + suspend fun editConcordMetadata( + communityId: String, + name: String, + description: String?, + icon: ImagePointer?, + banner: ImagePointer?, + relays: List, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays) + val wrap = ConcordModeration.editMetadata(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** + * Create a new public text channel in [communityId] (CORD-03/04 channel edition). Honored at fold + * only when this account holds MANAGE_CHANNELS (or is the owner); the button should be gated on + * the same predicate. The channel id is a fresh random 32-byte entity id. + */ + suspend fun createConcordChannel( + communityId: String, + name: String, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val channelId = RandomInstance.bytes(32) + val channel = ChannelEntity(name = name.trim()) + val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Rename an existing channel (chains the next channel edition onto its head). MANAGE_CHANNELS only. */ + suspend fun renameConcordChannel( + communityId: String, + channelIdHex: String, + name: String, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + // Carry the standing definition forward and change only the name. A ChannelEntity built from + // scratch defaults `private` and `voice` to false, so renaming a private channel used to + // publish an edition declaring it PUBLIC — and a voice channel became a text channel. + val standing = + session.state.value + ?.channels + ?.get(channelIdHex) + ?.definition + val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false) + val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Delete (tombstone) a channel — terminal; its id is never reused. MANAGE_CHANNELS only. */ + suspend fun deleteConcordChannel( + communityId: String, + channelIdHex: String, + name: String, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + // Same as rename: preserve the standing flags so a tombstone does not also silently + // reclassify the channel it retires. + val standing = + session.state.value + ?.channels + ?.get(channelIdHex) + ?.definition + val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true) + val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + return true + } + + /** + * Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from + * the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT + * joining. Returns the [CommunityInvite] (name, relays, community coordinates) so a + * card can show what the link opens, or null if the link is invalid/unreadable. + */ + suspend fun peekConcordInvite(url: String): CommunityInvite? { + val parsed = ConcordActions.parseInviteLink(url) ?: return null + val relays = + (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + account.outboxRelays.flow.value).toSet() + if (relays.isEmpty()) return null + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = account.client.fetchAll(filters = filters) + return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } + } + + /** + * Bootstrap the Concord hub from the network: fetch this account's kind-13302 + * joined-communities list and fold the newest into [LocalCache], so communities + * we joined on another Concord client with this key surface here. + * + * We query a wide relay set because different Concord clients publish this + * private list to different places: the reference clients (Armada/Vector) push + * it to the Concord **stock relays** (e.g. relay.ditto.pub), while a user may + * also have copied it onto their **own** outbox/read relays. Our normal account + * subscription never asks for kind 13302, so without this explicit fetch a + * community joined on Armada would never appear — even if the list sits on the + * user's own outbox. + * + * Read-only import: kind 13302 is replaceable, so folding an older copy is a + * no-op and this is safe to call on every hub open. Merging our own edits with + * a foreign writer's is a separate concern (newest-wins replaceable). + * + * [extraRelays] are additional relays to query — the bootstrap relays saved on the + * bottom-bar tabs of pinned communities. A community's private list frequently lives + * only on the community's own relays (never the user's outbox), so a community pinned + * to the bottom bar would otherwise never surface when opened cold. + */ + suspend fun importConcordCommunities(extraRelays: Set = emptySet()) { + val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + val relays = (stock + account.mineRelays.flow.value + account.outboxRelays.flow.value + extraRelays).toSet() + if (relays.isEmpty()) return + val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(account.signer.pubKey)) + // Stock relays like relay.ditto.pub can be slow (~10–20s to first response), so give + // the fetch a generous window to drain every relay before we pick the newest copy. + val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = 30_000L) + val newest = events.filterIsInstance().maxByOrNull { it.createdAt } + val entryCount = newest?.let { runCatching { it.decrypt(account.signer).size }.getOrElse { -1 } } ?: 0 + Log.d( + "Concord", + "importConcordCommunities: queried ${relays.size} relays, fetched ${events.size} 13302 event(s), " + + "newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}", + ) + newest?.let { account.cache.justConsumeMyOwnEvent(it) } + } + + /** + * One-shot warm of every channel of [entries] so a community's channel list and the Messages inbox + * fill in without the user opening each channel one by one. Per channel, a channel read before is + * caught up from its last-read time (accurate unread badge + the missed messages ready when it + * opens) while a channel never read pulls only its single newest wrap for a preview — see + * [ConcordSubscriptionPlanner.channelPreviewFilters]. + * + * This is deliberately **not** a live subscription: every wrap the drain pulls flows through the + * global cache connector (`CacheClientConnector` → `LocalCache.justConsume` → `concordSessions.ingest`), + * so it lands in the channel's message store the previews/unread counts read — and the always-on + * plane subscription ([RelaySubscriptionsCoordinator.concordChannels]) keeps them fresh afterward. + * So this only needs to run when a community's channels first fold (the account preload) or its + * screen is opened. One drain per call: all [entries]' per-channel filters are grouped by relay. + */ + suspend fun warmConcordChannelPreviews(entries: List) { + val filters = + entries.flatMap { entry -> + val state = + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value ?: return@flatMap emptyList() + ConcordSubscriptionPlanner.channelPreviewFilters( + entry, + state, + lastReadFor = { channelIdHex -> + account.loadLastRead(concordChannelLastReadRoute(entry.id, channelIdHex)) + }, + accountPubKey = account.userProfile().pubkeyHex, + ) + } + if (filters.isEmpty()) return + val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } } + account.client.fetchAll(filters = byRelay, timeoutMs = 20_000L) + } + + /** + * COMPLETE-mode Control-Plane sync — Armada's plane-sweep discipline for the one plane that must + * never fold on a truncated edition set. + * + * The Control Plane defines the channel list, the roster and the banlist, so a *partial* fold + * silently drops channels or mis-renders membership. Two ways that happens, both closed here: + * - **Forward-cursor gap:** the live plane subscription advances a `since` cursor, so an edition + * with a `created_at` below the high-water mark that we never actually ingested — an unban + * published while we were offline, a CORD-06 compaction re-wrap under a newly-held epoch — is + * never asked for again and stays invisible. This sweep uses **no `since`**: it re-fetches the + * whole plane every run. + * - **Per-filter cap:** a relay caps a REQ's result (~100/filter on relay.dreamith.to), which can + * crop a busy Control Plane. This **pages past the cap** ([fetchAllPagesFromPool] walks `until` + * cursors until a plane is drained), so the fold sees every edition regardless of the cap. + * + * Current + every held-prior epoch's Control Plane is swept (the anti-rollback floor folds from the + * priors). Wraps ingest through the global cache connector → [concordSessions] like every other + * Concord drain; AUTH is the shared stream-key handler. Merging communities that share a relay into + * one filter is safe here precisely because we page — the cap no longer truncates. The live control + * subscription still carries brand-new editions in real time; this is the periodic completeness pass. + */ + suspend fun syncConcordControlPlanes(entries: List) { + if (entries.isEmpty()) return + val authorsByRelay = HashMap>() + for (entry in entries) { + for (sub in ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry))) { + for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) + } + } + if (authorsByRelay.isEmpty()) return + // No `since`, no `limit` → fetchAllPages treats each filter as unbounded and pages until a + // plane is fully drained (empty page), so the whole Control Plane lands regardless of the cap. + val byRelay = authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors.toList())) } + var drained = 0 + account.client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ } + Log.d("Concord", "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)") + } + + // ── NIP-29 relay-group actions ─────────────────────────────────────────── + // All group commands are published ONLY to the group's host relay, where + // relay29 authorizes them. The relay is the source of truth; the kind-10009 + // list is our own cross-device bookkeeping of what we joined. + + /** Send a kind 9021 join request to the group's host relay and remember it. */ +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt index 6e08f8b3e2..67af29c2a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt @@ -76,7 +76,7 @@ fun ConcordInviteCard( // Peek the bundle once per link to reveal the community name (null until it resolves). val invite by produceState(initialValue = null, linkText) { - value = accountViewModel.account.peekConcordInvite(linkText) + value = accountViewModel.account.concord.peekConcordInvite(linkText) } val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index d78cb1919d..a1161ab2b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -275,8 +275,8 @@ fun CardBody( val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) // Concord moderation: only present when this account may actually act. - val canConcordBan = remember(note) { accountViewModel.account.concordBanTarget(note) != null } - val concordAdmin = remember(note) { accountViewModel.account.concordAdminTarget(note) } + val canConcordBan = remember(note) { accountViewModel.account.concord.concordBanTarget(note) != null } + val concordAdmin = remember(note) { accountViewModel.account.concord.concordAdminTarget(note) } val showConcordBanDialog = remember { mutableStateOf(false) } if (showConcordBanDialog.value) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt index e05844f5c5..df3f8c5572 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt @@ -371,7 +371,7 @@ fun noteActionSections( // message's author (both return null unless it's a Concord message this // account may act on). Promote/demote is instant; a ban re-keys the // community, so it defers to the surface's confirmation dialog. - val concordAdmin = accountViewModel.account.concordAdminTarget(note) + val concordAdmin = accountViewModel.account.concord.concordAdminTarget(note) if (concordAdmin != null) { val isAdmin = concordAdmin.third add( @@ -384,7 +384,7 @@ fun noteActionSections( }, ) } - if (handlers.onConcordBan != null && accountViewModel.account.concordBanTarget(note) != null) { + if (handlers.onConcordBan != null && accountViewModel.account.concord.concordBanTarget(note) != null) { add(NoteAction(MaterialSymbols.Gavel, stringRes(R.string.concord_ban_user), isDestructive = true, onClick = handlers.onConcordBan)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 49c9ef5be0..70079e66bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -548,7 +548,7 @@ class AccountViewModel( // public relays. Route the reaction through a channel-plane wrap instead. (Retraction of an // existing Concord reaction is a follow-up; for now this only adds one.) if (note.inGatherers?.any { it is ConcordChannel } == true) { - launchSigner { account.reactToConcordMessage(note, reaction) } + launchSigner { account.concord.reactToConcordMessage(note, reaction) } return } @@ -606,15 +606,15 @@ class AccountViewModel( /** Ban the author of a Concord channel message (no-op unless this account may ban them). */ fun banConcordMember(note: Note) { - val (communityId, member) = account.concordBanTarget(note) ?: return - launchSigner { account.banConcordMember(communityId, member) } + val (communityId, member) = account.concord.concordBanTarget(note) ?: return + launchSigner { account.concord.banConcordMember(communityId, member) } } /** Toggle the Admin role on the author of a Concord channel message (owner only). */ fun toggleConcordAdmin(note: Note) { - val (communityId, member, isAdmin) = account.concordAdminTarget(note) ?: return + val (communityId, member, isAdmin) = account.concord.concordAdminTarget(note) ?: return launchSigner { - if (isAdmin) account.removeConcordAdmin(communityId, member) else account.makeConcordAdmin(communityId, member) + if (isAdmin) account.concord.removeConcordAdmin(communityId, member) else account.concord.makeConcordAdmin(communityId, member) } } @@ -624,7 +624,7 @@ class AccountViewModel( member: HexKey, makeAdmin: Boolean, ) = launchSigner { - if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member) + if (makeAdmin) account.concord.makeConcordAdmin(communityId, member) else account.concord.removeConcordAdmin(communityId, member) } /** @@ -640,7 +640,7 @@ class AccountViewModel( member: HexKey, roleIds: List, ) = launchSigner { - if (!account.grantConcordRole(communityId, member, roleIds)) { + if (!account.concord.grantConcordRole(communityId, member, roleIds)) { toastManager.toast(R.string.concord_members_roles_title, R.string.concord_members_roles_failed) } } @@ -651,7 +651,7 @@ class AccountViewModel( member: HexKey, ban: Boolean, ) = launchSigner { - if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member) + if (ban) account.concord.banConcordMember(communityId, member) else account.concord.unbanConcordMember(communityId, member) } /** @@ -663,7 +663,7 @@ class AccountViewModel( communityId: String, member: HexKey, ) = launchSigner { - account.refoundConcordCommunity(communityId, setOf(member)) + account.concord.refoundConcordCommunity(communityId, setOf(member)) } /** @@ -683,7 +683,7 @@ class AccountViewModel( else -> emptyList() } }.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) } - account.importConcordCommunities(pinnedRelays) + account.concord.importConcordCommunities(pinnedRelays) } /** Publish an ephemeral typing heartbeat to a Concord channel (throttled by the caller). */ @@ -691,7 +691,7 @@ class AccountViewModel( communityId: String, channelIdHex: String, ) = viewModelScope.launch(Dispatchers.IO) { - account.sendConcordTyping(communityId, channelIdHex) + account.concord.sendConcordTyping(communityId, channelIdHex) } fun sendBuzzTyping(channel: RelayGroupChannel) = @@ -1756,7 +1756,7 @@ class AccountViewModel( * what makes leaving a community whose own relays are dead work at all — the list lives in *our* * outbox, not in the community's relays. */ - fun leaveConcordCommunity(communityId: String) = launchSigner { account.leaveConcordCommunity(communityId) } + fun leaveConcordCommunity(communityId: String) = launchSigner { account.concord.leaveConcordCommunity(communityId) } fun createRelayGroup( relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 532b172116..1b40e49820 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -190,9 +190,9 @@ fun ConcordChannelListScreen( channelEditor = null scope.launch { if (editor.channelIdHex == null) { - account.createConcordChannel(communityId, newName) + account.concord.createConcordChannel(communityId, newName) } else { - account.renameConcordChannel(communityId, editor.channelIdHex, newName) + account.concord.renameConcordChannel(communityId, editor.channelIdHex, newName) } } }, @@ -208,7 +208,7 @@ fun ConcordChannelListScreen( confirmButton = { TextButton(onClick = { channelToDelete = null - scope.launch { account.deleteConcordChannel(communityId, id, target.initialName) } + scope.launch { account.concord.deleteConcordChannel(communityId, id, target.initialName) } }) { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_confirm)) } @@ -254,7 +254,7 @@ fun ConcordChannelListScreen( minting = true scope.launch { try { - inviteLink = account.mintConcordInvite(communityId) + inviteLink = account.concord.mintConcordInvite(communityId) } finally { // Always clear the flag — a thrown mint would otherwise leave the // button disabled until the screen is recreated. 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 dcd7651d6d..d6a5116be7 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 @@ -596,7 +596,7 @@ private fun ConcordFileUploadDialog( onceUploaded = { uploads -> val imetas = uploads.mapNotNull { it.toConcordImeta() } if (imetas.isNotEmpty()) { - accountViewModel.account.sendConcordChannelImageMessage(community, channel, "", imetas) + accountViewModel.account.concord.sendConcordChannelImageMessage(community, channel, "", imetas) } onUpload() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index e92283510e..5d2a043e15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -120,7 +120,7 @@ fun ConcordCreateScreen( scope.launch { val communityId = try { - accountViewModel.account.createConcordCommunity( + accountViewModel.account.concord.createConcordCommunity( name = name.value.trim(), description = about.value.trim().ifBlank { null }, relays = relays.map { it.url }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index 63397ae827..91a1262982 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -163,7 +163,7 @@ fun ConcordEditScreen( scope.launch { val ok = try { - account.editConcordMetadata( + account.concord.editConcordMetadata( communityId = communityId, name = name.value.trim(), description = about.value.trim().ifBlank { null }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index d66aef1608..2788dee7ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -116,7 +116,7 @@ fun ConcordInviteScreen( LaunchedEffect(link, state) { if (state is RedeemState.Working) { state = - when (val result = accountViewModel.account.joinConcordViaInvite(link)) { + when (val result = accountViewModel.account.concord.joinConcordViaInvite(link)) { is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId) is ConcordInviteResult.InvalidLink -> RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt index 36dc1e6995..7c287c1241 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelPreviewLoader.kt @@ -50,7 +50,7 @@ fun ConcordChannelPreviewLoader( val entry = account.concordChannelList.liveCommunities.value .firstOrNull { it.id == communityId } ?: return@LaunchedEffect - account.warmConcordChannelPreviews(listOf(entry)) + account.concord.warmConcordChannelPreviews(listOf(entry)) } } @@ -72,6 +72,6 @@ fun ConcordChannelPreviewAccountPreload(accountViewModel: AccountViewModel) { LaunchedEffect(communities, revision) { // Debounce the cold-boot burst of fold revisions (and any join/leave churn) into one drain. delay(1500) - account.warmConcordChannelPreviews(communities) + account.concord.warmConcordChannelPreviews(communities) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt index 71f86fb4f8..7f308df781 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -150,7 +150,7 @@ private fun ConcordControlPlaneSync(accountViewModel: AccountViewModel) { // (1) Load + membership/epoch change: one complete sweep of the whole set. LaunchedEffect(sig) { - if (communities.isNotEmpty()) account.syncConcordControlPlanes(communities) + if (communities.isNotEmpty()) account.concord.syncConcordControlPlanes(communities) } // (2) Reconnect: re-sweep when a relay of ours transitions disconnected → connected. @@ -174,7 +174,7 @@ private fun ConcordControlPlaneSync(accountViewModel: AccountViewModel) { val now = TimeUtils.nowMillis() if (now - lastSweep < RECONNECT_RESWEEP_MIN_INTERVAL_MS) return@collect lastSweep = now - account.syncConcordControlPlanes(liveCommunities) + account.concord.syncConcordControlPlanes(liveCommunities) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index 5a37ff3e8a..3f57d276b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -204,11 +204,11 @@ open class ConcordNewMessageViewModel : ViewModel() { val editing = editingMessage.value if (editing != null) { - account.editConcordChannelMessage(editing, text) + account.concord.editConcordChannelMessage(editing, text) editingMessage.value = null } else { val parent = replyTo.value - account.sendConcordChannelMessage(community, channel, text, parent, replyMode.value) + account.concord.sendConcordChannelMessage(community, channel, text, parent, replyMode.value) } message.clearText() From 20149e26006b87f28424de742dd7b73dfb93c354 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:07:24 +0000 Subject: [PATCH 067/227] refactor: extract AccountMarmotActions from Account Moves the ~540-line Marmot/MLS orchestration cluster (group create/ leave/reset, member add/remove via key-package fetch, admin grant/ revoke, metadata updates, group messaging, key-package publishing and relay resolution) into AccountMarmotActions, exposed as account.marmot. External callers (marmot group screens, AccountViewModel forwarders, NotificationReplyReceiver, DecryptAndIndexProcessor) now call account.marmot.* directly. Moved code is unchanged except for account. qualification. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../vitorpamplona/amethyst/model/Account.kt | 540 +--------------- .../amethyst/model/AccountMarmotActions.kt | 580 ++++++++++++++++++ .../NotificationReplyReceiver.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 38 +- .../loggedIn/DecryptAndIndexProcessor.kt | 2 +- 5 files changed, 605 insertions(+), 557 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 0508c6e857..b86ecdf03d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -758,6 +758,9 @@ class Account( /** Concord community orchestration (join/create/messages/moderation). */ val concord = AccountConcordActions(this) + /** Marmot/MLS group orchestration (create/members/admins/messages/key packages). */ + val marmot = AccountMarmotActions(this) + /** * Relay routing + sign-and-publish choke point: computes which relays an event * should go to (outbox model, hints, channels, broadcast lists) and owns every @@ -3521,541 +3524,6 @@ class Account( // --- Marmot Group Messaging --- - /** - * Resolve the relay set for a Marmot group. Prefer the relays carried in - * the MLS GroupContext metadata so every member converges on the same - * canonical set; fall back to the account's outbox relays if the group - * has none (e.g. a group joined before MIP-01 metadata existed). - * - * Lives on Account (not AccountViewModel) so that headless callers — - * notifications' BroadcastReceiver, background workers — can resolve - * relays without spinning up a ViewModel. - */ - fun marmotGroupRelays(nostrGroupId: HexKey): Set { - val groupRelays = - marmotManager - ?.groupMetadata(nostrGroupId) - ?.relays - ?.mapNotNull { - com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer - .normalizeOrNull(it) - }?.toSet() - return if (!groupRelays.isNullOrEmpty()) groupRelays else outboxRelays.flow.value - } - - /** - * Send a message to a Marmot MLS group. - * Encrypts the inner event and publishes the GroupEvent to group relays. - */ - suspend fun sendMarmotGroupMessage( - nostrGroupId: HexKey, - innerEvent: Event, - groupRelays: Set, - ) { - Log.d("MarmotDbg") { - "sendMarmotGroupMessage: group=${nostrGroupId.take(8)}… innerKind=${innerEvent.kind} innerId=${innerEvent.id.take(8)}… " + - "→ ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}" - } - val manager = marmotManager ?: return - if (!isWriteable()) return - - val outbound = manager.buildGroupMessage(nostrGroupId, innerEvent) - Log.d("MarmotDbg") { - "sendMarmotGroupMessage: built outer kind:${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}…" - } - // Link the envelope to the inner message we just encrypted so relay - // OK acceptances drill down to the note the chat renders (see - // LocalCache.addRelayToNoteAndInners). - outbound.signedEvent.innerEventId = innerEvent.id - cache.justConsumeMyOwnEvent(outbound.signedEvent) - // Sending a message moves the group out of "New Requests" into - // "Known" — do this eagerly before relay round-trip so the UI - // updates immediately. - marmotGroupList.markAsKnown(nostrGroupId) - if (groupRelays.isEmpty()) { - Log.w("MarmotDbg") { - "sendMarmotGroupMessage: NO group relays for group=${nostrGroupId.take(8)}… — message will be silently dropped" - } - } - client.publish(outbound.signedEvent, groupRelays) - } - - /** - * Fetch a user's KeyPackage from relays and add them to a Marmot group. - * Returns a status message describing the outcome. - */ - @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) - suspend fun fetchKeyPackageAndAddMember( - nostrGroupId: HexKey, - memberPubKey: HexKey, - ): String { - Log.d("MarmotDbg") { - "fetchKeyPackageAndAddMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}…" - } - val manager = marmotManager ?: return "Error: Marmot not initialized" - if (!isWriteable()) return "Error: Account is read-only" - - // Per MIP-00, invitees advertise the relays that host their - // KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look - // there first, then fall back to the invitee's NIP-65 outbox - // (where KeyPackages typically also land), and finally union - // with our own outbox so we still find packages that ended up - // on a shared relay. - val myOutbox = outboxRelays.flow.value - val memberKeyPackageRelays = - ( - cache - .getAddressableNoteIfExists( - com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent - .createAddress(memberPubKey), - )?.event as? com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent - )?.relays()?.toSet().orEmpty() - val memberOutbox = - cache - .getOrCreateUser(memberPubKey) - .outboxRelays() - ?.toSet() - .orEmpty() - val fetchRelays = - com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher - .fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox) - - Log.d("MarmotDbg") { - "fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " + - "(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}" - } - - val event = - com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher - .fetchKeyPackage(client, memberPubKey, fetchRelays) - - if (event == null) { - Log.w("MarmotDbg") { - "fetchKeyPackageAndAddMember: NO KeyPackage found for ${memberPubKey.take(8)}… on any of ${fetchRelays.size} relay(s)" - } - return "Error: No KeyPackage found for this user. They may not have published one yet." - } - - Log.d("MarmotDbg") { - "fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}…" - } - - val keyPackageBase64 = event.keyPackageBase64() - if (keyPackageBase64.isBlank()) { - Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" } - return "Error: KeyPackage event has empty content" - } - - // The relays embedded in the WelcomeEvent tell the new member - // where to subscribe for subsequent GroupEvents. Use our own - // outbox — that's where we will publish them. - val groupRelays = myOutbox.toList() - - Log.d("MarmotDbg") { - "fetchKeyPackageAndAddMember: addMarmotGroupMember → groupRelays=${groupRelays.size}: ${groupRelays.map { it.url }}" - } - - addMarmotGroupMember( - nostrGroupId = nostrGroupId, - keyPackageEvent = event, - groupRelays = groupRelays, - ) - - return "Success: Member added to group" - } - - /** - * Add a member to a Marmot MLS group. - * Publishes the commit GroupEvent, then sends the Welcome gift wrap. - */ - suspend fun addMarmotGroupMember( - nostrGroupId: HexKey, - keyPackageEvent: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent, - groupRelays: List, - ) { - val memberPubKey = keyPackageEvent.pubKey - Log.d("MarmotDbg") { - "addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}… " + - "groupRelays=${groupRelays.size}" - } - val manager = marmotManager ?: return - if (!isWriteable()) return - - val (commitEvent, welcomeDelivery) = - manager.addMember( - nostrGroupId = nostrGroupId, - keyPackageEvent = keyPackageEvent, - relays = groupRelays, - ) - - // The MLS commit has already been applied to the local group state — - // surface the new member list in the chatroom now so observers (e.g. - // MarmotGroupInfoScreen) update without waiting for our own commit to - // loop back through the relay. - val chatroom = marmotGroupList.getOrCreateGroup(nostrGroupId) - manager.syncMetadataTo(nostrGroupId, chatroom) - - Log.d("MarmotDbg") { - "addMarmotGroupMember: built commit kind=${commitEvent.signedEvent.kind} id=${commitEvent.signedEvent.id.take(8)}… " + - "welcomeDelivery=${if (welcomeDelivery != null) "present(giftWrapId=${welcomeDelivery.giftWrapEvent.id.take(8)}…)" else "null"}" - } - - // Publish commit first (critical ordering) - Log.d("MarmotDbg") { - "addMarmotGroupMember: publishing commit kind:${commitEvent.signedEvent.kind} to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}" - } - client.publish(commitEvent.signedEvent, groupRelays.toSet()) - - // Then send the Welcome gift wrap to the new member. - // - // Use the same delivery path that NIP-17 DMs (kind:1059) take — - // computeRelayListToBroadcast() — which has fallbacks for kind:10050 - // → NIP-65 read → relay hints. Empirically, NIP-17 DMs reach the - // invitee, so this path is the one we know works. We also union - // with our own outbox + the recipient's dmInboxRelays() as a - // belt-and-braces measure in case the cache hasn't been hydrated - // yet for this contact. - if (welcomeDelivery != null) { - val computed = computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent) - val recipientInbox = - cache - .getOrCreateUser(memberPubKey) - .dmInboxRelays() - .orEmpty() - val relayList = computed + outboxRelays.flow.value + recipientInbox - Log.d("MarmotDbg") { - "addMarmotGroupMember: welcome gift wrap relay sources " + - "computeRelayListToBroadcast=${computed.size} myOutbox=${outboxRelays.flow.value.size} " + - "recipientInbox=${recipientInbox.size} → union=${relayList.size}" - } - if (relayList.isEmpty()) { - Log.w("MarmotDbg") { - "addMarmotGroupMember: NO relays to deliver welcome gift wrap to ${memberPubKey.take(8)}… — welcome will be silently dropped" - } - } else { - Log.d("MarmotDbg") { - "addMarmotGroupMember: publishing welcome gift wrap id=${welcomeDelivery.giftWrapEvent.id.take(8)}… " + - "kind:${welcomeDelivery.giftWrapEvent.kind} → ${relayList.size} relay(s): ${relayList.map { it.url }}" - } - } - client.publish(welcomeDelivery.giftWrapEvent, relayList) - } else { - Log.w("MarmotDbg") { - "addMarmotGroupMember: welcomeDelivery is NULL — invitee ${memberPubKey.take(8)}… will receive nothing!" - } - } - } - - /** - * Relays where this account publishes kind:30443 KeyPackage events. - * Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox. - */ - fun keyPackagePublishRelays(): Set = - com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher - .publishRelaysFor(keyPackageRelayList.flow.value, outboxRelays.flow.value) - - /** - * Publish or rotate KeyPackage events. - */ - suspend fun publishMarmotKeyPackages() { - val manager = - marmotManager ?: run { - Log.w("MarmotDbg") { "publishMarmotKeyPackages: marmotManager is NULL — no-op" } - return - } - if (!isWriteable()) { - Log.w("MarmotDbg") { "publishMarmotKeyPackages: account is not writeable — no-op" } - return - } - - val relays = keyPackagePublishRelays() - val needsRotation = manager.needsKeyPackageRotation() - Log.d("MarmotDbg") { - "publishMarmotKeyPackages: needsRotation=$needsRotation relays=${relays.size}" - } - - if (needsRotation) { - val rotatedEvents = manager.rotateConsumedKeyPackages(relays.toList()) - Log.d("MarmotDbg") { - "publishMarmotKeyPackages: rotateConsumedKeyPackages produced ${rotatedEvents.size} event(s)" - } - rotatedEvents.forEach { event -> - cache.justConsumeMyOwnEvent(event) - Log.d("MarmotDbg") { - "publishMarmotKeyPackages: publishing rotated kind:${event.kind} id=${event.id.take(8)}… " + - "→ ${relays.size} relay(s): ${relays.map { it.url }}" - } - client.publish(event, relays) - } - } - } - - /** - * Generate and publish initial KeyPackage for this account. - */ - suspend fun publishMarmotKeyPackage() { - val manager = marmotManager ?: return - if (!isWriteable()) return - - val relays = keyPackagePublishRelays() - Log.d("MarmotDbg") { - "publishMarmotKeyPackage: generating + publishing KeyPackage event → ${relays.size} relay(s): ${relays.map { it.url }}" - } - val event = manager.generateKeyPackageEvent(relays.toList()) - Log.d("MarmotDbg") { - "publishMarmotKeyPackage: signed kind:${event.kind} id=${event.id.take(8)}… authored=${event.pubKey.take(8)}…" - } - cache.justConsumeMyOwnEvent(event) - client.publish(event, relays) - } - - /** - * Ensure the local user has at least one active KeyPackage bundle and - * a published KeyPackage event on relays. Called from [init] after - * Marmot state has been restored from disk. - * - * - If [KeyPackageRotationManager] already has an active bundle (from - * the persisted snapshot), we trust the previous session and do - * nothing. The matching kind:30443 should already be on relays from - * when the bundle was first generated. - * - Otherwise we generate a fresh bundle (which is now persisted to - * disk by [KeyPackageRotationManager.generateKeyPackage]) and - * publish the corresponding event. - * - * Best-effort: failures are logged but never propagated. We don't want - * a flaky relay or missing outbox config at startup to crash account - * initialization. - */ - private suspend fun ensureMarmotKeyPackagePublished() { - val manager = marmotManager ?: return - if (!isWriteable()) return - try { - val hasBundle = manager.hasActiveKeyPackages() - Log.d("MarmotDbg") { - "ensureMarmotKeyPackagePublished: hasActiveKeyPackages=$hasBundle for ${signer.pubKey.take(8)}…" - } - if (hasBundle) { - return - } - Log.d("MarmotDbg") { - "ensureMarmotKeyPackagePublished: no active bundle — generating + publishing now" - } - publishMarmotKeyPackage() - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w("MarmotDbg", "ensureMarmotKeyPackagePublished failed: ${e.message}", e) - } - } - - /** - * Check if a KeyPackage has been published in this session. - * The d-tag is a randomly-generated value stored in the KeyPackageRotationManager's - * persisted snapshot, so there is no fixed address to query in the cache. - */ - suspend fun hasPublishedKeyPackage(): Boolean { - val manager = marmotManager ?: return false - return manager.hasActiveKeyPackages() - } - - /** - * Create a new Marmot MLS group. - */ - suspend fun createMarmotGroup(nostrGroupId: HexKey) { - val manager = marmotManager ?: return - if (!isWriteable()) return - manager.createGroup(nostrGroupId) - // Creator owns the group — mark it as "known" immediately so it - // doesn't appear under "New Requests" before the first message. - marmotGroupList.markAsKnown(nostrGroupId) - } - - /** - * Leave a Marmot MLS group. - * Publishes the SelfRemove proposal and removes local state. - * - * MIP-01/MIP-03: admins MUST first publish a GroupContextExtensions - * commit dropping themselves from `admin_pubkeys` before issuing a - * SelfRemove proposal. Without that, [MlsGroup.selfRemove] throws - * `IllegalStateException("Admin must self-demote via GroupContextExtensions - * before SelfRemove (MIP-01)")` and the leave aborts. Demote commit and - * SelfRemove proposal both go to the same group relays, demote first so - * peers apply it before they see the SelfRemove. - */ - suspend fun leaveMarmotGroup( - nostrGroupId: HexKey, - groupRelays: Set, - ) { - val manager = marmotManager ?: return - if (!isWriteable()) return - - val metadata = manager.groupMetadata(nostrGroupId) - if (metadata != null && metadata.adminPubkeys.contains(signer.pubKey)) { - val remaining = metadata.adminPubkeys.filter { it != signer.pubKey }.toMutableList() - // MIP-03 also rejects any GCE commit that leaves the group with zero - // admins. If we're the only one, promote an arbitrary non-self - // member to admin before stepping down. - if (remaining.isEmpty()) { - val heir = - manager - .memberPubkeys(nostrGroupId) - .map { it.pubkey } - .firstOrNull { it != signer.pubKey } - if (heir != null) remaining.add(heir) - } - if (remaining.isNotEmpty()) { - val demoted = metadata.copy(adminPubkeys = remaining) - val demoteCommit = manager.updateGroupMetadata(nostrGroupId, demoted) - client.publish(demoteCommit.signedEvent, groupRelays) - } - } - - val outbound = manager.leaveGroup(nostrGroupId) - // manager.leaveGroup already wiped MLS state, relay subscriptions and - // the persisted message log. Drop the in-memory chatroom too — that - // releases the strong refs to the decrypted inner notes so LocalCache - // (which holds them weakly) can GC them, and the Notification feed - // (which iterates marmotGroupList.rooms) stops surfacing the group. - marmotGroupList.removeGroup(nostrGroupId) - client.publish(outbound.signedEvent, groupRelays) - } - - /** - * User-initiated "nuclear" reset for the Marmot subsystem. - * - * Wipes every MLS group, every retained epoch secret, every persisted - * KeyPackage bundle, every relay subscription and every in-memory - * chatroom associated with this account. Does NOT broadcast any - * SelfRemove/leave commits to peers — if the user is in this flow at - * all, local state may already be unusable and a graceful leave is - * probably not possible. Peers will see the user as unresponsive until - * their next commit evicts the stale leaf. - * - * A fresh KeyPackage will be republished lazily on the next - * `ensureMarmotKeyPackagePublished` cycle, so the account remains - * reachable for future group invites. - */ - suspend fun resetMarmotState() { - Log.w("MarmotDbg") { "resetMarmotState(): wiping all Marmot state for ${signer.pubKey.take(8)}…" } - marmotManager?.resetAllState() - for (groupId in marmotGroupList.allGroupIds()) { - marmotGroupList.removeGroup(groupId) - } - } - - /** - * Remove a member from a Marmot MLS group. - * Publishes the commit GroupEvent to group relays. - */ - suspend fun removeMarmotGroupMember( - nostrGroupId: HexKey, - targetLeafIndex: Int, - groupRelays: Set, - ) { - Log.d("MarmotDbg") { - "removeMarmotGroupMember: group=${nostrGroupId.take(8)}… targetLeafIndex=$targetLeafIndex " + - "groupRelays=${groupRelays.size}" - } - val manager = - marmotManager ?: run { - Log.w("MarmotDbg") { "removeMarmotGroupMember: marmotManager is NULL — no-op" } - return - } - if (!isWriteable()) { - Log.w("MarmotDbg") { "removeMarmotGroupMember: account is not writeable — no-op" } - return - } - - val outbound = manager.removeMember(nostrGroupId, targetLeafIndex) - Log.d("MarmotDbg") { - "removeMarmotGroupMember: built commit kind=${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}…" - } - val chatroom = marmotGroupList.getOrCreateGroup(nostrGroupId) - manager.syncMetadataTo(nostrGroupId, chatroom) - Log.d("MarmotDbg") { - "removeMarmotGroupMember: publishing commit id=${outbound.signedEvent.id.take(8)}… " + - "to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}" - } - client.publish(outbound.signedEvent, groupRelays) - } - - /** - * Update a Marmot MLS group's metadata (name, description, etc.). - * Publishes the commit GroupEvent to group relays. - */ - suspend fun updateMarmotGroupMetadata( - nostrGroupId: HexKey, - metadata: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData, - groupRelays: Set, - ) { - val manager = marmotManager ?: return - if (!isWriteable()) return - - val outbound = manager.updateGroupMetadata(nostrGroupId, metadata) - // The MLS commit has already been applied locally — surface the new - // metadata in the chatroom now so the UI reflects it without waiting - // for the relay round-trip. - val chatroom = marmotGroupList.getOrCreateGroup(nostrGroupId) - manager.syncMetadataTo(nostrGroupId, chatroom) - client.publish(outbound.signedEvent, groupRelays) - } - - /** - * Grant admin privileges to [targetPubKey] in a Marmot MLS group by - * appending them to `admin_pubkeys` via a GroupContextExtensions commit. - * - * No-op if the group has no prior metadata (shouldn't happen outside the - * first bootstrap commit) or the target is already an admin. Callers - * must be an admin themselves — the MLS engine enforces this via the - * MIP-03 authorization gate in `enforceAuthorizedProposalSet`. - */ - suspend fun grantMarmotGroupAdmin( - nostrGroupId: HexKey, - targetPubKey: HexKey, - groupRelays: Set, - ) { - val manager = marmotManager ?: return - if (!isWriteable()) return - - val metadata = manager.groupMetadata(nostrGroupId) ?: return - if (metadata.adminPubkeys.contains(targetPubKey)) return - - val outboxRelayStrings = outboxRelays.flow.value.map { it.url } - val updated = - metadata - .copy(adminPubkeys = metadata.adminPubkeys + targetPubKey) - .withMergedRelays(outboxRelayStrings) - updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays) - } - - /** - * Revoke admin privileges from [targetPubKey]. Rejects any change that - * would leave the group with zero admins — MIP-03's admin-depletion guard - * in [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup] would otherwise - * throw at commit time. - */ - suspend fun revokeMarmotGroupAdmin( - nostrGroupId: HexKey, - targetPubKey: HexKey, - groupRelays: Set, - ) { - val manager = marmotManager ?: return - if (!isWriteable()) return - - val metadata = manager.groupMetadata(nostrGroupId) ?: return - if (!metadata.adminPubkeys.contains(targetPubKey)) return - val remaining = metadata.adminPubkeys.filter { it != targetPubKey } - check(remaining.isNotEmpty()) { - "Cannot revoke the last admin from a Marmot group (MIP-03)" - } - - val outboxRelayStrings = outboxRelays.flow.value.map { it.url } - val updated = - metadata - .copy(adminPubkeys = remaining) - .withMergedRelays(outboxRelayStrings) - updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays) - } - suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer)) suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) { @@ -4811,7 +4279,7 @@ class Account( // restoreAll() above has already restored any previously // generated bundles. Only generate-and-publish if no active // bundle exists in memory after restore. - ensureMarmotKeyPackagePublished() + marmot.ensureMarmotKeyPackagePublished() // Sync MIP-01 metadata from restored groups to chatrooms and // re-hydrate decrypted messages from persistent storage. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt new file mode 100644 index 0000000000..43029dfbc0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -0,0 +1,580 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.Log +import kotlin.coroutines.cancellation.CancellationException + +/** + * Marmot (MLS encrypted groups) orchestration for an [Account]: group create/ + * leave/reset, member add/remove via key-package fetch, admin grant/revoke, + * metadata updates, group messaging, and key-package publishing. MLS state + * lives in [MarmotManager]; this class wires it to the account's signer, relay + * client, and relay lists. Functions live here (not a ViewModel) so headless + * callers - notification receivers, background workers - can drive them. + */ +class AccountMarmotActions( + private val account: Account, +) { + /** + * Resolve the relay set for a Marmot group. Prefer the relays carried in + * the MLS GroupContext metadata so every member converges on the same + * canonical set; fall back to the account's outbox relays if the group + * has none (e.g. a group joined before MIP-01 metadata existed). + * + * Lives on Account (not AccountViewModel) so that headless callers — + * notifications' BroadcastReceiver, background workers — can resolve + * relays without spinning up a ViewModel. + */ + fun marmotGroupRelays(nostrGroupId: HexKey): Set { + val groupRelays = + account.marmotManager + ?.groupMetadata(nostrGroupId) + ?.relays + ?.mapNotNull { + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull(it) + }?.toSet() + return if (!groupRelays.isNullOrEmpty()) groupRelays else account.outboxRelays.flow.value + } + + /** + * Send a message to a Marmot MLS group. + * Encrypts the inner event and publishes the GroupEvent to group relays. + */ + suspend fun sendMarmotGroupMessage( + nostrGroupId: HexKey, + innerEvent: Event, + groupRelays: Set, + ) { + Log.d("MarmotDbg") { + "sendMarmotGroupMessage: group=${nostrGroupId.take(8)}… innerKind=${innerEvent.kind} innerId=${innerEvent.id.take(8)}… " + + "→ ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}" + } + val manager = account.marmotManager ?: return + if (!account.isWriteable()) return + + val outbound = manager.buildGroupMessage(nostrGroupId, innerEvent) + Log.d("MarmotDbg") { + "sendMarmotGroupMessage: built outer kind:${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}…" + } + // Link the envelope to the inner message we just encrypted so relay + // OK acceptances drill down to the note the chat renders (see + // LocalCache.addRelayToNoteAndInners). + outbound.signedEvent.innerEventId = innerEvent.id + account.cache.justConsumeMyOwnEvent(outbound.signedEvent) + // Sending a message moves the group out of "New Requests" into + // "Known" — do this eagerly before relay round-trip so the UI + // updates immediately. + account.marmotGroupList.markAsKnown(nostrGroupId) + if (groupRelays.isEmpty()) { + Log.w("MarmotDbg") { + "sendMarmotGroupMessage: NO group relays for group=${nostrGroupId.take(8)}… — message will be silently dropped" + } + } + account.client.publish(outbound.signedEvent, groupRelays) + } + + /** + * Fetch a user's KeyPackage from relays and add them to a Marmot group. + * Returns a status message describing the outcome. + */ + @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) + suspend fun fetchKeyPackageAndAddMember( + nostrGroupId: HexKey, + memberPubKey: HexKey, + ): String { + Log.d("MarmotDbg") { + "fetchKeyPackageAndAddMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}…" + } + val manager = account.marmotManager ?: return "Error: Marmot not initialized" + if (!account.isWriteable()) return "Error: Account is read-only" + + // Per MIP-00, invitees advertise the relays that host their + // KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look + // there first, then fall back to the invitee's NIP-65 outbox + // (where KeyPackages typically also land), and finally union + // with our own outbox so we still find packages that ended up + // on a shared relay. + val myOutbox = account.outboxRelays.flow.value + val memberKeyPackageRelays = + ( + account.cache + .getAddressableNoteIfExists( + com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent + .createAddress(memberPubKey), + )?.event as? com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent + )?.relays()?.toSet().orEmpty() + val memberOutbox = + account.cache + .getOrCreateUser(memberPubKey) + .outboxRelays() + ?.toSet() + .orEmpty() + val fetchRelays = + com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher + .fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox) + + Log.d("MarmotDbg") { + "fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " + + "(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}" + } + + val event = + com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher + .fetchKeyPackage(account.client, memberPubKey, fetchRelays) + + if (event == null) { + Log.w("MarmotDbg") { + "fetchKeyPackageAndAddMember: NO KeyPackage found for ${memberPubKey.take(8)}… on any of ${fetchRelays.size} relay(s)" + } + return "Error: No KeyPackage found for this user. They may not have published one yet." + } + + Log.d("MarmotDbg") { + "fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}…" + } + + val keyPackageBase64 = event.keyPackageBase64() + if (keyPackageBase64.isBlank()) { + Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" } + return "Error: KeyPackage event has empty content" + } + + // The relays embedded in the WelcomeEvent tell the new member + // where to subscribe for subsequent GroupEvents. Use our own + // outbox — that's where we will publish them. + val groupRelays = myOutbox.toList() + + Log.d("MarmotDbg") { + "fetchKeyPackageAndAddMember: addMarmotGroupMember → groupRelays=${groupRelays.size}: ${groupRelays.map { it.url }}" + } + + addMarmotGroupMember( + nostrGroupId = nostrGroupId, + keyPackageEvent = event, + groupRelays = groupRelays, + ) + + return "Success: Member added to group" + } + + /** + * Add a member to a Marmot MLS group. + * Publishes the commit GroupEvent, then sends the Welcome gift wrap. + */ + suspend fun addMarmotGroupMember( + nostrGroupId: HexKey, + keyPackageEvent: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent, + groupRelays: List, + ) { + val memberPubKey = keyPackageEvent.pubKey + Log.d("MarmotDbg") { + "addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}… " + + "groupRelays=${groupRelays.size}" + } + val manager = account.marmotManager ?: return + if (!account.isWriteable()) return + + val (commitEvent, welcomeDelivery) = + manager.addMember( + nostrGroupId = nostrGroupId, + keyPackageEvent = keyPackageEvent, + relays = groupRelays, + ) + + // The MLS commit has already been applied to the local group state — + // surface the new member list in the chatroom now so observers (e.g. + // MarmotGroupInfoScreen) update without waiting for our own commit to + // loop back through the relay. + val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + manager.syncMetadataTo(nostrGroupId, chatroom) + + Log.d("MarmotDbg") { + "addMarmotGroupMember: built commit kind=${commitEvent.signedEvent.kind} id=${commitEvent.signedEvent.id.take(8)}… " + + "welcomeDelivery=${if (welcomeDelivery != null) "present(giftWrapId=${welcomeDelivery.giftWrapEvent.id.take(8)}…)" else "null"}" + } + + // Publish commit first (critical ordering) + Log.d("MarmotDbg") { + "addMarmotGroupMember: publishing commit kind:${commitEvent.signedEvent.kind} to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}" + } + account.client.publish(commitEvent.signedEvent, groupRelays.toSet()) + + // Then send the Welcome gift wrap to the new member. + // + // Use the same delivery path that NIP-17 DMs (kind:1059) take — + // computeRelayListToBroadcast() — which has fallbacks for kind:10050 + // → NIP-65 read → relay hints. Empirically, NIP-17 DMs reach the + // invitee, so this path is the one we know works. We also union + // with our own outbox + the recipient's dmInboxRelays() as a + // belt-and-braces measure in case the cache hasn't been hydrated + // yet for this contact. + if (welcomeDelivery != null) { + val computed = account.broadcaster.computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent) + val recipientInbox = + account.cache + .getOrCreateUser(memberPubKey) + .dmInboxRelays() + .orEmpty() + val relayList = computed + account.outboxRelays.flow.value + recipientInbox + Log.d("MarmotDbg") { + "addMarmotGroupMember: welcome gift wrap relay sources " + + "computeRelayListToBroadcast=${computed.size} myOutbox=${account.outboxRelays.flow.value.size} " + + "recipientInbox=${recipientInbox.size} → union=${relayList.size}" + } + if (relayList.isEmpty()) { + Log.w("MarmotDbg") { + "addMarmotGroupMember: NO relays to deliver welcome gift wrap to ${memberPubKey.take(8)}… — welcome will be silently dropped" + } + } else { + Log.d("MarmotDbg") { + "addMarmotGroupMember: publishing welcome gift wrap id=${welcomeDelivery.giftWrapEvent.id.take(8)}… " + + "kind:${welcomeDelivery.giftWrapEvent.kind} → ${relayList.size} relay(s): ${relayList.map { it.url }}" + } + } + account.client.publish(welcomeDelivery.giftWrapEvent, relayList) + } else { + Log.w("MarmotDbg") { + "addMarmotGroupMember: welcomeDelivery is NULL — invitee ${memberPubKey.take(8)}… will receive nothing!" + } + } + } + + /** + * Relays where this account publishes kind:30443 KeyPackage events. + * Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox. + */ + fun keyPackagePublishRelays(): Set = + com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher + .publishRelaysFor(account.keyPackageRelayList.flow.value, account.outboxRelays.flow.value) + + /** + * Publish or rotate KeyPackage events. + */ + suspend fun publishMarmotKeyPackages() { + val manager = + account.marmotManager ?: run { + Log.w("MarmotDbg") { "publishMarmotKeyPackages: account.marmotManager is NULL — no-op" } + return + } + if (!account.isWriteable()) { + Log.w("MarmotDbg") { "publishMarmotKeyPackages: account is not writeable — no-op" } + return + } + + val relays = keyPackagePublishRelays() + val needsRotation = manager.needsKeyPackageRotation() + Log.d("MarmotDbg") { + "publishMarmotKeyPackages: needsRotation=$needsRotation relays=${relays.size}" + } + + if (needsRotation) { + val rotatedEvents = manager.rotateConsumedKeyPackages(relays.toList()) + Log.d("MarmotDbg") { + "publishMarmotKeyPackages: rotateConsumedKeyPackages produced ${rotatedEvents.size} event(s)" + } + rotatedEvents.forEach { event -> + account.cache.justConsumeMyOwnEvent(event) + Log.d("MarmotDbg") { + "publishMarmotKeyPackages: publishing rotated kind:${event.kind} id=${event.id.take(8)}… " + + "→ ${relays.size} relay(s): ${relays.map { it.url }}" + } + account.client.publish(event, relays) + } + } + } + + /** + * Generate and publish initial KeyPackage for this account. + */ + suspend fun publishMarmotKeyPackage() { + val manager = account.marmotManager ?: return + if (!account.isWriteable()) return + + val relays = keyPackagePublishRelays() + Log.d("MarmotDbg") { + "publishMarmotKeyPackage: generating + publishing KeyPackage event → ${relays.size} relay(s): ${relays.map { it.url }}" + } + val event = manager.generateKeyPackageEvent(relays.toList()) + Log.d("MarmotDbg") { + "publishMarmotKeyPackage: signed kind:${event.kind} id=${event.id.take(8)}… authored=${event.pubKey.take(8)}…" + } + account.cache.justConsumeMyOwnEvent(event) + account.client.publish(event, relays) + } + + /** + * Ensure the local user has at least one active KeyPackage bundle and + * a published KeyPackage event on relays. Called from [init] after + * Marmot state has been restored from disk. + * + * - If [KeyPackageRotationManager] already has an active bundle (from + * the persisted snapshot), we trust the previous session and do + * nothing. The matching kind:30443 should already be on relays from + * when the bundle was first generated. + * - Otherwise we generate a fresh bundle (which is now persisted to + * disk by [KeyPackageRotationManager.generateKeyPackage]) and + * publish the corresponding event. + * + * Best-effort: failures are logged but never propagated. We don't want + * a flaky relay or missing outbox config at startup to crash account + * initialization. + */ + internal suspend fun ensureMarmotKeyPackagePublished() { + val manager = account.marmotManager ?: return + if (!account.isWriteable()) return + try { + val hasBundle = manager.hasActiveKeyPackages() + Log.d("MarmotDbg") { + "ensureMarmotKeyPackagePublished: hasActiveKeyPackages=$hasBundle for ${account.signer.pubKey.take(8)}…" + } + if (hasBundle) { + return + } + Log.d("MarmotDbg") { + "ensureMarmotKeyPackagePublished: no active bundle — generating + publishing now" + } + publishMarmotKeyPackage() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("MarmotDbg", "ensureMarmotKeyPackagePublished failed: ${e.message}", e) + } + } + + /** + * Check if a KeyPackage has been published in this session. + * The d-tag is a randomly-generated value stored in the KeyPackageRotationManager's + * persisted snapshot, so there is no fixed address to query in the cache. + */ + suspend fun hasPublishedKeyPackage(): Boolean { + val manager = account.marmotManager ?: return false + return manager.hasActiveKeyPackages() + } + + /** + * Create a new Marmot MLS group. + */ + suspend fun createMarmotGroup(nostrGroupId: HexKey) { + val manager = account.marmotManager ?: return + if (!account.isWriteable()) return + manager.createGroup(nostrGroupId) + // Creator owns the group — mark it as "known" immediately so it + // doesn't appear under "New Requests" before the first message. + account.marmotGroupList.markAsKnown(nostrGroupId) + } + + /** + * Leave a Marmot MLS group. + * Publishes the SelfRemove proposal and removes local state. + * + * MIP-01/MIP-03: admins MUST first publish a GroupContextExtensions + * commit dropping themselves from `admin_pubkeys` before issuing a + * SelfRemove proposal. Without that, [MlsGroup.selfRemove] throws + * `IllegalStateException("Admin must self-demote via GroupContextExtensions + * before SelfRemove (MIP-01)")` and the leave aborts. Demote commit and + * SelfRemove proposal both go to the same group relays, demote first so + * peers apply it before they see the SelfRemove. + */ + suspend fun leaveMarmotGroup( + nostrGroupId: HexKey, + groupRelays: Set, + ) { + val manager = account.marmotManager ?: return + if (!account.isWriteable()) return + + val metadata = manager.groupMetadata(nostrGroupId) + if (metadata != null && metadata.adminPubkeys.contains(account.signer.pubKey)) { + val remaining = metadata.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList() + // MIP-03 also rejects any GCE commit that leaves the group with zero + // admins. If we're the only one, promote an arbitrary non-self + // member to admin before stepping down. + if (remaining.isEmpty()) { + val heir = + manager + .memberPubkeys(nostrGroupId) + .map { it.pubkey } + .firstOrNull { it != account.signer.pubKey } + if (heir != null) remaining.add(heir) + } + if (remaining.isNotEmpty()) { + val demoted = metadata.copy(adminPubkeys = remaining) + val demoteCommit = manager.updateGroupMetadata(nostrGroupId, demoted) + account.client.publish(demoteCommit.signedEvent, groupRelays) + } + } + + val outbound = manager.leaveGroup(nostrGroupId) + // manager.leaveGroup already wiped MLS state, relay subscriptions and + // the persisted message log. Drop the in-memory chatroom too — that + // releases the strong refs to the decrypted inner notes so LocalCache + // (which holds them weakly) can GC them, and the Notification feed + // (which iterates account.marmotGroupList.rooms) stops surfacing the group. + account.marmotGroupList.removeGroup(nostrGroupId) + account.client.publish(outbound.signedEvent, groupRelays) + } + + /** + * User-initiated "nuclear" reset for the Marmot subsystem. + * + * Wipes every MLS group, every retained epoch secret, every persisted + * KeyPackage bundle, every relay subscription and every in-memory + * chatroom associated with this account. Does NOT broadcast any + * SelfRemove/leave commits to peers — if the user is in this flow at + * all, local state may already be unusable and a graceful leave is + * probably not possible. Peers will see the user as unresponsive until + * their next commit evicts the stale leaf. + * + * A fresh KeyPackage will be republished lazily on the next + * `ensureMarmotKeyPackagePublished` cycle, so the account remains + * reachable for future group invites. + */ + suspend fun resetMarmotState() { + Log.w("MarmotDbg") { "resetMarmotState(): wiping all Marmot state for ${account.signer.pubKey.take(8)}…" } + account.marmotManager?.resetAllState() + for (groupId in account.marmotGroupList.allGroupIds()) { + account.marmotGroupList.removeGroup(groupId) + } + } + + /** + * Remove a member from a Marmot MLS group. + * Publishes the commit GroupEvent to group relays. + */ + suspend fun removeMarmotGroupMember( + nostrGroupId: HexKey, + targetLeafIndex: Int, + groupRelays: Set, + ) { + Log.d("MarmotDbg") { + "removeMarmotGroupMember: group=${nostrGroupId.take(8)}… targetLeafIndex=$targetLeafIndex " + + "groupRelays=${groupRelays.size}" + } + val manager = + account.marmotManager ?: run { + Log.w("MarmotDbg") { "removeMarmotGroupMember: account.marmotManager is NULL — no-op" } + return + } + if (!account.isWriteable()) { + Log.w("MarmotDbg") { "removeMarmotGroupMember: account is not writeable — no-op" } + return + } + + val outbound = manager.removeMember(nostrGroupId, targetLeafIndex) + Log.d("MarmotDbg") { + "removeMarmotGroupMember: built commit kind=${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}…" + } + val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + manager.syncMetadataTo(nostrGroupId, chatroom) + Log.d("MarmotDbg") { + "removeMarmotGroupMember: publishing commit id=${outbound.signedEvent.id.take(8)}… " + + "to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}" + } + account.client.publish(outbound.signedEvent, groupRelays) + } + + /** + * Update a Marmot MLS group's metadata (name, description, etc.). + * Publishes the commit GroupEvent to group relays. + */ + suspend fun updateMarmotGroupMetadata( + nostrGroupId: HexKey, + metadata: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData, + groupRelays: Set, + ) { + val manager = account.marmotManager ?: return + if (!account.isWriteable()) return + + val outbound = manager.updateGroupMetadata(nostrGroupId, metadata) + // The MLS commit has already been applied locally — surface the new + // metadata in the chatroom now so the UI reflects it without waiting + // for the relay round-trip. + val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + manager.syncMetadataTo(nostrGroupId, chatroom) + account.client.publish(outbound.signedEvent, groupRelays) + } + + /** + * Grant admin privileges to [targetPubKey] in a Marmot MLS group by + * appending them to `admin_pubkeys` via a GroupContextExtensions commit. + * + * No-op if the group has no prior metadata (shouldn't happen outside the + * first bootstrap commit) or the target is already an admin. Callers + * must be an admin themselves — the MLS engine enforces this via the + * MIP-03 authorization gate in `enforceAuthorizedProposalSet`. + */ + suspend fun grantMarmotGroupAdmin( + nostrGroupId: HexKey, + targetPubKey: HexKey, + groupRelays: Set, + ) { + val manager = account.marmotManager ?: return + if (!account.isWriteable()) return + + val metadata = manager.groupMetadata(nostrGroupId) ?: return + if (metadata.adminPubkeys.contains(targetPubKey)) return + + val outboxRelayStrings = + account.outboxRelays.flow.value + .map { it.url } + val updated = + metadata + .copy(adminPubkeys = metadata.adminPubkeys + targetPubKey) + .withMergedRelays(outboxRelayStrings) + updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays) + } + + /** + * Revoke admin privileges from [targetPubKey]. Rejects any change that + * would leave the group with zero admins — MIP-03's admin-depletion guard + * in [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup] would otherwise + * throw at commit time. + */ + suspend fun revokeMarmotGroupAdmin( + nostrGroupId: HexKey, + targetPubKey: HexKey, + groupRelays: Set, + ) { + val manager = account.marmotManager ?: return + if (!account.isWriteable()) return + + val metadata = manager.groupMetadata(nostrGroupId) ?: return + if (!metadata.adminPubkeys.contains(targetPubKey)) return + val remaining = metadata.adminPubkeys.filter { it != targetPubKey } + check(remaining.isNotEmpty()) { + "Cannot revoke the last admin from a Marmot group (MIP-03)" + } + + val outboxRelayStrings = + account.outboxRelays.flow.value + .map { it.url } + val updated = + metadata + .copy(adminPubkeys = remaining) + .withMergedRelays(outboxRelayStrings) + updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index ecfd14b6ad..ddb16c72cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -189,7 +189,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { persistOwn = false, ) - account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmotGroupRelays(nostrGroupId)) + account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmot.marmotGroupRelays(nostrGroupId)) } private suspend fun sendPublicReply( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 70079e66bb..7a9981040a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2330,8 +2330,8 @@ class AccountViewModel( mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(), ) ?: return - val relays = account.marmotGroupRelays(nostrGroupId) - account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays) } suspend fun sendMarmotGroupMediaMessage( @@ -2356,21 +2356,21 @@ class AccountViewModel( account.signer.pubKey, template, ) - val relays = account.marmotGroupRelays(nostrGroupId) - account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays) } fun marmotMediaExporterSecret(nostrGroupId: String): ByteArray? = account.marmotManager?.mediaExporterSecret(nostrGroupId) suspend fun createMarmotGroup(nostrGroupId: String) { - account.createMarmotGroup(nostrGroupId) + account.marmot.createMarmotGroup(nostrGroupId) } suspend fun publishMarmotKeyPackage() { - account.publishMarmotKeyPackage() + account.marmot.publishMarmotKeyPackage() } - suspend fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage() + suspend fun hasPublishedKeyPackage(): Boolean = account.marmot.hasPublishedKeyPackage() /** * Whether this account has a kind:10051 KeyPackage Relay List (MIP-00) @@ -2394,12 +2394,12 @@ class AccountViewModel( } suspend fun leaveMarmotGroup(nostrGroupId: String) { - val relays = account.marmotGroupRelays(nostrGroupId) - account.leaveMarmotGroup(nostrGroupId, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.leaveMarmotGroup(nostrGroupId, relays) } suspend fun resetMarmotState() { - account.resetMarmotState() + account.marmot.resetMarmotState() } fun marmotGroupMembers(nostrGroupId: String): List = account.marmotManager?.memberPubkeys(nostrGroupId) ?: emptyList() @@ -2407,30 +2407,30 @@ class AccountViewModel( suspend fun addMarmotGroupMember( nostrGroupId: String, memberPubKey: String, - ): String = account.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey) + ): String = account.marmot.fetchKeyPackageAndAddMember(nostrGroupId, memberPubKey) suspend fun removeMarmotGroupMember( nostrGroupId: String, targetLeafIndex: Int, ) { - val relays = account.marmotGroupRelays(nostrGroupId) - account.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays) } suspend fun grantMarmotGroupAdmin( nostrGroupId: String, targetPubKey: String, ) { - val relays = account.marmotGroupRelays(nostrGroupId) - account.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays) } suspend fun revokeMarmotGroupAdmin( nostrGroupId: String, targetPubKey: String, ) { - val relays = account.marmotGroupRelays(nostrGroupId) - account.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays) } /** @@ -2486,8 +2486,8 @@ class AccountViewModel( imageUploadKey = icon.upload.imageUploadKey, ) } - val relays = account.marmotGroupRelays(nostrGroupId) - account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays) + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + account.marmot.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays) } override fun onCleared() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index d897298812..fc7a33d5ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -415,7 +415,7 @@ private suspend fun processMarmotWelcomeFlow( // Rotate KeyPackages if needed if (result.needsKeyPackageRotation) { - account.publishMarmotKeyPackages() + account.marmot.publishMarmotKeyPackages() } // Fire the "You've been added to " notification. Welcomes From d2c9593919be2e50fd2239bb9db63f57833cd99c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:11:04 +0000 Subject: [PATCH 068/227] refactor: extract AccountRelayGroupActions from Account Moves the ~460-line NIP-29 relay-group + Buzz workspace orchestration (join/leave/create/delete/archive, threads, invites, pins, member/role management, metadata edits, Buzz DMs/jobs/workflows/typing, community member add/remove) into AccountRelayGroupActions, exposed as account.relayGroups. External callers now use account.relayGroups.* directly. Moved code is unchanged except for account. qualification. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../vitorpamplona/amethyst/model/Account.kt | 506 +--------------- .../model/AccountRelayGroupActions.kt | 549 ++++++++++++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 36 +- .../loggedIn/buzz/AgentWorkBoardViewModel.kt | 12 +- .../screen/loggedIn/buzz/BuzzDmListScreen.kt | 2 +- .../loggedIn/buzz/BuzzDmListViewModel.kt | 4 +- .../loggedIn/buzz/BuzzNewDmViewModel.kt | 2 +- .../screen/loggedIn/buzz/JobBoardViewModel.kt | 6 +- .../buzz/WorkflowRunBoardViewModel.kt | 8 +- .../relayGroup/RelayGroupMetadataViewModel.kt | 4 +- .../send/ChannelNewMessageViewModel.kt | 2 +- 11 files changed, 590 insertions(+), 541 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b86ecdf03d..d9c5c66860 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermi import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect -import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager @@ -47,10 +46,8 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache @@ -160,26 +157,9 @@ import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor -import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent -import com.vitorpamplona.quartz.buzz.dm.DmHideEvent -import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent -import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent -import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent -import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent -import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent -import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent import com.vitorpamplona.quartz.buzz.threading.buzzThread import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot -import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent -import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent -import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent -import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent -import com.vitorpamplona.quartz.buzz.workflow.workflowChannel -import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN -import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER -import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN -import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent @@ -214,10 +194,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -268,20 +245,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.notify import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.hTag -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous -import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent -import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache @@ -332,7 +297,6 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag -import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag @@ -378,8 +342,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import java.math.BigDecimal import kotlin.coroutines.cancellation.CancellationException import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash @@ -761,6 +723,9 @@ class Account( /** Marmot/MLS group orchestration (create/members/admins/messages/key packages). */ val marmot = AccountMarmotActions(this) + /** NIP-29 relay-group + Buzz workspace orchestration. */ + val relayGroups = AccountRelayGroupActions(this) + /** * Relay routing + sign-and-publish choke point: computes which relays an event * should go to (outbox model, hints, channels, broadcast lists) and owns every @@ -2081,471 +2046,6 @@ class Account( return (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") } - suspend fun joinRelayGroup( - channel: RelayGroupChannel, - code: String? = null, - ) { - val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - follow(channel) - } - - /** - * Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral - * (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter - * our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS]. - */ - suspend fun sendBuzzTyping(channel: RelayGroupChannel) { - if (!isWriteable()) return - val signed = signer.sign(TypingIndicatorEvent.build(channel.groupId.id)) - client.publish(signed, setOf(channel.groupId.relayUrl)) - } - - /** - * Open (or re-surface) a Buzz DM with [participants] on [relay] via a kind-41010 - * command. [participants] are the OTHER 1-8 people — the relay adds me, derives the - * canonical channel UUID, and confirms with a relay-signed [DmCreatedEvent] - * (kind-41001) that lands in [com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry]. - * We never assign the channel id ourselves, so callers discover the materialized DM - * by watching that registry rather than from this call's return. - */ - suspend fun openBuzzDm( - relay: NormalizedRelayUrl, - participants: List, - ): String? { - val signed = signer.sign(DmOpenEvent.build(participants)) - // The relay confirms the DM synchronously in the OK as `response:{"channel_id":"…"}` — - // the authoritative, relay-assigned channel UUID (the deployed relay does not emit a - // queryable kind-41001). Read it straight from the ack so the caller can open the chat. - var results = client.publishAndCollectResults(signed, setOf(relay)) - var channelId = buzzDmChannelIdFromAck(results) - - // NIP-42 write race: on a cold connection the relay rejects the first publish with - // `auth-required` (our AUTH reply lands async and the write path doesn't re-send). Warm - // the connection with a pendingOnAuthRequired read so the auth coordinator completes the - // handshake, then retry the publish on the now-authed socket. Mirrors the amy CLI fix. - if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) { - client.fetchAllWithHooks( - filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))), - timeoutMs = 8_000, - pendingOnAuthRequired = true, - ) { _, _ -> false } - results = client.publishAndCollectResults(signed, setOf(relay)) - channelId = buzzDmChannelIdFromAck(results) - } - return channelId - } - - /** The relay-assigned DM channel id from a DM-open OK message (`response:{"channel_id":"…"}`). */ - private fun buzzDmChannelIdFromAck(results: Map): String? = - results.values - .firstOrNull { it.accepted } - ?.message - ?.substringAfter("\"channel_id\":\"", "") - ?.substringBefore('"') - ?.takeIf { it.isNotBlank() } - - /** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */ - suspend fun hideBuzzDm(channel: RelayGroupChannel) { - val template = DmHideEvent.build(channel.groupId.id) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** Add [member] to an existing group DM with a kind-41011 command (creates a new DM set). */ - suspend fun addBuzzDmMember( - channel: RelayGroupChannel, - member: HexKey, - ) { - val template = DmAddMemberEvent.build(channel.groupId.id, member) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** - * File a Buzz agent job (kind-43001) into channel [channelId] on [relay] — a shared - * feature-request the workspace bot can pick up. Untargeted: any agent watching the - * channel may accept it. Returns the new job id (the request event id), or null when the - * account can't write. See [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator]. - */ - suspend fun fileBuzzJob( - relay: NormalizedRelayUrl, - channelId: String, - request: String, - ): HexKey? { - if (!isWriteable()) return null - val signed = signer.sign(JobRequestEvent.build(request, channelId, null)) - // Reflect it locally so the board updates immediately (publish only sends to relays). - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - return signed.id - } - - /** Cancel a Buzz job [jobId] with a kind-43005 scoped to [channelId] on [relay]. */ - suspend fun cancelBuzzJob( - relay: NormalizedRelayUrl, - channelId: String, - jobId: HexKey, - ) { - if (!isWriteable()) return - val signed = signer.sign(JobCancelEvent.build(jobId, "", channelId)) - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - } - - /** - * Trigger a Buzz **workflow** run (kind-46020) for [workflowId] into channel [channelId] on - * [relay], carrying [task] as the run's request. The trigger's event id IS the run id (and the - * approval token), returned here. A run pauses on a human-approval gate before anything ships — - * see [com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator]. - */ - suspend fun triggerBuzzWorkflow( - relay: NormalizedRelayUrl, - channelId: String, - workflowId: String, - task: String, - ): HexKey? { - if (!isWriteable()) return null - val content = Json.encodeToString(WorkflowRunPayload(task = task, workflow = workflowId)) - val signed = signer.sign(WorkflowTriggerEvent.build(workflowId, content) { workflowChannel(channelId) }) - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - return signed.id - } - - /** - * Publish a Buzz **workflow definition** (kind-30620) into channel [channelId] on [relay]: an - * addressable event whose `d` tag is a freshly-minted workflow UUID (returned here), carrying a - * human-readable [name] and the workflow's [yaml] recipe. On a real Buzz relay the relay parses - * the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker - * offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write. - */ - suspend fun publishBuzzWorkflowDef( - relay: NormalizedRelayUrl, - channelId: String, - name: String, - yaml: String, - ): String? { - if (!isWriteable()) return null - val workflowId = RandomInstance.randomChars(16) - val signed = signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null })) - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - return workflowId - } - - /** - * Grant a paused Buzz workflow run's approval gate (kind-46030). [runId] is the run id, which - * doubles as the approval token (the grant's `d` tag). Resuming lets the runner ship the work. - * Publishing to the single group [relay]; the runner discovers the decision by author. - */ - suspend fun approveBuzzWorkflowRun( - relay: NormalizedRelayUrl, - runId: HexKey, - note: String = "", - ): HexKey? { - if (!isWriteable()) return null - val signed = signer.sign(ApprovalGrantEvent.build(runId, note)) - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - return signed.id - } - - /** Deny a paused Buzz workflow run's approval gate (kind-46031); the run is terminal (DENIED). */ - suspend fun denyBuzzWorkflowRun( - relay: NormalizedRelayUrl, - runId: HexKey, - note: String = "", - ): HexKey? { - if (!isWriteable()) return null - val signed = signer.sign(ApprovalDenyEvent.build(runId, note)) - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - return signed.id - } - - /** - * Upvote a Buzz job [jobId] (authored by [jobAuthor]) — a NIP-25 like (kind-7 `+`) `e`-tagging - * the request, `p`-tagging its author and `k`-tagging the reacted kind per NIP-25, and - * `h`-scoped to [channelId] so the scheduler (and the board) count it toward priority. - */ - suspend fun upvoteBuzzJob( - relay: NormalizedRelayUrl, - channelId: String, - jobId: HexKey, - jobAuthor: HexKey?, - ) { - if (!isWriteable()) return - val template = - eventTemplate(ReactionEvent.KIND, ReactionEvent.LIKE) { - addUnique(ETag.assemble(jobId, null, null)) - jobAuthor?.let { addUnique(PTag.assemble(it, null)) } - addUnique(arrayOf("k", JobRequestEvent.KIND.toString())) - addUnique(GroupIdTag.assemble(channelId)) - } - val signed = signer.sign(template) - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - } - - /** Send a kind 9022 leave request to the host relay and drop it from our list. */ - suspend fun leaveRelayGroup(channel: RelayGroupChannel) { - val template = LeaveRequestEvent.build(channel.groupId.id) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - unfollow(channel) - } - - /** - * Delete the whole group with a kind 9008 delete-group event (owner/admin only — the relay - * enforces this). Unlike [leaveRelayGroup], this destroys the channel for everyone rather than - * just removing me; the relay drops the group and its messages. Also drops it from our own list - * so it disappears from Messages immediately instead of lingering as a now-dead id. - */ - suspend fun deleteRelayGroup(channel: RelayGroupChannel) { - val template = DeleteGroupEvent.build(channel.groupId.id) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - unfollow(channel) - // Remember the deletion so the channel leaves the community's browse list immediately and - // stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a - // stale re-announced 44100 on a Buzz relay) would otherwise keep it visible. - RelayGroupDeletions.markDeleted(channel.groupId) - } - - /** - * Create a new group on [relay]: kind 9007 (create-group) then kind 9002 - * (edit-metadata) with the chosen name/visibility, then remember it. Returns - * the new group's id. - */ - suspend fun createRelayGroup( - relay: NormalizedRelayUrl, - groupId: String, - name: String, - about: String? = null, - picture: String? = null, - isPrivate: Boolean = false, - isClosed: Boolean = false, - isHidden: Boolean = false, - isRestricted: Boolean = false, - hashtags: List = emptyList(), - geohashes: List = emptyList(), - parent: String? = null, - channelType: String? = null, - ): GroupId { - // The metadata rides the create event as well as the 9002 below. A plain NIP-29 relay takes - // its metadata from the 9002 and ignores these tags; Buzz rejects the 9007 outright without - // a `name` (see CreateGroupEvent.build), which used to make "create group" on a Buzz relay - // publish two events and produce nothing at all. - signAndSendPrivatelyOrBroadcast( - CreateGroupEvent.build( - groupId = groupId, - name = name, - about = about, - visibility = if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN, - channelType = channelType, - ), - ) { listOf(relay) } - - val edit = - EditMetadataEvent.build( - groupId, - name = name, - about = about, - picture = picture, - status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), - hashtags = hashtags, - geohashes = geohashes, - parent = parent, - ) - signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } - - val id = GroupId(groupId, relay) - follow(LocalCache.getOrCreateRelayGroupChannel(id)) - return id - } - - /** - * The set of NIP-29 status flags to emit on a kind-9002 metadata event. Flags are - * presence-only — public/open/visible/unrestricted are simply the ABSENCE of their - * restrictive counterpart — so only the enabled restrictive flags are added. - */ - private fun relayGroupStatus( - isPrivate: Boolean, - isClosed: Boolean, - isHidden: Boolean, - isRestricted: Boolean, - ): Set = - buildSet { - if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE) - if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED) - if (isHidden) add(GroupMetadataEvent.GroupStatus.HIDDEN) - if (isRestricted) add(GroupMetadataEvent.GroupStatus.RESTRICTED) - } - - /** Post a kind 11 thread (forum-style) to the group, scoped by its `h` tag. */ - suspend fun postRelayGroupThread( - channel: RelayGroupChannel, - title: String, - body: String, - ) { - val template = - ThreadEvent.build(body, title) { - hTag(channel.groupId.id) - previous(channel.previousEventRefs(pubKey)) - } - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** Mint a kind 9009 invite code for the group (admin/moderator only). */ - suspend fun createRelayGroupInvite( - channel: RelayGroupChannel, - code: String, - ) { - val template = CreateInviteEvent.build(channel.groupId.id, code) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** - * Replace the group's pinned-message list with a kind 9010 update-pin-list event - * (admin/moderator only). NIP-29 carries the FULL list, so the relay applies it and - * republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent]. - */ - suspend fun updateRelayGroupPins( - channel: RelayGroupChannel, - pinnedEventIds: List, - ) { - val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** Pin [eventId] by appending it to the current list (no-op if already pinned). */ - suspend fun pinRelayGroupMessage( - channel: RelayGroupChannel, - eventId: HexKey, - ) { - if (channel.isPinned(eventId)) return - updateRelayGroupPins(channel, channel.pinnedEventIds + eventId) - } - - /** Unpin [eventId] by removing it from the current list (no-op if not pinned). */ - suspend fun unpinRelayGroupMessage( - channel: RelayGroupChannel, - eventId: HexKey, - ) { - if (!channel.isPinned(eventId)) return - updateRelayGroupPins(channel, channel.pinnedEventIds - eventId) - } - - /** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */ - suspend fun removeRelayGroupUser( - channel: RelayGroupChannel, - pubkey: HexKey, - ) { - val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey)) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** - * Add [pubkey] to the group (or change its roles) with a kind 9000 put-user - * event (moderator only). Pass an empty [roles] list for a plain member. - */ - suspend fun putRelayGroupUser( - channel: RelayGroupChannel, - pubkey: HexKey, - roles: List, - ) { - // Buzz ignores the roles inside the `p` tag and reads a top-level `role` tag instead, in its - // own vocabulary — so map ours onto its set before sending. Anything it cannot parse fails - // the whole put-user, which is why an unmapped role must become `member` rather than travel. - val buzzRole = - if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) { - when { - roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN - else -> BUZZ_ROLE_MEMBER - } - } else { - null - } - val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** - * Add [pubkey] to a Buzz **community** (the whole relay/tenant, not one channel) via the - * relay-admin add-member command (kind 9030). Owner/admin only — the relay validates the - * sender's role and, on a new insert, updates its NIP-43 membership list (13534). Published to - * [relay] with no channel scope. - */ - suspend fun addCommunityMember( - relay: NormalizedRelayUrl, - pubkey: HexKey, - role: String? = null, - ) { - signAndSendPrivatelyOrBroadcast(RelayAdminAddMemberEvent.build(pubkey, role)) { listOf(relay) } - } - - /** Remove [pubkey] from a Buzz community via the relay-admin remove-member command (kind 9031). */ - suspend fun removeCommunityMember( - relay: NormalizedRelayUrl, - pubkey: HexKey, - ) { - signAndSendPrivatelyOrBroadcast(RelayAdminRemoveMemberEvent.build(pubkey)) { listOf(relay) } - } - - /** - * Edit the group's relay-signed metadata with a kind 9002 event (admin only). - * - * NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy - * links: a 9002 with no `parent` tag re-roots the group, and one that drops any - * existing `child` is rejected by the relay. So unless the caller is explicitly - * re-parenting, we re-carry the group's current [parent] and full [children] list - * from its latest known metadata to keep the tree intact across a plain name/flag - * edit. Pass an explicit value to change them. - */ - suspend fun editRelayGroupMetadata( - channel: RelayGroupChannel, - name: String?, - about: String?, - picture: String?, - isPrivate: Boolean, - isClosed: Boolean, - isHidden: Boolean, - isRestricted: Boolean, - hashtags: List = emptyList(), - geohashes: List = emptyList(), - parent: String? = channel.parentGroupId(), - children: List = channel.childGroupIds(), - ) { - // On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT - // read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on - // edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag. - val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) - val template = - EditMetadataEvent.build( - channel.groupId.id, - name = name, - about = about, - picture = picture, - status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), - hashtags = hashtags, - geohashes = geohashes, - parent = parent, - children = children, - visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null, - ) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** - * Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The - * relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its - * history — the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces. - */ - suspend fun archiveRelayGroup( - channel: RelayGroupChannel, - archived: Boolean, - ) { - val template = EditMetadataEvent.build(channel.groupId.id, archived = archived) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community)) suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt new file mode 100644 index 0000000000..7816501a0b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt @@ -0,0 +1,549 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership +import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent +import com.vitorpamplona.quartz.buzz.dm.DmHideEvent +import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent +import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent +import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent +import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent +import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent +import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent +import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent +import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent +import com.vitorpamplona.quartz.buzz.workflow.workflowChannel +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.hTag +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous +import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * NIP-29 relay-group and Buzz-workspace orchestration for an [Account]: + * join/leave/create/delete/archive groups, threads, invites, pins, member and + * role management, metadata edits, plus the Buzz dialect's DMs, jobs, + * workflows, and typing signals. Event building lives in quartz builders; + * this class wires them to the account's signer and the group's host relay. + */ +class AccountRelayGroupActions( + private val account: Account, +) { + suspend fun joinRelayGroup( + channel: RelayGroupChannel, + code: String? = null, + ) { + val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + account.follow(channel) + } + + /** + * Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral + * (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter + * our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS]. + */ + suspend fun sendBuzzTyping(channel: RelayGroupChannel) { + if (!account.isWriteable()) return + val signed = account.signer.sign(TypingIndicatorEvent.build(channel.groupId.id)) + account.client.publish(signed, setOf(channel.groupId.relayUrl)) + } + + /** + * Open (or re-surface) a Buzz DM with [participants] on [relay] via a kind-41010 + * command. [participants] are the OTHER 1-8 people — the relay adds me, derives the + * canonical channel UUID, and confirms with a relay-signed [DmCreatedEvent] + * (kind-41001) that lands in [com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry]. + * We never assign the channel id ourselves, so callers discover the materialized DM + * by watching that registry rather than from this call's return. + */ + suspend fun openBuzzDm( + relay: NormalizedRelayUrl, + participants: List, + ): String? { + val signed = account.signer.sign(DmOpenEvent.build(participants)) + // The relay confirms the DM synchronously in the OK as `response:{"channel_id":"…"}` — + // the authoritative, relay-assigned channel UUID (the deployed relay does not emit a + // queryable kind-41001). Read it straight from the ack so the caller can open the chat. + var results = account.client.publishAndCollectResults(signed, setOf(relay)) + var channelId = buzzDmChannelIdFromAck(results) + + // NIP-42 write race: on a cold connection the relay rejects the first publish with + // `auth-required` (our AUTH reply lands async and the write path doesn't re-send). Warm + // the connection with a pendingOnAuthRequired read so the auth coordinator completes the + // handshake, then retry the publish on the now-authed socket. Mirrors the amy CLI fix. + if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) { + account.client.fetchAllWithHooks( + filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))), + timeoutMs = 8_000, + pendingOnAuthRequired = true, + ) { _, _ -> false } + results = account.client.publishAndCollectResults(signed, setOf(relay)) + channelId = buzzDmChannelIdFromAck(results) + } + return channelId + } + + /** The relay-assigned DM channel id from a DM-open OK message (`response:{"channel_id":"…"}`). */ + private fun buzzDmChannelIdFromAck(results: Map): String? = + results.values + .firstOrNull { it.accepted } + ?.message + ?.substringAfter("\"channel_id\":\"", "") + ?.substringBefore('"') + ?.takeIf { it.isNotBlank() } + + /** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */ + suspend fun hideBuzzDm(channel: RelayGroupChannel) { + val template = DmHideEvent.build(channel.groupId.id) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** Add [member] to an existing group DM with a kind-41011 command (creates a new DM set). */ + suspend fun addBuzzDmMember( + channel: RelayGroupChannel, + member: HexKey, + ) { + val template = DmAddMemberEvent.build(channel.groupId.id, member) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** + * File a Buzz agent job (kind-43001) into channel [channelId] on [relay] — a shared + * feature-request the workspace bot can pick up. Untargeted: any agent watching the + * channel may accept it. Returns the new job id (the request event id), or null when the + * account can't write. See [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator]. + */ + suspend fun fileBuzzJob( + relay: NormalizedRelayUrl, + channelId: String, + request: String, + ): HexKey? { + if (!account.isWriteable()) return null + val signed = account.signer.sign(JobRequestEvent.build(request, channelId, null)) + // Reflect it locally so the board updates immediately (publish only sends to relays). + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + return signed.id + } + + /** Cancel a Buzz job [jobId] with a kind-43005 scoped to [channelId] on [relay]. */ + suspend fun cancelBuzzJob( + relay: NormalizedRelayUrl, + channelId: String, + jobId: HexKey, + ) { + if (!account.isWriteable()) return + val signed = account.signer.sign(JobCancelEvent.build(jobId, "", channelId)) + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + } + + /** + * Trigger a Buzz **workflow** run (kind-46020) for [workflowId] into channel [channelId] on + * [relay], carrying [task] as the run's request. The trigger's event id IS the run id (and the + * approval token), returned here. A run pauses on a human-approval gate before anything ships — + * see [com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator]. + */ + suspend fun triggerBuzzWorkflow( + relay: NormalizedRelayUrl, + channelId: String, + workflowId: String, + task: String, + ): HexKey? { + if (!account.isWriteable()) return null + val content = Json.encodeToString(WorkflowRunPayload(task = task, workflow = workflowId)) + val signed = account.signer.sign(WorkflowTriggerEvent.build(workflowId, content) { workflowChannel(channelId) }) + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + return signed.id + } + + /** + * Publish a Buzz **workflow definition** (kind-30620) into channel [channelId] on [relay]: an + * addressable event whose `d` tag is a freshly-minted workflow UUID (returned here), carrying a + * human-readable [name] and the workflow's [yaml] recipe. On a real Buzz relay the relay parses + * the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker + * offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write. + */ + suspend fun publishBuzzWorkflowDef( + relay: NormalizedRelayUrl, + channelId: String, + name: String, + yaml: String, + ): String? { + if (!account.isWriteable()) return null + val workflowId = RandomInstance.randomChars(16) + val signed = account.signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null })) + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + return workflowId + } + + /** + * Grant a paused Buzz workflow run's approval gate (kind-46030). [runId] is the run id, which + * doubles as the approval token (the grant's `d` tag). Resuming lets the runner ship the work. + * Publishing to the single group [relay]; the runner discovers the decision by author. + */ + suspend fun approveBuzzWorkflowRun( + relay: NormalizedRelayUrl, + runId: HexKey, + note: String = "", + ): HexKey? { + if (!account.isWriteable()) return null + val signed = account.signer.sign(ApprovalGrantEvent.build(runId, note)) + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + return signed.id + } + + /** Deny a paused Buzz workflow run's approval gate (kind-46031); the run is terminal (DENIED). */ + suspend fun denyBuzzWorkflowRun( + relay: NormalizedRelayUrl, + runId: HexKey, + note: String = "", + ): HexKey? { + if (!account.isWriteable()) return null + val signed = account.signer.sign(ApprovalDenyEvent.build(runId, note)) + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + return signed.id + } + + /** + * Upvote a Buzz job [jobId] (authored by [jobAuthor]) — a NIP-25 like (kind-7 `+`) `e`-tagging + * the request, `p`-tagging its author and `k`-tagging the reacted kind per NIP-25, and + * `h`-scoped to [channelId] so the scheduler (and the board) count it toward priority. + */ + suspend fun upvoteBuzzJob( + relay: NormalizedRelayUrl, + channelId: String, + jobId: HexKey, + jobAuthor: HexKey?, + ) { + if (!account.isWriteable()) return + val template = + eventTemplate(ReactionEvent.KIND, ReactionEvent.LIKE) { + addUnique(ETag.assemble(jobId, null, null)) + jobAuthor?.let { addUnique(PTag.assemble(it, null)) } + addUnique(arrayOf("k", JobRequestEvent.KIND.toString())) + addUnique(GroupIdTag.assemble(channelId)) + } + val signed = account.signer.sign(template) + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + } + + /** Send a kind 9022 leave request to the host relay and drop it from our list. */ + suspend fun leaveRelayGroup(channel: RelayGroupChannel) { + val template = LeaveRequestEvent.build(channel.groupId.id) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + account.unfollow(channel) + } + + /** + * Delete the whole group with a kind 9008 delete-group event (owner/admin only — the relay + * enforces this). Unlike [leaveRelayGroup], this destroys the channel for everyone rather than + * just removing me; the relay drops the group and its messages. Also drops it from our own list + * so it disappears from Messages immediately instead of lingering as a now-dead id. + */ + suspend fun deleteRelayGroup(channel: RelayGroupChannel) { + val template = DeleteGroupEvent.build(channel.groupId.id) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + account.unfollow(channel) + // Remember the deletion so the channel leaves the community's browse list immediately and + // stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a + // stale re-announced 44100 on a Buzz relay) would otherwise keep it visible. + RelayGroupDeletions.markDeleted(channel.groupId) + } + + /** + * Create a new group on [relay]: kind 9007 (create-group) then kind 9002 + * (edit-metadata) with the chosen name/visibility, then remember it. Returns + * the new group's id. + */ + suspend fun createRelayGroup( + relay: NormalizedRelayUrl, + groupId: String, + name: String, + about: String? = null, + picture: String? = null, + isPrivate: Boolean = false, + isClosed: Boolean = false, + isHidden: Boolean = false, + isRestricted: Boolean = false, + hashtags: List = emptyList(), + geohashes: List = emptyList(), + parent: String? = null, + channelType: String? = null, + ): GroupId { + // The metadata rides the create event as well as the 9002 below. A plain NIP-29 relay takes + // its metadata from the 9002 and ignores these tags; Buzz rejects the 9007 outright without + // a `name` (see CreateGroupEvent.build), which used to make "create group" on a Buzz relay + // publish two events and produce nothing at all. + account.broadcaster.signAndSendPrivatelyOrBroadcast( + CreateGroupEvent.build( + groupId = groupId, + name = name, + about = about, + visibility = if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN, + channelType = channelType, + ), + ) { listOf(relay) } + + val edit = + EditMetadataEvent.build( + groupId, + name = name, + about = about, + picture = picture, + status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), + hashtags = hashtags, + geohashes = geohashes, + parent = parent, + ) + account.broadcaster.signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } + + val id = GroupId(groupId, relay) + account.follow(LocalCache.getOrCreateRelayGroupChannel(id)) + return id + } + + /** + * The set of NIP-29 status flags to emit on a kind-9002 metadata event. Flags are + * presence-only — public/open/visible/unrestricted are simply the ABSENCE of their + * restrictive counterpart — so only the enabled restrictive flags are added. + */ + private fun relayGroupStatus( + isPrivate: Boolean, + isClosed: Boolean, + isHidden: Boolean, + isRestricted: Boolean, + ): Set = + buildSet { + if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE) + if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED) + if (isHidden) add(GroupMetadataEvent.GroupStatus.HIDDEN) + if (isRestricted) add(GroupMetadataEvent.GroupStatus.RESTRICTED) + } + + /** Post a kind 11 thread (forum-style) to the group, scoped by its `h` tag. */ + suspend fun postRelayGroupThread( + channel: RelayGroupChannel, + title: String, + body: String, + ) { + val template = + ThreadEvent.build(body, title) { + hTag(channel.groupId.id) + previous(channel.previousEventRefs(account.pubKey)) + } + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** Mint a kind 9009 invite code for the group (admin/moderator only). */ + suspend fun createRelayGroupInvite( + channel: RelayGroupChannel, + code: String, + ) { + val template = CreateInviteEvent.build(channel.groupId.id, code) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** + * Replace the group's pinned-message list with a kind 9010 update-pin-list event + * (admin/moderator only). NIP-29 carries the FULL list, so the relay applies it and + * republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent]. + */ + suspend fun updateRelayGroupPins( + channel: RelayGroupChannel, + pinnedEventIds: List, + ) { + val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** Pin [eventId] by appending it to the current list (no-op if already pinned). */ + suspend fun pinRelayGroupMessage( + channel: RelayGroupChannel, + eventId: HexKey, + ) { + if (channel.isPinned(eventId)) return + updateRelayGroupPins(channel, channel.pinnedEventIds + eventId) + } + + /** Unpin [eventId] by removing it from the current list (no-op if not pinned). */ + suspend fun unpinRelayGroupMessage( + channel: RelayGroupChannel, + eventId: HexKey, + ) { + if (!channel.isPinned(eventId)) return + updateRelayGroupPins(channel, channel.pinnedEventIds - eventId) + } + + /** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */ + suspend fun removeRelayGroupUser( + channel: RelayGroupChannel, + pubkey: HexKey, + ) { + val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey)) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** + * Add [pubkey] to the group (or change its roles) with a kind 9000 put-user + * event (moderator only). Pass an empty [roles] list for a plain member. + */ + suspend fun putRelayGroupUser( + channel: RelayGroupChannel, + pubkey: HexKey, + roles: List, + ) { + // Buzz ignores the roles inside the `p` tag and reads a top-level `role` tag instead, in its + // own vocabulary — so map ours onto its set before sending. Anything it cannot parse fails + // the whole put-user, which is why an unmapped role must become `member` rather than travel. + val buzzRole = + if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) { + when { + roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN + else -> BUZZ_ROLE_MEMBER + } + } else { + null + } + val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** + * Add [pubkey] to a Buzz **community** (the whole relay/tenant, not one channel) via the + * relay-admin add-member command (kind 9030). Owner/admin only — the relay validates the + * sender's role and, on a new insert, updates its NIP-43 membership list (13534). Published to + * [relay] with no channel scope. + */ + suspend fun addCommunityMember( + relay: NormalizedRelayUrl, + pubkey: HexKey, + role: String? = null, + ) { + account.broadcaster.signAndSendPrivatelyOrBroadcast(RelayAdminAddMemberEvent.build(pubkey, role)) { listOf(relay) } + } + + /** Remove [pubkey] from a Buzz community via the relay-admin remove-member command (kind 9031). */ + suspend fun removeCommunityMember( + relay: NormalizedRelayUrl, + pubkey: HexKey, + ) { + account.broadcaster.signAndSendPrivatelyOrBroadcast(RelayAdminRemoveMemberEvent.build(pubkey)) { listOf(relay) } + } + + /** + * Edit the group's relay-signed metadata with a kind 9002 event (admin only). + * + * NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy + * links: a 9002 with no `parent` tag re-roots the group, and one that drops any + * existing `child` is rejected by the relay. So unless the caller is explicitly + * re-parenting, we re-carry the group's current [parent] and full [children] list + * from its latest known metadata to keep the tree intact across a plain name/flag + * edit. Pass an explicit value to change them. + */ + suspend fun editRelayGroupMetadata( + channel: RelayGroupChannel, + name: String?, + about: String?, + picture: String?, + isPrivate: Boolean, + isClosed: Boolean, + isHidden: Boolean, + isRestricted: Boolean, + hashtags: List = emptyList(), + geohashes: List = emptyList(), + parent: String? = channel.parentGroupId(), + children: List = channel.childGroupIds(), + ) { + // On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT + // read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on + // edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag. + val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) + val template = + EditMetadataEvent.build( + channel.groupId.id, + name = name, + about = about, + picture = picture, + status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), + hashtags = hashtags, + geohashes = geohashes, + parent = parent, + children = children, + visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null, + ) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** + * Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The + * relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its + * history — the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces. + */ + suspend fun archiveRelayGroup( + channel: RelayGroupChannel, + archived: Boolean, + ) { + val template = EditMetadataEvent.build(channel.groupId.id, archived = archived) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 7a9981040a..3a24635536 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -696,7 +696,7 @@ class AccountViewModel( fun sendBuzzTyping(channel: RelayGroupChannel) = viewModelScope.launch(Dispatchers.IO) { - account.sendBuzzTyping(channel) + account.relayGroups.sendBuzzTyping(channel) } @Immutable @@ -1668,12 +1668,12 @@ class AccountViewModel( fun joinRelayGroup( channel: RelayGroupChannel, code: String? = null, - ) = launchSigner { account.joinRelayGroup(channel, code) } + ) = launchSigner { account.relayGroups.joinRelayGroup(channel, code) } - fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) } + fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.leaveRelayGroup(channel) } /** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */ - fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) } + fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.deleteRelayGroup(channel) } /** * Archive/unarchive a Buzz channel (kind-9002 `archived` tag) — hides it from the sidebar without @@ -1682,7 +1682,7 @@ class AccountViewModel( fun archiveRelayGroup( channel: RelayGroupChannel, archived: Boolean, - ) = launchSigner { account.archiveRelayGroup(channel, archived) } + ) = launchSigner { account.relayGroups.archiveRelayGroup(channel, archived) } /** * Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops @@ -1721,7 +1721,7 @@ class AccountViewModel( * Hide a Buzz DM from Messages (kind-41012). DM-specific — a DM has no kind-10009 entry; the relay * republishes my per-viewer 30622 hidden snapshot, dropping it from the inbox until I re-open it. */ - fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.hideBuzzDm(channel) } + fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.relayGroups.hideBuzzDm(channel) } /** * Bring a hidden Buzz DM back to Messages: Buzz has no "unhide", so re-open the conversation with @@ -1731,7 +1731,7 @@ class AccountViewModel( fun unhideBuzzDm( relay: NormalizedRelayUrl, participants: List, - ) = launchSigner { account.openBuzzDm(relay, participants) } + ) = launchSigner { account.relayGroups.openBuzzDm(relay, participants) } /** * Keep the channel off Messages without touching membership. Local and reversible — I stay in the @@ -1745,7 +1745,7 @@ class AccountViewModel( /** Actually leave: kind-9022 to the host relay, and drop it from my list and the pending set. */ fun leaveChannelInvite(channel: RelayGroupChannel) = launchSigner { - account.leaveRelayGroup(channel) + account.relayGroups.leaveRelayGroup(channel) BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id) } @@ -1771,7 +1771,7 @@ class AccountViewModel( hashtags: List, geohashes: List, ) = launchSigner { - account.createRelayGroup( + account.relayGroups.createRelayGroup( relay, groupId, name, @@ -1789,47 +1789,47 @@ class AccountViewModel( fun createRelayGroupInvite( channel: RelayGroupChannel, code: String, - ) = launchSigner { account.createRelayGroupInvite(channel, code) } + ) = launchSigner { account.relayGroups.createRelayGroupInvite(channel, code) } fun postRelayGroupThread( channel: RelayGroupChannel, title: String, body: String, - ) = launchSigner { account.postRelayGroupThread(channel, title, body) } + ) = launchSigner { account.relayGroups.postRelayGroupThread(channel, title, body) } fun pinRelayGroupMessage( channel: RelayGroupChannel, note: Note, - ) = launchSigner { account.pinRelayGroupMessage(channel, note.idHex) } + ) = launchSigner { account.relayGroups.pinRelayGroupMessage(channel, note.idHex) } fun unpinRelayGroupMessage( channel: RelayGroupChannel, note: Note, - ) = launchSigner { account.unpinRelayGroupMessage(channel, note.idHex) } + ) = launchSigner { account.relayGroups.unpinRelayGroupMessage(channel, note.idHex) } fun removeRelayGroupUser( channel: RelayGroupChannel, pubkey: HexKey, - ) = launchSigner { account.removeRelayGroupUser(channel, pubkey) } + ) = launchSigner { account.relayGroups.removeRelayGroupUser(channel, pubkey) } fun putRelayGroupUser( channel: RelayGroupChannel, pubkey: HexKey, roles: List, - ) = launchSigner { account.putRelayGroupUser(channel, pubkey, roles) } + ) = launchSigner { account.relayGroups.putRelayGroupUser(channel, pubkey, roles) } /** Add [pubkey] to a Buzz community (relay-wide, kind 9030). Owner/admin only; relay enforces. */ fun addCommunityMember( relay: NormalizedRelayUrl, pubkey: HexKey, role: String? = null, - ) = launchSigner { account.addCommunityMember(relay, pubkey, role) } + ) = launchSigner { account.relayGroups.addCommunityMember(relay, pubkey, role) } /** Remove [pubkey] from a Buzz community (relay-wide, kind 9031). Owner/admin only. */ fun removeCommunityMember( relay: NormalizedRelayUrl, pubkey: HexKey, - ) = launchSigner { account.removeCommunityMember(relay, pubkey) } + ) = launchSigner { account.relayGroups.removeCommunityMember(relay, pubkey) } fun editRelayGroupMetadata( channel: RelayGroupChannel, @@ -1843,7 +1843,7 @@ class AccountViewModel( hashtags: List, geohashes: List, ) = launchSigner { - account.editRelayGroupMetadata( + account.relayGroups.editRelayGroupMetadata( channel, name, about, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt index 1aa34aaccf..e3f106f595 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt @@ -169,33 +169,33 @@ class AgentWorkBoardViewModel : ViewModel() { onResult: (Boolean) -> Unit, ) = act(onResult) { account, relay, channelId -> if (requireApproval) { - account.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null + account.relayGroups.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null } else { - account.fileBuzzJob(relay, channelId, text) != null + account.relayGroups.fileBuzzJob(relay, channelId, text) != null } } fun approve( runId: HexKey, onResult: (Boolean) -> Unit, - ) = act(onResult) { account, relay, _ -> account.approveBuzzWorkflowRun(relay, runId) != null } + ) = act(onResult) { account, relay, _ -> account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null } fun deny( runId: HexKey, onResult: (Boolean) -> Unit, - ) = act(onResult) { account, relay, _ -> account.denyBuzzWorkflowRun(relay, runId) != null } + ) = act(onResult) { account, relay, _ -> account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null } fun upvote( jobId: HexKey, jobAuthor: HexKey?, ) = act({}) { account, relay, channelId -> - account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) + account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) true } fun cancel(jobId: HexKey) = act({}) { account, relay, channelId -> - account.cancelBuzzJob(relay, channelId, jobId) + account.relayGroups.cancelBuzzJob(relay, channelId, jobId) true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt index da30fcc4dc..008372414c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt @@ -211,7 +211,7 @@ private fun DmRowCard( addMemberOpen = false scope.launch { val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) - accountViewModel.account.addBuzzDmMember(channel, hex) + accountViewModel.account.relayGroups.addBuzzDmMember(channel, hex) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index 0e3427d717..a63b122d6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -254,7 +254,7 @@ class BuzzDmListViewModel : ViewModel() { fun removeFromMessages(row: DmRow) { val account = account ?: return viewModelScope.launch(Dispatchers.IO) { - account.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl))) + account.relayGroups.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl))) } } @@ -268,7 +268,7 @@ class BuzzDmListViewModel : ViewModel() { val account = account ?: return viewModelScope.launch(Dispatchers.IO) { val me = account.userProfile().pubkeyHex - account.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) }) + account.relayGroups.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) }) // The relay's new 30622 normally arrives on the live subscription; refresh anyway so the // row returns even if this screen's socket missed the snapshot. refresh() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt index 248dbc896f..abd18633d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt @@ -194,7 +194,7 @@ class BuzzNewDmViewModel : ViewModel() { _status.value = Status.Sending viewModelScope.launch(Dispatchers.IO) { try { - val channelId = account.openBuzzDm(relay, others) + val channelId = account.relayGroups.openBuzzDm(relay, others) val groupId = channelId?.let { GroupId(it, relay) } withContext(Dispatchers.Main) { onOpened(groupId) } } catch (e: CancellationException) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt index d634500dd6..62ead90720 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt @@ -112,19 +112,19 @@ class JobBoardViewModel : ViewModel() { fun file(request: String) = act { account, relay, channelId -> - account.fileBuzzJob(relay, channelId, request) + account.relayGroups.fileBuzzJob(relay, channelId, request) } fun upvote( jobId: String, jobAuthor: String?, ) = act { account, relay, channelId -> - account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) + account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) } fun cancel(jobId: String) = act { account, relay, channelId -> - account.cancelBuzzJob(relay, channelId, jobId) + account.relayGroups.cancelBuzzJob(relay, channelId, jobId) } private inline fun act(crossinline block: suspend (Account, NormalizedRelayUrl, String) -> Unit) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt index 31b3145ee9..4dc32ff52c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt @@ -199,21 +199,21 @@ class WorkflowRunBoardViewModel : ViewModel() { task: String, onResult: (Boolean) -> Unit, ) = act(onResult) { account, relay, channelId -> - account.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null + account.relayGroups.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null } fun approve( runId: HexKey, onResult: (Boolean) -> Unit, ) = act(onResult) { account, relay, _ -> - account.approveBuzzWorkflowRun(relay, runId) != null + account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null } fun deny( runId: HexKey, onResult: (Boolean) -> Unit, ) = act(onResult) { account, relay, _ -> - account.denyBuzzWorkflowRun(relay, runId) != null + account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null } /** @@ -234,7 +234,7 @@ class WorkflowRunBoardViewModel : ViewModel() { return } viewModelScope.launch(Dispatchers.IO) { - val newId = account.publishBuzzWorkflowDef(relay, channelId, name, yaml) + val newId = account.relayGroups.publishBuzzWorkflowDef(relay, channelId, name, yaml) withContext(Dispatchers.Main) { onResult(newId) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index f4519f4bcb..ae5275e8f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -254,7 +254,7 @@ class RelayGroupMetadataViewModel : ViewModel() { val geohashes = parseGeohashes() val existing = channel if (existing == null) { - account.createRelayGroup( + account.relayGroups.createRelayGroup( relay = relay!!, groupId = groupId, name = name, @@ -270,7 +270,7 @@ class RelayGroupMetadataViewModel : ViewModel() { channelType = if (isBuzzRelay) (if (isForum) BUZZ_CHANNEL_TYPE_FORUM else BUZZ_CHANNEL_TYPE_STREAM) else null, ) } else { - account.editRelayGroupMetadata( + account.relayGroups.editRelayGroupMetadata( channel = existing, name = name, about = about, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index a4e1818b7d..a8573a9032 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -567,7 +567,7 @@ open class ChannelNewMessageViewModel : val pk = user.pubkeyHex if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) { try { - accountViewModel.account.putRelayGroupUser(channel, pk, emptyList()) + accountViewModel.account.relayGroups.putRelayGroupUser(channel, pk, emptyList()) } catch (e: Exception) { if (e is CancellationException) throw e Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}: ${e.message}") From 1dba215d4dea50d6ed3c0924564db08be0c93734 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:16:27 +0000 Subject: [PATCH 069/227] refactor: extract AccountZapActions from Account Moves the ~270-line zap/payment orchestration (NIP-57 zap requests, NWC wallet requests with spoof tracking, NIP-B1 BOLT12 zaps, NIP-BC onchain zaps/sends/splits) into AccountZapActions, exposed as account.zaps. The onchain backend-not-configured constant moves with it. External callers (ZapPaymentHandler, V4VPaymentHandler, wallet viewmodels, blossom payments, app functions) now call account.zaps.* directly. Moved code is unchanged except for account. qualification. Completes the Account decoupling series: Account.kt went from 6228 to 3618 lines across EventBroadcaster, AccountConcordActions, AccountMarmotActions, AccountRelayGroupActions, and AccountZapActions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../vitorpamplona/amethyst/model/Account.kt | 291 +-------------- .../amethyst/model/AccountZapActions.kt | 337 ++++++++++++++++++ .../gateways/AccountNappletGateways.kt | 2 +- .../amethyst/service/V4VPaymentHandler.kt | 6 +- .../amethyst/service/ZapPaymentHandler.kt | 8 +- .../uploads/blossom/BlossomPaymentHandler.kt | 2 +- .../amethyst/ui/note/ZapPollNoteViewModel.kt | 2 +- .../amethyst/ui/note/types/Goal.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 14 +- .../profile/payment/SendPaymentScreen.kt | 4 +- .../loggedIn/wallet/OnchainZapSendDialog.kt | 4 +- .../loggedIn/wallet/ReloadMintViewModel.kt | 2 +- .../loggedIn/wallet/TopUpMintViewModel.kt | 2 +- .../screen/loggedIn/wallet/WalletViewModel.kt | 20 +- .../appfunctions/AmethystAppFunctions.kt | 4 +- 15 files changed, 376 insertions(+), 324 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d9c5c66860..70afe7bbfe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore @@ -60,11 +59,6 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCa import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent -import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError -import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult -import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage -import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender -import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -254,12 +248,6 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -319,8 +307,6 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent -import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder -import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log @@ -347,8 +333,6 @@ import kotlin.coroutines.cancellation.CancellationException import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash -private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured" - @OptIn(DelicateCoroutinesApi::class) @Stable class Account( @@ -726,6 +710,9 @@ class Account( /** NIP-29 relay-group + Buzz workspace orchestration. */ val relayGroups = AccountRelayGroupActions(this) + /** Zap/payment orchestration (NIP-57, NWC, BOLT12, onchain). */ + val zaps = AccountZapActions(this) + /** * Relay routing + sign-and-publish choke point: computes which relays an event * should go to (outbox model, hints, channels, broadcast lists) and owns every @@ -1339,278 +1326,6 @@ class Account( cache.justConsumeMyOwnEvent(event) } - suspend fun createZapRequestFor( - event: Event, - pollOption: Int?, - message: String = "", - zapType: LnZapEvent.ZapType, - toUser: User?, - additionalRelays: Set? = null, - amountMillisats: Long? = null, - lnurl: String? = null, - ) = LnZapRequestEvent.create( - zappedEvent = event, - relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()), - signer = signer, - pollOption = pollOption, - message = message, - zapType = zapType, - toUserPubHex = toUser?.pubkeyHex, - amountMillisats = amountMillisats, - lnurl = lnurl, - ) - - suspend fun calculateIfNoteWasZappedByAccount( - zappedNote: Note?, - afterTimeInSeconds: Long, - ): Boolean = zappedNote?.isZappedBy(userProfile(), afterTimeInSeconds, this) == true - - suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState) - - suspend fun sendNwcRequest( - request: Request, - onResponse: (Response?) -> Unit, - ) { - val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse) - client.publish(event, setOf(relay)) - } - - suspend fun sendNwcRequestToWallet( - walletUri: Nip47WalletConnect.Nip47URINorm, - request: Request, - onResponse: (Response?) -> Unit, - ): HexKey { - val (event, relay) = nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse) - client.publish(event, setOf(relay)) - return event.id - } - - /** - * Number of spoofed (wrong-author) NIP-47 replies that have arrived for - * the given request id. 0 if the request is unknown or already resolved. - */ - fun nwcSpoofAttempts(requestId: HexKey): Int = LocalCache.paymentTracker.spoofAttemptsFor(requestId) - - /** - * Removes a pending NIP-47 request from the tracker. Call this when the - * UI gives up waiting (timeout) so the entry doesn't stick around. - */ - fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId) - - suspend fun sendZapPaymentRequestFor( - bolt11: String, - zappedNote: Note?, - onResponse: (Response?) -> Unit, - ) { - val (event, relay) = nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) - client.publish(event, setOf(relay)) - } - - /** - * True when the default NWC wallet advertises the nwc#2 `pay` method — the rail a - * BOLT12 zap needs to obtain a payer proof. Read from the wallet's cached kind:13194 - * info event (its capability advertisement), which [NwcSignerState] already refreshes - * on wallet change. A missing/unfetched info event reads as false, so the zap path - * falls back to lightning rather than attempting a `pay` the wallet can't honor. - */ - fun defaultWalletSupportsBolt12Pay(): Boolean { - val uri = nip47SignerState.defaultWalletUri.value ?: return false - return nip47SignerState.infoCache?.current(uri)?.supportsMethod(NwcMethod.PAY) == true - } - - /** - * Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet. - * - * Signs a kind 9737 intent, pays [offer] via the nwc#2 `pay` method with the - * intent-bound `payer_note`, then — only if the wallet returns a payer proof that - * validates — builds, self-consumes, and publishes the kind 9736 zap. Validation - * is the fail-safe: a wallet that drops or misroutes the note yields a proof that - * fails the binding check, so no invalid receipt is ever published (the payment - * still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for - * a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no - * external-wallet or LNURL fallback because only NWC returns the proof. - */ - suspend fun sendBolt12Zap( - zappedEvent: Event?, - recipientPubKey: HexKey, - offer: String, - amountMillisats: Long, - message: String, - zapType: LnZapEvent.ZapType, - // (messageResId, detail) — the caller localizes; detail carries a wallet error, if any. - onError: (Int, String?) -> Unit, - onProcessed: () -> Unit, - ) { - // NONZAP means "pay, but publish no receipt" — settle the offer without binding - // a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP. - if (zapType == LnZapEvent.ZapType.NONZAP) { - sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response -> - scope.launch { - if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage()) - onProcessed() - } - } - return - } - - val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS - // The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous - // zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable. - val zapSigner = if (anonymous) NostrSignerInternal(KeyPair()) else signer - - val intent = - if (zappedEvent == null) { - Bolt12ZapBuilder.buildProfileIntent(zapSigner, recipientPubKey, amountMillisats, offer, message) - } else { - Bolt12ZapBuilder.buildIntent(zapSigner, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), message) - } - - val payerNote = Bolt12ZapBuilder.payerNote(intent) - - sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response -> - scope.launch { - // try/finally so a failure while assembling/publishing the receipt (e.g. a - // remote signer error) still steps progress and surfaces an error, instead - // of vanishing as an uncaught coroutine exception. The payment already - // settled at this point, so such a failure means "paid, no receipt". - try { - when (response) { - is PaySuccessResponse -> { - val proof = response.result?.payer_proof - if (proof.isNullOrBlank()) { - onError(R.string.bolt12_zap_paid_no_receipt, null) - } else { - val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous) - if (cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) { - cache.justConsumeMyOwnEvent(zap) - client.publish(zap, computeRelayListToBroadcast(zap)) - } else { - onError(R.string.bolt12_zap_invalid_receipt, null) - } - } - } - - is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage()) - - else -> onError(R.string.bolt12_zap_paid_no_receipt, null) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w("Account", "BOLT12 zap receipt assembly failed after payment", e) - onError(R.string.bolt12_zap_paid_no_receipt, null) - } finally { - onProcessed() - } - } - } - } - - suspend fun createZapRequestFor( - user: User, - message: String = "", - zapType: LnZapEvent.ZapType, - amountMillisats: Long? = null, - lnurl: String? = null, - ): LnZapRequestEvent { - val zapRequest = - LnZapRequestEvent.create( - userHex = user.pubkeyHex, - relays = nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()), - signer = signer, - message = message, - zapType = zapType, - amountMillisats = amountMillisats, - lnurl = lnurl, - ) - - cache.justConsumeMyOwnEvent(zapRequest) - return zapRequest - } - - private fun onchainBackendNotConfigured() = - OnchainZapSendResult.Failure( - OnchainZapSendStage.LOADING_UTXOS, - OnchainZapSendError.BACKEND_NOT_CONFIGURED, - ONCHAIN_BACKEND_NOT_CONFIGURED, - ) - - /** - * Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's - * derived Taproot address, sign it, broadcast it, and publish the kind:8333 - * zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or - * leave it null for a profile zap. - */ - suspend fun sendOnchainZap( - recipientPubKey: HexKey, - amountSats: Long, - feeRateSatPerVByte: Double, - comment: String = "", - zappedEvent: EventHintBundle? = null, - ): OnchainZapSendResult { - val backend = - cache.onchainBackend - ?: return onchainBackendNotConfigured() - return OnchainZapSender.send( - backend = backend, - signer = signer, - senderPubKey = signer.pubKey, - recipientPubKey = recipientPubKey, - amountSats = amountSats, - feeRateSatPerVByte = feeRateSatPerVByte, - comment = comment, - zappedEvent = zappedEvent, - ) { template -> signAndComputeBroadcast(template) } - } - - /** - * Pay an explicit Bitcoin address (e.g. a profile's NIP-A3 `bitcoin` - * payment target) from the NIP-BC Taproot wallet. A plain wallet send — - * no kind:8333 receipt is published. See [OnchainZapSender.sendToAddress]. - */ - suspend fun sendOnchainToAddress( - recipientAddress: String, - amountSats: Long, - feeRateSatPerVByte: Double, - ): OnchainZapSendResult { - val backend = - cache.onchainBackend - ?: return onchainBackendNotConfigured() - return OnchainZapSender.sendToAddress( - backend = backend, - signer = signer, - senderPubKey = signer.pubKey, - recipientAddress = recipientAddress, - amountSats = amountSats, - feeRateSatPerVByte = feeRateSatPerVByte, - ) - } - - /** - * Send a NIP-BC onchain split zap: a single Bitcoin transaction paying - * each recipient their precomputed share, plus one kind:8333 receipt per - * recipient. See [OnchainZapSender.sendSplit] for failure semantics. - */ - suspend fun sendOnchainZapWithSplits( - recipients: List, - feeRateSatPerVByte: Double, - comment: String = "", - zappedEvent: EventHintBundle? = null, - ): OnchainZapSendResult { - val backend = - cache.onchainBackend - ?: return onchainBackendNotConfigured() - return OnchainZapSender.sendSplit( - backend = backend, - signer = signer, - senderPubKey = signer.pubKey, - recipients = recipients, - feeRateSatPerVByte = feeRateSatPerVByte, - comment = comment, - zappedEvent = zappedEvent, - ) { template -> signAndComputeBroadcast(template) } - } - suspend fun report( note: Note, type: ReportType, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt new file mode 100644 index 0000000000..1c9f6ae8bf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare +import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.launch +import java.math.BigDecimal +import kotlin.coroutines.cancellation.CancellationException + +private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured" + +/** + * Zap and payment orchestration for an [Account]: NIP-57 zap requests, NIP-47 + * NWC wallet requests (with spoof tracking), NIP-B1 BOLT12 zaps, and NIP-BC + * onchain zaps/sends. Event building lives in the commons ZapActions/ + * Bolt12ZapActions; this class wires wallet selection, signing, and relay + * routing to the account. + */ +class AccountZapActions( + private val account: Account, +) { + suspend fun createZapRequestFor( + event: Event, + pollOption: Int?, + message: String = "", + zapType: LnZapEvent.ZapType, + toUser: User?, + additionalRelays: Set? = null, + amountMillisats: Long? = null, + lnurl: String? = null, + ) = LnZapRequestEvent.create( + zappedEvent = event, + relays = account.nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()), + signer = account.signer, + pollOption = pollOption, + message = message, + zapType = zapType, + toUserPubHex = toUser?.pubkeyHex, + amountMillisats = amountMillisats, + lnurl = lnurl, + ) + + suspend fun calculateIfNoteWasZappedByAccount( + zappedNote: Note?, + afterTimeInSeconds: Long, + ): Boolean = zappedNote?.isZappedBy(account.userProfile(), afterTimeInSeconds, account) == true + + suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(account.nip47SignerState) + + suspend fun sendNwcRequest( + request: Request, + onResponse: (Response?) -> Unit, + ) { + val (event, relay) = account.nip47SignerState.sendNwcRequest(request, onResponse) + account.client.publish(event, setOf(relay)) + } + + suspend fun sendNwcRequestToWallet( + walletUri: Nip47WalletConnect.Nip47URINorm, + request: Request, + onResponse: (Response?) -> Unit, + ): HexKey { + val (event, relay) = account.nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse) + account.client.publish(event, setOf(relay)) + return event.id + } + + /** + * Number of spoofed (wrong-author) NIP-47 replies that have arrived for + * the given request id. 0 if the request is unknown or already resolved. + */ + fun nwcSpoofAttempts(requestId: HexKey): Int = LocalCache.paymentTracker.spoofAttemptsFor(requestId) + + /** + * Removes a pending NIP-47 request from the tracker. Call this when the + * UI gives up waiting (timeout) so the entry doesn't stick around. + */ + fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId) + + suspend fun sendZapPaymentRequestFor( + bolt11: String, + zappedNote: Note?, + onResponse: (Response?) -> Unit, + ) { + val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) + account.client.publish(event, setOf(relay)) + } + + /** + * True when the default NWC wallet advertises the nwc#2 `pay` method — the rail a + * BOLT12 zap needs to obtain a payer proof. Read from the wallet's cached kind:13194 + * info event (its capability advertisement), which [NwcSignerState] already refreshes + * on wallet change. A missing/unfetched info event reads as false, so the zap path + * falls back to lightning rather than attempting a `pay` the wallet can't honor. + */ + fun defaultWalletSupportsBolt12Pay(): Boolean { + val uri = account.nip47SignerState.defaultWalletUri.value ?: return false + return account.nip47SignerState.infoCache + ?.current(uri) + ?.supportsMethod(NwcMethod.PAY) == true + } + + /** + * Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet. + * + * Signs a kind 9737 intent, pays [offer] via the nwc#2 `pay` method with the + * intent-bound `payer_note`, then — only if the wallet returns a payer proof that + * validates — builds, self-consumes, and publishes the kind 9736 zap. Validation + * is the fail-safe: a wallet that drops or misroutes the note yields a proof that + * fails the binding check, so no invalid receipt is ever published (the payment + * still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for + * a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no + * external-wallet or LNURL fallback because only NWC returns the proof. + */ + suspend fun sendBolt12Zap( + zappedEvent: Event?, + recipientPubKey: HexKey, + offer: String, + amountMillisats: Long, + message: String, + zapType: LnZapEvent.ZapType, + // (messageResId, detail) — the caller localizes; detail carries a wallet error, if any. + onError: (Int, String?) -> Unit, + onProcessed: () -> Unit, + ) { + // NONZAP means "pay, but publish no receipt" — settle the offer without binding + // a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP. + if (zapType == LnZapEvent.ZapType.NONZAP) { + sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response -> + account.scope.launch { + if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage()) + onProcessed() + } + } + return + } + + val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS + // The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous + // zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable. + val zapSigner = if (anonymous) NostrSignerInternal(KeyPair()) else account.signer + + val intent = + if (zappedEvent == null) { + Bolt12ZapBuilder.buildProfileIntent(zapSigner, recipientPubKey, amountMillisats, offer, message) + } else { + Bolt12ZapBuilder.buildIntent(zapSigner, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), message) + } + + val payerNote = Bolt12ZapBuilder.payerNote(intent) + + sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response -> + account.scope.launch { + // try/finally so a failure while assembling/publishing the receipt (e.g. a + // remote signer error) still steps progress and surfaces an error, instead + // of vanishing as an uncaught coroutine exception. The payment already + // settled at this point, so such a failure means "paid, no receipt". + try { + when (response) { + is PaySuccessResponse -> { + val proof = response.result?.payer_proof + if (proof.isNullOrBlank()) { + onError(R.string.bolt12_zap_paid_no_receipt, null) + } else { + val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous) + if (account.cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) { + account.cache.justConsumeMyOwnEvent(zap) + account.client.publish(zap, account.broadcaster.computeRelayListToBroadcast(zap)) + } else { + onError(R.string.bolt12_zap_invalid_receipt, null) + } + } + } + + is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage()) + + else -> onError(R.string.bolt12_zap_paid_no_receipt, null) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("Account", "BOLT12 zap receipt assembly failed after payment", e) + onError(R.string.bolt12_zap_paid_no_receipt, null) + } finally { + onProcessed() + } + } + } + } + + suspend fun createZapRequestFor( + user: User, + message: String = "", + zapType: LnZapEvent.ZapType, + amountMillisats: Long? = null, + lnurl: String? = null, + ): LnZapRequestEvent { + val zapRequest = + LnZapRequestEvent.create( + userHex = user.pubkeyHex, + relays = account.nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()), + signer = account.signer, + message = message, + zapType = zapType, + amountMillisats = amountMillisats, + lnurl = lnurl, + ) + + account.cache.justConsumeMyOwnEvent(zapRequest) + return zapRequest + } + + private fun onchainBackendNotConfigured() = + OnchainZapSendResult.Failure( + OnchainZapSendStage.LOADING_UTXOS, + OnchainZapSendError.BACKEND_NOT_CONFIGURED, + ONCHAIN_BACKEND_NOT_CONFIGURED, + ) + + /** + * Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's + * derived Taproot address, sign it, broadcast it, and publish the kind:8333 + * zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or + * leave it null for a profile zap. + */ + suspend fun sendOnchainZap( + recipientPubKey: HexKey, + amountSats: Long, + feeRateSatPerVByte: Double, + comment: String = "", + zappedEvent: EventHintBundle? = null, + ): OnchainZapSendResult { + val backend = + account.cache.onchainBackend + ?: return onchainBackendNotConfigured() + return OnchainZapSender.send( + backend = backend, + signer = account.signer, + senderPubKey = account.signer.pubKey, + recipientPubKey = recipientPubKey, + amountSats = amountSats, + feeRateSatPerVByte = feeRateSatPerVByte, + comment = comment, + zappedEvent = zappedEvent, + ) { template -> account.broadcaster.signAndComputeBroadcast(template) } + } + + /** + * Pay an explicit Bitcoin address (e.g. a profile's NIP-A3 `bitcoin` + * payment target) from the NIP-BC Taproot wallet. A plain wallet send — + * no kind:8333 receipt is published. See [OnchainZapSender.sendToAddress]. + */ + suspend fun sendOnchainToAddress( + recipientAddress: String, + amountSats: Long, + feeRateSatPerVByte: Double, + ): OnchainZapSendResult { + val backend = + account.cache.onchainBackend + ?: return onchainBackendNotConfigured() + return OnchainZapSender.sendToAddress( + backend = backend, + signer = account.signer, + senderPubKey = account.signer.pubKey, + recipientAddress = recipientAddress, + amountSats = amountSats, + feeRateSatPerVByte = feeRateSatPerVByte, + ) + } + + /** + * Send a NIP-BC onchain split zap: a single Bitcoin transaction paying + * each recipient their precomputed share, plus one kind:8333 receipt per + * recipient. See [OnchainZapSender.sendSplit] for failure semantics. + */ + suspend fun sendOnchainZapWithSplits( + recipients: List, + feeRateSatPerVByte: Double, + comment: String = "", + zappedEvent: EventHintBundle? = null, + ): OnchainZapSendResult { + val backend = + account.cache.onchainBackend + ?: return onchainBackendNotConfigured() + return OnchainZapSender.sendSplit( + backend = backend, + signer = account.signer, + senderPubKey = account.signer.pubKey, + recipients = recipients, + feeRateSatPerVByte = feeRateSatPerVByte, + comment = comment, + zappedEvent = zappedEvent, + ) { template -> account.broadcaster.signAndComputeBroadcast(template) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index e7fd4f0d67..51f482ba94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -279,7 +279,7 @@ class AccountNappletGateways( } val result = CompletableDeferred() - account.sendZapPaymentRequestFor(invoice, null) { response -> + account.zaps.sendZapPaymentRequestFor(invoice, null) { response -> when (response) { is PayInvoiceSuccessResponse -> result.complete(response.result?.preimage) is PayInvoiceErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Payment failed.")) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt index 6fa95cfe3f..3f7657ca83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -159,7 +159,7 @@ class V4VPaymentHandler( tlvRecords = tlvRecords, ) - account.sendNwcRequest(request) { response: Response? -> + account.zaps.sendNwcRequest(request) { response: Response? -> if (response is IErrorResponseLike) { onError( stringRes(context, R.string.error_dialog_pay_invoice_error), @@ -195,7 +195,7 @@ class V4VPaymentHandler( try { val nostrRequest = if (asZap && noteEvent != null) { - account.createZapRequestFor( + account.zaps.createZapRequestFor( event = noteEvent, pollOption = null, message = message, @@ -250,7 +250,7 @@ class V4VPaymentHandler( is PaymentSource.Nwc -> { var done = 0 payables.forEach { payable -> - account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response -> + account.zaps.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response -> if (response is IErrorResponseLike) { onError( stringRes(context, R.string.error_dialog_pay_invoice_error), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 6fa29d1b5e..0d5ad13cfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -163,7 +163,7 @@ class ZapPaymentHandler( val canBolt12 = account.settings.nwcWallets.value .isNotEmpty() && - account.defaultWalletSupportsBolt12Pay() + account.zaps.defaultWalletSupportsBolt12Pay() val bolt12Recipients = unverifiedZapsToSend.mapNotNull { @@ -330,7 +330,7 @@ class ZapPaymentHandler( val zapRequest = if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) { - account.createZapRequestFor( + account.zaps.createZapRequestFor( event = noteEvent, pollOption = pollOption, message = message, @@ -414,7 +414,7 @@ class ZapPaymentHandler( return mapNotNullAsync( items = payables, runRequestFor = { payable: Payable -> - account.sendZapPaymentRequestFor( + account.zaps.sendZapPaymentRequestFor( bolt11 = payable.invoice, zappedNote = note, onResponse = { response -> @@ -462,7 +462,7 @@ class ZapPaymentHandler( val progress = PaymentProgress(recipients.size, onProgress) mapNotNullAsync(recipients) { recipient: Bolt12Recipient -> - account.sendBolt12Zap( + account.zaps.sendBolt12Zap( zappedEvent = note.event, recipientPubKey = recipient.user.pubkeyHex, offer = recipient.offer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt index 5deaa6928f..7bcd89d292 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt @@ -166,7 +166,7 @@ object BlossomPaymentHandler { val preimageResult = CompletableDeferred() try { - account.sendZapPaymentRequestFor(invoice, null) { response -> + account.zaps.sendZapPaymentRequestFor(invoice, null) { response -> // CompletableDeferred.complete is idempotent, so extra callbacks are harmless. preimageResult.complete((response as? PayInvoiceSuccessResponse)?.result?.preimage) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt index 77d435e736..f8546909b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt @@ -103,7 +103,7 @@ class PollNoteViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { totalZapped = totalZapped() wasZappedByLoggedInAccount = false - wasZappedByLoggedInAccount = account.calculateIfNoteWasZappedByAccount(pollNote, 0) + wasZappedByLoggedInAccount = account.zaps.calculateIfNoteWasZappedByAccount(pollNote, 0) canZap.value = checkIfCanZap() tallies.forEach { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt index c3e621001b..005e1ba19e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt @@ -150,7 +150,7 @@ fun GoalProgressBar( LaunchedEffect(key1 = zapsState) { zapsState?.note?.let { - val newZapAmount = accountViewModel.account.calculateZappedAmount(note) + val newZapAmount = accountViewModel.account.zaps.calculateZappedAmount(note) var percentage = newZapAmount.div(goalAmountSats.toBigDecimal()).toFloat() if (percentage > 1) percentage = 1f diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 3a24635536..7963b56c27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -843,7 +843,7 @@ class AccountViewModel( afterTimeInSeconds: Long, ): Boolean = withContext(Dispatchers.IO) { - account.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds) + account.zaps.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds) } suspend fun calculateZapAmount(zappedNote: Note): String { @@ -854,7 +854,7 @@ class AccountViewModel( val ownPendingOnchain = zappedNote.extraOwnPendingOnchainSats(account.userProfile().pubkeyHex) return if (zappedNote.zapPayments.isNotEmpty()) { withContext(Dispatchers.IO) { - val nwc = account.calculateZappedAmount(zappedNote) + val nwc = account.zaps.calculateZappedAmount(zappedNote) showAmount(nwc + java.math.BigDecimal(ownPendingOnchain)) } } else { @@ -866,7 +866,7 @@ class AccountViewModel( val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0 return if (zappedNote.zapPayments.isNotEmpty()) { withContext(Dispatchers.IO) { - val newZapAmount = account.calculateZappedAmount(zappedNote) + val newZapAmount = account.zaps.calculateZappedAmount(zappedNote) var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat() if (percentage > 1) { @@ -1202,7 +1202,7 @@ class AccountViewModel( .isNotEmpty() /** True when a BOLT12 offer can be paid in-app: an NWC wallet is set and advertises `pay` (nwc#2). */ - fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.defaultWalletSupportsBolt12Pay() + fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.zaps.defaultWalletSupportsBolt12Pay() /** * Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2 @@ -1214,7 +1214,7 @@ class AccountViewModel( offer: String, amountMillisats: Long, ) = launchSigner { - account.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response -> + account.zaps.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response -> when (response) { is PaySuccessResponse -> toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_sent) is IErrorResponseLike -> @@ -2745,7 +2745,7 @@ class AccountViewModel( onSent: () -> Unit = {}, onResponse: (Response?) -> Unit, ) = launchSigner { - account.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) + account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse) onSent() } @@ -2801,7 +2801,7 @@ class AccountViewModel( if (effectiveZapType != LnZapEvent.ZapType.NONZAP) { // NIP-57 Appendix F: include amount + lnurl so the receipt can be validated. val splitLnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32) - account.createZapRequestFor( + account.zaps.createZapRequestFor( user = user, message = message, zapType = effectiveZapType, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt index d9a2fc3da0..629f595cb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt @@ -509,13 +509,13 @@ private fun SendPaymentLoaded( if (onchainAddressTarget != null) { // Pays the profile's announced bitcoin address directly — // a plain wallet send, no NIP-BC receipt exists for it. - accountViewModel.account.sendOnchainToAddress( + accountViewModel.account.zaps.sendOnchainToAddress( recipientAddress = onchainAddressTarget, amountSats = amount, feeRateSatPerVByte = feeRate, ) } else { - accountViewModel.account.sendOnchainZap( + accountViewModel.account.zaps.sendOnchainZap( recipientPubKey = user.pubkeyHex, amountSats = amount, feeRateSatPerVByte = feeRate, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index f5c2947bc5..51ca681e33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -425,7 +425,7 @@ fun OnchainZapSendDialog( ) return@launch } - accountViewModel.account.sendOnchainZapWithSplits( + accountViewModel.account.zaps.sendOnchainZapWithSplits( recipients = shares, feeRateSatPerVByte = feeRate, comment = comment.trim(), @@ -433,7 +433,7 @@ fun OnchainZapSendDialog( ) } else { val recipient = resolvedRecipient ?: return@launch - accountViewModel.account.sendOnchainZap( + accountViewModel.account.zaps.sendOnchainZap( recipientPubKey = recipient, amountSats = amount, feeRateSatPerVByte = feeRate, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 51db1320fc..c887fd980a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -347,7 +347,7 @@ class ReloadMintViewModel : ViewModel() { // Fire-and-forget: the mint-quote poll below is the source of truth for // whether the payment actually landed. runCatching { - vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { } + vm.account.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { } } } else { // No NWC — surface the invoice for an external wallet and keep polling. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt index cd6e902cfa..6fde099208 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt @@ -209,7 +209,7 @@ class TopUpMintViewModel : ViewModel() { // Fire-and-forget: the mint-quote poll below is the source of truth for // whether the payment actually landed. runCatching { - vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { } + vm.account.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { } } } else { // No NWC — surface the invoice for an external wallet and keep polling. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index e988daeed4..15e1150248 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -222,7 +222,7 @@ class WalletViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { delay(NWC_TIMEOUT_MS) val requestId = requestIdProvider() - val spoofs = requestId?.let { account?.nwcSpoofAttempts(it) ?: 0 } ?: 0 + val spoofs = requestId?.let { account?.zaps?.nwcSpoofAttempts(it) ?: 0 } ?: 0 _error.value = if (spoofs > 0) { "Wallet request timed out — $spoofs ${if (spoofs == 1) "reply was" else "replies were"} rejected because " + @@ -230,7 +230,7 @@ class WalletViewModel : ViewModel() { } else { "Wallet request timed out" } - requestId?.let { account?.cleanupNwcRequest(it) } + requestId?.let { account?.zaps?.cleanupNwcRequest(it) } onTimeout() } @@ -406,7 +406,7 @@ class WalletViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { updateWalletInfo(walletId) { it.copy(isLoading = true, error = null) } try { - acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response -> + acc.zaps.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response -> when (response) { is GetBalanceSuccessResponse -> { val sats = (response.result?.balance ?: 0L) / 1000L @@ -437,7 +437,7 @@ class WalletViewModel : ViewModel() { val walletUri = getWalletUri(walletId) ?: return viewModelScope.launch(Dispatchers.IO) { try { - acc.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response -> + acc.zaps.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response -> when (response) { is GetInfoSuccessResponse -> { updateWalletInfo(walletId) { it.copy(alias = response.result?.alias) } @@ -479,7 +479,7 @@ class WalletViewModel : ViewModel() { val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false } try { requestId = - acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response -> + acc.zaps.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response -> timeoutJob.cancel() when (response) { is GetBalanceSuccessResponse -> { @@ -512,7 +512,7 @@ class WalletViewModel : ViewModel() { val walletUri = getWalletUri(walletId) ?: return viewModelScope.launch(Dispatchers.IO) { try { - acc.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response -> + acc.zaps.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response -> when (response) { is GetInfoSuccessResponse -> { _walletAlias.value = response.result?.alias @@ -538,7 +538,7 @@ class WalletViewModel : ViewModel() { val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false } try { requestId = - acc.sendNwcRequestToWallet( + acc.zaps.sendNwcRequestToWallet( walletUri, ListTransactionsMethod.create( limit = pageSize, @@ -591,7 +591,7 @@ class WalletViewModel : ViewModel() { val timeoutJob = launchTimeout({ requestId }) { _isLoadingMore.value = false } try { requestId = - acc.sendNwcRequestToWallet( + acc.zaps.sendNwcRequestToWallet( walletUri, ListTransactionsMethod.create( limit = pageSize, @@ -638,7 +638,7 @@ class WalletViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { _sendState.value = SendState.Sending try { - acc.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(bolt11)) { response -> + acc.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(bolt11)) { response -> when (response) { is PayInvoiceSuccessResponse -> { _sendState.value = SendState.Success(response.result?.preimage) @@ -676,7 +676,7 @@ class WalletViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { _receiveState.value = ReceiveState.Creating try { - acc.sendNwcRequestToWallet( + acc.zaps.sendNwcRequestToWallet( walletUri, MakeInvoiceMethod.create( amount = amountSats * 1000L, diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index 717c519036..f02f086b07 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -1467,7 +1467,7 @@ class AmethystAppFunctions { } val result = - account.sendOnchainZap( + account.zaps.sendOnchainZap( recipientPubKey = recipientPub, amountSats = sats, feeRateSatPerVByte = feeRateSatPerVByte, @@ -1751,7 +1751,7 @@ class AmethystAppFunctions { val deferred = CompletableDeferred() // sendZapPaymentRequestFor fires onResponse exactly once when the wallet replies // (success, error, or NwcError). On timeout we discard the late response. - account.sendZapPaymentRequestFor(bolt11, zappedNote) { response -> + account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote) { response -> if (!deferred.isCompleted) deferred.complete(response) } val response = From dc5e4562fdbcd0c57046bc1353e518d8335c761d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:22:31 +0000 Subject: [PATCH 070/227] fix: restore two Marmot log messages mangled during extraction The account-qualification regex in the AccountMarmotActions extraction also rewrote 'marmotManager is NULL' to 'account.marmotManager is NULL' inside two log string literals, changing log output text. Restore the original wording. Found by the post-refactor equivalence audit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../com/vitorpamplona/amethyst/model/AccountMarmotActions.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index 43029dfbc0..e45fd03764 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -278,7 +278,7 @@ class AccountMarmotActions( suspend fun publishMarmotKeyPackages() { val manager = account.marmotManager ?: run { - Log.w("MarmotDbg") { "publishMarmotKeyPackages: account.marmotManager is NULL — no-op" } + Log.w("MarmotDbg") { "publishMarmotKeyPackages: marmotManager is NULL — no-op" } return } if (!account.isWriteable()) { @@ -475,7 +475,7 @@ class AccountMarmotActions( } val manager = account.marmotManager ?: run { - Log.w("MarmotDbg") { "removeMarmotGroupMember: account.marmotManager is NULL — no-op" } + Log.w("MarmotDbg") { "removeMarmotGroupMember: marmotManager is NULL — no-op" } return } if (!account.isWriteable()) { From 92ca11a583f65d6762150abe25de19eaec63f6ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:27:56 +0000 Subject: [PATCH 071/227] chore: move stray NIP-29 section comment to AccountRelayGroupActions The relay-group section header and joinRelayGroup KDoc were left dangling at the end of AccountConcordActions when the clusters were split into separate files; reattach them to the function they describe. Found by the post-refactor audit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../vitorpamplona/amethyst/model/AccountConcordActions.kt | 7 ------- .../amethyst/model/AccountRelayGroupActions.kt | 5 +++++ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index 4b65c4848f..6fc115f11d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -1055,11 +1055,4 @@ class AccountConcordActions( account.client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ } Log.d("Concord", "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)") } - - // ── NIP-29 relay-group actions ─────────────────────────────────────────── - // All group commands are published ONLY to the group's host relay, where - // relay29 authorizes them. The relay is the source of truth; the kind-10009 - // list is our own cross-device bookkeeping of what we joined. - - /** Send a kind 9021 join request to the group's host relay and remember it. */ } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt index 7816501a0b..d8be5a68c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt @@ -82,6 +82,11 @@ import kotlinx.serialization.json.Json class AccountRelayGroupActions( private val account: Account, ) { + // All group commands are published ONLY to the group's host relay, where + // relay29 authorizes them. The relay is the source of truth; the kind-10009 + // list is our own cross-device bookkeeping of what we joined. + + /** Send a kind 9021 join request to the group's host relay and remember it. */ suspend fun joinRelayGroup( channel: RelayGroupChannel, code: String? = null, From 0cc3f72f55c15a9a07f88a8f78bc02a8348d84e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 18:03:03 +0000 Subject: [PATCH 072/227] fix: resolve always-true is-check and redundant cast warnings in ExplainedFilterTest Declaring 'advanced' as the base Filter type keeps the copy() regression guard as a genuine runtime assertion instead of a compile-time triviality, which is what the compiler was warning about. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012KG9YkeFp6zyth5J364DLF --- .../commons/relayClient/subscriptions/ExplainedFilterTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt index a983b300e2..9e7f37a289 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt @@ -108,7 +108,9 @@ class ExplainedFilterTest { */ @Test fun `copy preserves the purpose`() { - val advanced = explained().copy(since = 1_785_379_272) + // Typed as the base Filter so the is-check below stays a runtime assertion — with the + // override's covariant return type inferred, the compiler would prove it true statically. + val advanced: Filter = explained().copy(since = 1_785_379_272) assertTrue("copy() must stay an ExplainedFilter", advanced is ExplainedFilter) assertEquals(SubPurpose.NOTIFICATIONS, advanced.purposeOrNull()) From 6cd91bb05881db3ca7f897bbc03fb997686a0cfa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 00:43:27 +0000 Subject: [PATCH 073/227] fix: request follower-count provider's 30382 cards in UserCardsSubAssembler updateFilter only added the rank provider to the trusted-author set, so when the follower-count provider differed (different pubkey and/or relay), its kind:30382 cards were never requested from the relay. followerCountStrFlow then filtered for a signer whose cards never arrived and rendered "--" forever. Add liveUserFollowerCount (with its relayUrl) into the same mapOfSet block, symmetrically with liveUserRankProvider. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G7AA1AxqA6StnPVdjGDcwv --- .../reqCommand/user/watchers/UserCardsSubAssembler.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt index a833114d6b..0404db46cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -84,9 +84,14 @@ class UserCardsSubAssembler( add(it, account.userProfile().pubkeyHex) } } - accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { account -> - if (account != null) { - add(account.relayUrl, account.pubkey) + accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { provider -> + if (provider != null) { + add(provider.relayUrl, provider.pubkey) + } + } + accounts.map { it.trustProviderList.liveUserFollowerCount.value }.forEach { provider -> + if (provider != null) { + add(provider.relayUrl, provider.pubkey) } } } From a76a1911ea16605182aea6e0998a8fe05bbc71aa Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 2 Aug 2026 10:14:07 -0400 Subject: [PATCH 074/227] Stop COUNT from inheriting the default page size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relay holding 12,289,614 profiles answers ["COUNT","c1",{"kinds":[0]}] -> {"count":500} LimitsPolicy ran the same clampLimits over CountCmd as over ReqCmd, so an unbounded COUNT was given RelayLimits.defaultLimit and the store then counted at most that many. defaultLimit answers "how many events should a REQ return when the client names none". A COUNT returns no events, so the question has no meaning for it, and applying the answer anyway turns every unbounded COUNT into min(matches, defaultLimit). The failure is quiet, which is what let it survive: 500 is a plausible number, and the kinds under the default answered correctly. On the relay that surfaced it, kinds 1 and 10040 were right while 0, 10002 and 30382 were all exactly 500. maxLimit still applies — a client asking to count at most N is asking something a relay may bound. Only the invented default is dropped. Co-Authored-By: Claude Opus 5 (1M context) --- .../relay/server/policies/LimitsPolicy.kt | 28 ++++++++++++++- .../nip01Core/relay/server/RelayLimitsTest.kt | 34 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt index e711734d5b..3aaa3e3f32 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt @@ -72,9 +72,13 @@ class LimitsPolicy( return PolicyResult.Accepted(if (clamped === cmd.filters) cmd else ReqCmd(cmd.subId, clamped)) } + /** + * A COUNT is clamped by [RelayLimits.maxLimit] but NEVER given + * [RelayLimits.defaultLimit] — see [capLimits]. + */ override fun accept(cmd: CountCmd): PolicyResult { subscriptionRejection(cmd.queryId, cmd.filters)?.let { return PolicyResult.Rejected(it) } - val clamped = clampLimits(cmd.filters) + val clamped = capLimits(cmd.filters) return PolicyResult.Accepted(if (clamped === cmd.filters) cmd else CountCmd(cmd.queryId, clamped)) } @@ -106,6 +110,28 @@ class LimitsPolicy( return filters.map { it.copy(limit = targetLimit(it.limit)) } } + /** + * Cap what a COUNT asks for, without inventing a page size for it. + * + * `defaultLimit` answers "how many events should a REQ return when the + * client names no limit". A COUNT returns no events, so that question has + * no meaning for it — and applying the answer anyway turns every unbounded + * COUNT into `min(matches, defaultLimit)`. + * + * Silently: a relay holding 12,289,614 profiles replied `{"count":500}`, + * which is a plausible-looking number, so a client cannot tell it from the + * truth. The kinds that happened to fall under the default were correct, + * which is what made it survive. + * + * `maxLimit` still applies, because a client that explicitly asks to count + * at most N is asking a question this relay may bound. + */ + private fun capLimits(filters: List): List { + val max = limits.maxLimit ?: return filters + if (filters.none { it.limit != null && it.limit!! > max }) return filters + return filters.map { if (it.limit != null && it.limit!! > max) it.copy(limit = max) else it } + } + private fun targetLimit(current: Int?): Int? = when { current != null && limits.maxLimit != null && current > limits.maxLimit -> limits.maxLimit diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsTest.kt index 4fa12f80d7..55f6aa7139 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsTest.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class RelayLimitsTest { @@ -157,6 +158,39 @@ class RelayLimitsTest { ) } + @Test + fun countNeverGetsTheDefaultLimit() { + // A COUNT returns no events, so "how many should a REQ return by + // default" is not a question it asked. Applying the answer anyway turns + // every unbounded COUNT into min(matches, defaultLimit) — a relay + // holding 12,289,614 profiles replied {"count":500}, which is plausible + // enough that no client can tell it from the truth. + val policy = LimitsPolicy(RelayLimits(defaultLimit = 50, maxLimit = 100)) + + val result = policy.accept(CountCmd("q", listOf(Filter(kinds = listOf(1))))) as PolicyResult.Accepted + + assertNull( + result.cmd.filters + .single() + .limit, + ) + } + + @Test + fun countStillHonoursAnExplicitMaxLimit() { + // Asking to count at most N is a question the relay may bound. + val policy = LimitsPolicy(RelayLimits(defaultLimit = 50, maxLimit = 100)) + + val result = policy.accept(CountCmd("q", listOf(Filter(kinds = listOf(1), limit = 900)))) as PolicyResult.Accepted + + assertEquals( + 100, + result.cmd.filters + .single() + .limit, + ) + } + @Test fun leavesAcceptableRequestUnchanged() { val policy = LimitsPolicy(RelayLimits(maxLimit = 100)) From 431ad153bc22a3333cd9083e8c5224de6e6e0377 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 02:35:10 +0000 Subject: [PATCH 075/227] fix: normalize accessory timeouts to idle windows (time since last relay message) fetchAllPages waited for each page's EOSE under a hard wall-clock withTimeoutOrNull, unlike fetchAll / fetchAllWithHooks / the negentropy sync, whose timeouts are idle windows reset by every arriving message. A slow-but-streaming page could be silently truncated, and a relay slower than timeoutMs to first byte was mistaken for a drained set. - Extract IdleClock + receiveWithinIdle out of NostrClientNegentropySyncExt into a shared internal IdleWatchdog.kt and document the package-wide convention in the accessories README. - fetchAllPages (+ pool/hooks variants): the per-page timeout is now an idle window bumped by every event; a maxPageMs ceiling (10x, matching fetchAllWithHooks' maxTotalMs) backstops a never-EOSE trickle. A non-positive ceiling means uncapped, mirroring the idle <= 0 convention. - fetchFirst: the wait restarts on every arriving signal instead of one hard deadline across all relays; maxTotalMs ceiling added. - count (multi-relay): each arriving COUNT result restarts the window; maxTotalMs ceiling added. Single-relay count is trivially idle already. - NegentropyStoreSync page fallback clamps a disabled watchdog (0) to DEFAULT_DOWNLOAD_IDLE_MS like fetchByIds, instead of paging unbounded. - New NostrClientFetchAllPagesIdleTimeoutTest pins the semantics (verified to fail against the old hard-deadline wait). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb --- .../relay/client/accessories/IdleWatchdog.kt | 80 ++++++++++ .../client/accessories/NegentropyStoreSync.kt | 6 +- .../client/accessories/NostrClientCountExt.kt | 28 +++- .../NostrClientFetchAllPagesExt.kt | 31 +++- .../NostrClientFetchAllPagesPoolExt.kt | 7 +- .../NostrClientFetchAllWithHooksExt.kt | 2 + .../accessories/NostrClientFetchFirstExt.kt | 56 ++++--- .../NostrClientNegentropySyncExt.kt | 54 +------ .../relay/client/accessories/README.md | 14 +- ...NostrClientFetchAllPagesIdleTimeoutTest.kt | 145 ++++++++++++++++++ 10 files changed, 343 insertions(+), 80 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/IdleWatchdog.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/IdleWatchdog.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/IdleWatchdog.kt new file mode 100644 index 0000000000..c1be16acdb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/IdleWatchdog.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.concurrent.Volatile +import kotlin.time.TimeSource + +/** + * Monotonic "last activity" marker for the idle watchdogs shared by the accessory + * fetch/sync loops. [bump] on every sign of life from the relay; [elapsedMs] reports + * the silence since the last bump (or since construction, before the first bump). + * + * This is the timeout convention for every accessory in this package: a `timeoutMs` + * (or `idleTimeoutMs`) is an **idle window measured from the relay's most recent + * message**, not a wall-clock deadline — an actively streaming relay is never cut + * off mid-delivery, only one that goes silent. + * + * [bump] is on the per-event hot path (a connection listener may bump for every + * message the relay sends — millions during a large download), so it must not + * allocate: a single [start] mark is taken once (unboxed field) and each bump only + * writes a `Long` of nanos-since-start into a `@Volatile` field. Reader threads + * write, the driver coroutine reads — visibility is all we need, so a plain volatile + * Long beats boxing a `ValueTimeMark` into an `AtomicReference` on every event. + */ +internal class IdleClock { + private val start = TimeSource.Monotonic.markNow() + + @Volatile + private var lastNanos = 0L + + fun bump() { + lastNanos = start.elapsedNow().inWholeNanoseconds + } + + fun elapsedMs(): Long = (start.elapsedNow().inWholeNanoseconds - lastNanos) / 1_000_000 +} + +/** + * Receives the next item, giving up (returning `null`) only after [idleMs] elapse with + * no activity on [clock]. Because [clock] can be bumped by *any* relay message — not + * just items on this channel — unrelated progress (e.g. download events arriving during + * a reconcile wait) keeps pushing the deadline out. [idleMs] `<= 0` disables the + * watchdog: it waits until an item arrives (a disconnect is delivered as an item, so + * a dead socket still unblocks it). + */ +internal suspend fun Channel.receiveWithinIdle( + clock: IdleClock, + idleMs: Long, +): T? { + if (idleMs <= 0) return receive() + while (true) { + val remaining = idleMs - clock.elapsedMs() + if (remaining <= 0) return null + val item = withTimeoutOrNull(remaining) { receive() } + if (item != null) return item + // Timed out with nothing on this channel. If other activity bumped the clock + // meanwhile, the next `remaining` is positive and we wait again; otherwise it + // is <= 0 on the next iteration and we give up. + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyStoreSync.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyStoreSync.kt index 540411a522..a0272abecc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyStoreSync.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyStoreSync.kt @@ -298,7 +298,11 @@ class NegentropyStoreSync( } } try { - client.fetchAllPages(relay, listOf(filter), config.idleTimeoutMs) { event -> events.trySend(event) } + // Like fetchByIds, a download keeps a finite idle bound even when the + // whole-sync watchdog is disabled (idleTimeoutMs = 0) — a page that + // never EOSEs must not hang the sync forever. + val pageIdleMs = if (config.idleTimeoutMs > 0) config.idleTimeoutMs else DEFAULT_DOWNLOAD_IDLE_MS + client.fetchAllPages(relay, listOf(filter), pageIdleMs) { event -> events.trySend(event) } } finally { events.close() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt index 9222ba74d2..4fced89a6e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt @@ -38,9 +38,13 @@ import kotlinx.coroutines.withTimeoutOrNull * Sends a NIP-45 COUNT query to a single relay and suspends until * the result arrives or the timeout expires. * + * A COUNT exchange is a single response message, so [timeoutMs] here is + * trivially the package-wide idle-window convention (time since the most + * recent message): no message can arrive before the one that completes it. + * * @param relay Target relay to query. * @param filter The filter to count against. - * @param timeoutMs How long to wait for a response (default 15 s). + * @param timeoutMs How long to wait for the response (default 15 s). * @return The [CountResult], or `null` on timeout. */ suspend fun INostrClient.count( @@ -88,13 +92,22 @@ suspend fun INostrClient.count( * (one filter per relay) and suspends until all results arrive * or the timeout expires. * + * [timeoutMs] is an **idle window measured from the most recent message**, not a + * wall-clock deadline for the whole batch — the package-wide accessory + * convention: each arriving COUNT result restarts it, so a large fan-out where + * results keep trickling in is never cut short; the wait only gives up after a + * full window with no relay answering. [maxTotalMs] (default 10x the idle + * window) is the wall-clock ceiling against a misbehaving relay re-sending + * results forever. + * * @param filters Map of relay -> filter to count. - * @param timeoutMs How long to wait for all responses (default 15 s). + * @param timeoutMs Idle window between responses (default 15 s). * @return Map of relay -> [CountResult] for every relay that responded in time. */ suspend fun INostrClient.count( filters: Map>, timeoutMs: Long = 15_000, + maxTotalMs: Long = timeoutMs * 10, ): Map { if (filters.isEmpty()) return emptyMap() @@ -125,10 +138,13 @@ suspend fun INostrClient.count( val results = mutableMapOf() - withTimeoutOrNull(timeoutMs) { + // Each receive is bounded by the idle window alone; every arriving result + // restarts it on the next loop iteration. The outer ceiling bounds the whole + // wait against a relay that keeps re-sending results. + withTimeoutOrNull(maxTotalMs) { while (results.size < filters.size) { - val (relay, result) = resultChannel.receive() - results[relay] = result + val next = withTimeoutOrNull(timeoutMs) { resultChannel.receive() } ?: break + results[next.first] = next.second } } @@ -152,7 +168,7 @@ suspend fun INostrClient.count( * * @param relays List of relays to query. * @param filter The filter to count against. - * @param timeoutMs How long to wait for all responses (default 15 s). + * @param timeoutMs Idle window between responses (default 15 s) — see [count]. * @return A merged [CountResult], or `null` if no relay responded. */ suspend fun INostrClient.countMerged( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index 18a5502a6c..2a940c342e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -72,7 +72,13 @@ import kotlin.coroutines.coroutineContext * * @param relay The relay to query. * @param filters Filters to apply on every page (the `until` field is overwritten per page). - * @param timeoutMs Maximum time to wait for a single page's EOSE before giving up. + * @param timeoutMs Idle window per page — like every accessory timeout, it is measured + * from the relay's **most recent message**, not from the page's start: every arriving + * event resets it, so a slow relay actively streaming a large page is never cropped + * mid-delivery. A page only gives up after this much silence without an EOSE. + * @param maxPageMs Hard wall-clock ceiling per page (default 10x the idle window) — the + * idle window alone is unbounded against a misbehaving relay that trickles events + * forever without ever sending EOSE. Pass [Long.MAX_VALUE] for an uncapped page. * @param onEvent Called once for every distinct event delivered, in page order. * @return Total number of distinct events delivered across all pages. */ @@ -80,6 +86,7 @@ suspend fun INostrClient.fetchAllPages( relay: NormalizedRelayUrl, filters: List, timeoutMs: Long = 30_000L, + maxPageMs: Long = timeoutMs * 10, onNewPage: ((Long) -> Unit)? = null, onEvent: (Event) -> Unit, ): Int { @@ -144,6 +151,11 @@ suspend fun INostrClient.fetchAllPages( val doneChannel = Channel(Channel.CONFLATED) + // Idle watchdog for this page: every arriving event bumps it, so the page's + // timeout measures silence since the relay's most recent message (the same + // convention as fetchAll and the negentropy sync), never total page time. + val clock = IdleClock() + // Captured for the listener: the boundary second we re-fetch this page. val boundary = until var received = 0 @@ -160,6 +172,7 @@ suspend fun INostrClient.fetchAllPages( relay: NormalizedRelayUrl, forFilters: List?, ) { + clock.bump() received++ // Drop a boundary-second event we already delivered on an // earlier page (the inclusive re-fetch returns it again). @@ -222,8 +235,18 @@ suspend fun INostrClient.fetchAllPages( subscribe(subId, mapOf(relay to activeFilters.map { it.value }), listener) - withTimeoutOrNull(timeoutMs) { - doneChannel.receive() + // Wait for the page's terminal signal (EOSE / CLOSED / cannot-connect), + // giving up only after [timeoutMs] of silence — the wait resets on every + // event — or at the [maxPageMs] wall-clock ceiling. A non-positive + // ceiling means uncapped, mirroring the idle window's `<= 0` = disabled + // convention (it also absorbs a `timeoutMs * 10` overflow from a caller + // passing an effectively-infinite idle window). + if (maxPageMs <= 0 || maxPageMs == Long.MAX_VALUE) { + doneChannel.receiveWithinIdle(clock, timeoutMs) + } else { + withTimeoutOrNull(maxPageMs) { + doneChannel.receiveWithinIdle(clock, timeoutMs) + } } unsubscribe(subId) @@ -277,6 +300,7 @@ suspend fun INostrClient.fetchAllPages( relay: String, filters: List, timeoutMs: Long = 30_000L, + maxPageMs: Long = timeoutMs * 10, onNewPage: ((Long) -> Unit)? = null, onEvent: (Event) -> Unit, ): Int = @@ -284,6 +308,7 @@ suspend fun INostrClient.fetchAllPages( relay = RelayUrlNormalizer.normalize(relay), filters = filters, timeoutMs = timeoutMs, + maxPageMs = maxPageMs, onNewPage = onNewPage, onEvent = onEvent, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt index c6d4ffeca2..ac669908a3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt @@ -50,7 +50,10 @@ import kotlinx.coroutines.sync.Semaphore * @param filters per-relay filter lists; the key set is the relays queried, in * iteration order (pass a [LinkedHashMap]/`associateWith` result to control it). * A `search` filter is fetched as a single relevance page — see [fetchAllPages]. - * @param timeoutMs per-page EOSE timeout handed to each relay's [fetchAllPages]. + * @param timeoutMs per-page idle window handed to each relay's [fetchAllPages] — + * measured from that relay's most recent message (every event resets it), not + * from the page's start. + * @param maxPageMs per-page wall-clock ceiling handed to each relay's [fetchAllPages]. * @param maxConcurrentRelays upper bound on relays paginating at once (≥ 1). * @param onNewPage optional `(until, relay)` tick before each non-first page. * @param onRelayStart optional hook fired as each relay's download begins. @@ -61,6 +64,7 @@ import kotlinx.coroutines.sync.Semaphore suspend fun INostrClient.fetchAllPagesFromPool( filters: Map>, timeoutMs: Long = 30_000L, + maxPageMs: Long = timeoutMs * 10, maxConcurrentRelays: Int = 8, onNewPage: ((until: Long, relay: NormalizedRelayUrl) -> Unit)? = null, onRelayStart: ((relay: NormalizedRelayUrl) -> Unit)? = null, @@ -81,6 +85,7 @@ suspend fun INostrClient.fetchAllPagesFromPool( relay = relay, filters = filtersForRelay, timeoutMs = timeoutMs, + maxPageMs = maxPageMs, onNewPage = onNewPage?.let { cb -> { until -> cb(until, relay) } }, ) { event -> onEvent(event, relay) } onRelayComplete?.invoke(relay, total) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt index 160bf90218..9473d24daf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt @@ -255,6 +255,7 @@ suspend fun INostrClient.fetchAllWithHooks( suspend fun INostrClient.fetchAllPagesFromPoolWithHooks( filters: Map>, timeoutMs: Long = 30_000L, + maxPageMs: Long = timeoutMs * 10, maxConcurrentRelays: Int = 8, onEvent: suspend (relay: NormalizedRelayUrl, event: Event) -> Boolean, ): List> { @@ -286,6 +287,7 @@ suspend fun INostrClient.fetchAllPagesFromPoolWithHooks( fetchAllPagesFromPool( filters = filters, timeoutMs = timeoutMs, + maxPageMs = maxPageMs, maxConcurrentRelays = maxConcurrentRelays, ) { event, relay -> eventChannel.trySend(relay to event) } } finally { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt index 20e686de6a..75a03704c2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt @@ -64,10 +64,23 @@ suspend fun INostrClient.fetchFirst( filters: List, ) = fetchFirst(subscriptionId, mapOf(relay to filters)) +/** + * Subscribe [filters], return the first event any relay delivers (or `null` when + * every relay reached a terminal state — EOSE, CLOSED, or cannot-connect — with + * nothing matching, or the line went quiet). + * + * [timeoutMs] is an **idle window measured from the most recent message**, not a + * wall-clock deadline — the package-wide accessory convention: every arriving + * signal (a terminal state from one relay of many) restarts it, so the fetch only + * gives up after a full window of total silence. [maxTotalMs] (default 10x the + * idle window) is the wall-clock ceiling that bounds a relay emitting endless + * terminal chatter (e.g. a CLOSED/reconnect loop) without ever delivering an event. + */ suspend fun INostrClient.fetchFirst( subscriptionId: String = newSubId(), filters: Map>, timeoutMs: Long = 30_000L, + maxTotalMs: Long = timeoutMs * 10, ): Event? { val eventChannel = Channel(UNLIMITED) val doneChannel = Channel(UNLIMITED) @@ -112,27 +125,34 @@ suspend fun INostrClient.fetchFirst( try { subscribe(subscriptionId, filters, listener) - withTimeoutOrNull(timeoutMs) { + // Each wait is bounded by the idle window alone; any arriving signal + // restarts it on the next loop iteration. The outer ceiling stays far + // above the window so legitimate multi-relay stragglers still land. + withTimeoutOrNull(maxTotalMs) { while (remaining.isNotEmpty()) { - select { - eventChannel.onReceive { event -> - result = event - remaining.clear() - } - doneChannel.onReceive { relay -> - // A relay sends its matching events before its EOSE, so an event may - // already be buffered when this completion fires. select() picks a ready - // clause at random, so without this drain we could treat the relay as done - // and exit while its event still sits unread in the channel. - val buffered = eventChannel.tryReceive().getOrNull() - if (buffered != null) { - result = buffered - remaining.clear() - } else { - remaining.remove(relay) + val progressed = + withTimeoutOrNull(timeoutMs) { + select { + eventChannel.onReceive { event -> + result = event + remaining.clear() + } + doneChannel.onReceive { relay -> + // A relay sends its matching events before its EOSE, so an event may + // already be buffered when this completion fires. select() picks a ready + // clause at random, so without this drain we could treat the relay as done + // and exit while its event still sits unread in the channel. + val buffered = eventChannel.tryReceive().getOrNull() + if (buffered != null) { + result = buffered + remaining.clear() + } else { + remaining.remove(relay) + } + } } } - } + if (progressed == null) break } } } finally { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt index 0e93bcef1f..19afb00934 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt @@ -47,14 +47,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull -import kotlin.concurrent.Volatile import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.decrementAndFetch import kotlin.concurrent.atomics.incrementAndFetch import kotlin.coroutines.coroutineContext import kotlin.math.min -import kotlin.time.TimeSource /** * Outcome of a successful [negentropySync] run. @@ -1180,52 +1178,8 @@ internal val KEEP_ALIVE_ID = "f".repeat(64) * still honor "run until the socket drops". */ private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000L -private const val DEFAULT_DOWNLOAD_IDLE_MS = 60_000L +internal const val DEFAULT_DOWNLOAD_IDLE_MS = 60_000L -/** - * Monotonic "last activity" marker for the idle watchdog. [bump] on every sign of - * life from the relay; [elapsedMs] reports the silence since the last bump. - * - * [bump] is on the per-event hot path (the connection listener bumps for every - * message the relay sends — millions during a large download), so it must not - * allocate: a single [start] mark is taken once (unboxed field) and each bump only - * writes a `Long` of nanos-since-start into a `@Volatile` field. Reader threads - * write, the driver coroutine reads — visibility is all we need, so a plain volatile - * Long beats boxing a `ValueTimeMark` into an `AtomicReference` on every event. - */ -private class IdleClock { - private val start = TimeSource.Monotonic.markNow() - - @Volatile - private var lastNanos = 0L - - fun bump() { - lastNanos = start.elapsedNow().inWholeNanoseconds - } - - fun elapsedMs(): Long = (start.elapsedNow().inWholeNanoseconds - lastNanos) / 1_000_000 -} - -/** - * Receives the next item, giving up (returning `null`) only after [idleMs] elapse with - * no activity on [clock]. Because [clock] is bumped by *any* relay message — not just - * items on this channel — unrelated progress (e.g. download events arriving during a - * reconcile wait) keeps pushing the deadline out. [idleMs] `<= 0` disables the - * watchdog: it waits until an item arrives (a disconnect is delivered as an item, so - * a dead socket still unblocks it). - */ -private suspend fun Channel.receiveWithinIdle( - clock: IdleClock, - idleMs: Long, -): T? { - if (idleMs <= 0) return receive() - while (true) { - val remaining = idleMs - clock.elapsedMs() - if (remaining <= 0) return null - val item = withTimeoutOrNull(remaining) { receive() } - if (item != null) return item - // Timed out with nothing on this channel. If other activity bumped the clock - // meanwhile, the next `remaining` is positive and we wait again; otherwise it - // is <= 0 on the next iteration and we give up. - } -} +// IdleClock and receiveWithinIdle — the idle-watchdog primitives this file +// introduced — now live in IdleWatchdog.kt, shared by every accessory whose +// timeout is measured from the relay's most recent message. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md index a7adfef4a9..5d2034bc88 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md @@ -12,11 +12,23 @@ count, negentropy sync/reconcile) already exists. Import as `com.vitorpamplona.quartz.nip01Core.relay.client.accessories.` (or `...client.reqs.` for the flow/subscribe helpers). +## Timeout convention + +Every `timeoutMs` / `idleTimeoutMs` in this package is an **idle window measured +from the relay's most recent message**, never a wall-clock deadline: each arriving +event / result / terminal signal resets it, so an actively streaming relay is never +cut off mid-delivery — the operation only gives up after a full window of silence. +The shared primitives are in `IdleWatchdog.kt` (`IdleClock` + `receiveWithinIdle`); +use them when writing a new accessory. Because an idle window alone is unbounded +against a relay that trickles messages forever, the loops that could run away also +take a hard wall-clock ceiling (`maxTotalMs` / `maxPageMs`, default 10x the idle +window) as a backstop. + ## One-shot reads (subscribe → collect → return) | Function | File | Use when | | --- | --- | --- | -| `fetchAll(relay, filter, timeoutMs)` | `NostrClientFetchAllExt` | Get every event matching a filter in one REQ, deduped by id, until EOSE or timeout. **No verify, no store** — just the events. | +| `fetchAll(relay, filter, timeoutMs)` | `NostrClientFetchAllExt` | Get every event matching a filter in one REQ, deduped by id, until EOSE or a full idle window of silence. **No verify, no store** — just the events. | | `fetchFirst(relay, filter, timeoutMs)` | `NostrClientFetchFirstExt` | Get the first matching event and stop (returns `null` on none/timeout). | | `fetchAllPages(relay, filters, timeoutMs)` | `NostrClientFetchAllPagesExt` | Fully retrieve a result set larger than the relay's per-REQ cap (strfry `limit`, ~500) by walking a `created_at` cursor. Bound it with the filter's `limit`. | | `fetchAllPagesFromPool(filters, ...)` | `NostrClientFetchAllPagesPoolExt` | Same paging, across several relays at once, deduped across them. | diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt new file mode 100644 index 0000000000..d67d44dbe6 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.TimeSource + +/** + * Pins [fetchAllPages]'s timeout to the package-wide idle-window convention: + * `timeoutMs` is silence measured from the relay's MOST RECENT message, not a + * wall-clock deadline for the page. A relay that keeps streaming — however + * slowly — must never have a page cropped mid-delivery. + * + * Real-clock ([runBlocking]) on purpose: the page watchdog is a monotonic + * `IdleClock` bumped from the socket reader thread, which virtual time can't + * exercise. + */ +class NostrClientFetchAllPagesIdleTimeoutTest { + /** Captures the subscription listener so the test can play a relay. */ + private class ScriptedClient : INostrClient by EmptyNostrClient() { + var listener: SubscriptionListener? = null + var subscribeCount = 0 + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + subscribeCount++ + this.listener = listener + } + } + + private val relay = RelayUrlNormalizer.normalize("wss://slow.example.com") + + private fun event(i: Int) = + Event( + id = i.toString(16).padStart(64, '0'), + pubKey = "f".repeat(64), + createdAt = i.toLong(), + kind = 1, + tags = emptyArray(), + content = "e$i", + sig = "0".repeat(128), + ) + + @Test + fun streamingPageOutlivesTheIdleWindow() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + // 6 events, each arriving 150ms apart — every gap is under the + // 500ms idle window, but the whole page (~950ms) far exceeds it. + // The old hard per-page deadline truncated this mid-stream and + // re-subscribed for another page; the idle window must not. + repeat(6) { i -> + delay(150) + client.listener!!.onEvent(event(i + 1), false, relay, null) + } + delay(50) + client.listener!!.onEose(relay, null) + } + + var pages = 0 + val got = mutableListOf() + val total = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1), limit = 6)), + timeoutMs = 500, + onNewPage = { pages++ }, + ) { got.add(it) } + feeder.join() + + assertEquals(6, total, "a slowly-but-actively streaming page must never be cropped") + assertEquals(6, got.size) + assertEquals(1, client.subscribeCount, "the whole stream must arrive in ONE page — a hard deadline would truncate and re-subscribe") + assertEquals(0, pages, "no pagination should be needed") + } + + @Test + fun silentRelayGivesUpOneIdleWindowAfterItsLastMessage() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + // Two quick events, then the relay goes quiet without EOSE: the + // page must end ~one idle window after the LAST message. + delay(50) + client.listener!!.onEvent(event(1), false, relay, null) + delay(50) + client.listener!!.onEvent(event(2), false, relay, null) + } + + val start = TimeSource.Monotonic.markNow() + val got = mutableListOf() + // limit = 3 keeps the filter unfulfilled, so only the stall can end the + // page; the events already delivered are kept and, since nothing older + // followed, the next page comes back empty and the walk terminates. + val total = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1), limit = 3)), + timeoutMs = 300, + ) { got.add(it) } + feeder.join() + val elapsedMs = start.elapsedNow().inWholeMilliseconds + + assertEquals(2, total, "events delivered before the stall are kept") + assertTrue(elapsedMs >= 300, "must wait out at least one idle window, took ${elapsedMs}ms") + assertTrue(elapsedMs < 5_000, "a stalled page must end promptly after the idle window, took ${elapsedMs}ms") + } +} From 53be8d1755ff6784491d70b1efcfc1cfdd6ec5ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 02:48:15 +0000 Subject: [PATCH 076/227] fix: harden accessory timeout ceilings and count() listener cleanup Audit follow-ups on the idle-window normalization: - maxTotalMs/maxPageMs defaults are timeoutMs * 10, which silently overflows to a negative Long for an effectively-infinite idle window (Long.MAX_VALUE * 10 wraps to -10). withTimeoutOrNull then expired immediately, inverting 'wait forever' into 'never wait'. All three ceilings (fetchAllPages, fetchFirst, fetchAllWithHooks) now treat a non-positive ceiling as uncapped, matching the idle window's <= 0 convention. Regression-tested via fetchFirst. - count(filters) leaked its RelayConnectionListener and left COUNT subs open if the caller cancelled mid-wait or a listener threw: the cleanup ran as straight-line code with no try/finally, unlike every sibling accessory. Wrapped so unsubscribe + removeConnectionListener + channel close always run. - New FetchFirstIdleTimeoutTest pins fetchFirst's idle-window semantics (signals restart the window, silence costs exactly one window, the ceiling stops endless terminal chatter, overflow means uncapped). - README: note publishAndConfirm's fixed window as the deliberate exception, and correct the fetchAllPagesFromPool row, which claimed cross-relay dedup the function explicitly does not do. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb --- .../client/accessories/NostrClientCountExt.kt | 46 ++--- .../NostrClientFetchAllPagesExt.kt | 7 +- .../NostrClientFetchAllWithHooksExt.kt | 6 +- .../accessories/NostrClientFetchFirstExt.kt | 7 +- .../NostrClientNegentropySyncExt.kt | 4 - .../relay/client/accessories/README.md | 6 +- .../accessories/FetchFirstIdleTimeoutTest.kt | 158 ++++++++++++++++++ 7 files changed, 198 insertions(+), 36 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchFirstIdleTimeoutTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt index 4fced89a6e..e9125e602b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt @@ -98,7 +98,8 @@ suspend fun INostrClient.count( * results keep trickling in is never cut short; the wait only gives up after a * full window with no relay answering. [maxTotalMs] (default 10x the idle * window) is the wall-clock ceiling against a misbehaving relay re-sending - * results forever. + * results forever; a non-positive value means uncapped (which also absorbs a + * `timeoutMs * 10` overflow from an effectively-infinite idle window). * * @param filters Map of relay -> filter to count. * @param timeoutMs Idle window between responses (default 15 s). @@ -128,29 +129,32 @@ suspend fun INostrClient.count( } } - addConnectionListener(listener) - - filters.forEach { (relay, filterList) -> - val subId = newSubId() - subIdToRelay[subId] = relay - count(subId = subId, filters = mapOf(relay to filterList)) - } - val results = mutableMapOf() - // Each receive is bounded by the idle window alone; every arriving result - // restarts it on the next loop iteration. The outer ceiling bounds the whole - // wait against a relay that keeps re-sending results. - withTimeoutOrNull(maxTotalMs) { - while (results.size < filters.size) { - val next = withTimeoutOrNull(timeoutMs) { resultChannel.receive() } ?: break - results[next.first] = next.second - } - } + try { + addConnectionListener(listener) - subIdToRelay.keys.forEach { unsubscribe(it) } - removeConnectionListener(listener) - resultChannel.close() + filters.forEach { (relay, filterList) -> + val subId = newSubId() + subIdToRelay[subId] = relay + count(subId = subId, filters = mapOf(relay to filterList)) + } + + // Each receive is bounded by the idle window alone; every arriving result + // restarts it on the next loop iteration. The outer ceiling bounds the whole + // wait against a relay that keeps re-sending results. + val ceiling = if (maxTotalMs <= 0) Long.MAX_VALUE else maxTotalMs + withTimeoutOrNull(ceiling) { + while (results.size < filters.size) { + val next = withTimeoutOrNull(timeoutMs) { resultChannel.receive() } ?: break + results[next.first] = next.second + } + } + } finally { + subIdToRelay.keys.forEach { unsubscribe(it) } + removeConnectionListener(listener) + resultChannel.close() + } return results } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index 2a940c342e..a1886854b5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -241,12 +241,9 @@ suspend fun INostrClient.fetchAllPages( // ceiling means uncapped, mirroring the idle window's `<= 0` = disabled // convention (it also absorbs a `timeoutMs * 10` overflow from a caller // passing an effectively-infinite idle window). - if (maxPageMs <= 0 || maxPageMs == Long.MAX_VALUE) { + val ceiling = if (maxPageMs <= 0) Long.MAX_VALUE else maxPageMs + withTimeoutOrNull(ceiling) { doneChannel.receiveWithinIdle(clock, timeoutMs) - } else { - withTimeoutOrNull(maxPageMs) { - doneChannel.receiveWithinIdle(clock, timeoutMs) - } } unsubscribe(subId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt index 9473d24daf..bc4bff4d44 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt @@ -86,7 +86,9 @@ suspend fun INostrClient.fetchAllWithHooks( * adversarial or misbehaving relay could pin the caller forever. The cap * restores an upper bound while staying far above the idle window, so a * legitimately streaming relay still finishes its backlog. Pass - * [Long.MAX_VALUE] for a deliberately uncapped drain. + * [Long.MAX_VALUE] for a deliberately uncapped drain; a non-positive value + * also uncaps (absorbing a `timeoutMs * 10` overflow from an + * effectively-infinite idle window). */ maxTotalMs: Long = timeoutMs * 10, onEvent: suspend (relay: NormalizedRelayUrl, event: Event) -> Boolean, @@ -144,7 +146,7 @@ suspend fun INostrClient.fetchAllWithHooks( coroutineScope { subscribe(subscriptionId, filters, listener) val watchdog = - if (maxTotalMs == Long.MAX_VALUE) { + if (maxTotalMs <= 0 || maxTotalMs == Long.MAX_VALUE) { null } else { launch { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt index 75a03704c2..fb25ae4672 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt @@ -74,7 +74,9 @@ suspend fun INostrClient.fetchFirst( * signal (a terminal state from one relay of many) restarts it, so the fetch only * gives up after a full window of total silence. [maxTotalMs] (default 10x the * idle window) is the wall-clock ceiling that bounds a relay emitting endless - * terminal chatter (e.g. a CLOSED/reconnect loop) without ever delivering an event. + * terminal chatter (e.g. a CLOSED/reconnect loop) without ever delivering an + * event; a non-positive value means uncapped (which also absorbs a + * `timeoutMs * 10` overflow from an effectively-infinite idle window). */ suspend fun INostrClient.fetchFirst( subscriptionId: String = newSubId(), @@ -128,7 +130,8 @@ suspend fun INostrClient.fetchFirst( // Each wait is bounded by the idle window alone; any arriving signal // restarts it on the next loop iteration. The outer ceiling stays far // above the window so legitimate multi-relay stragglers still land. - withTimeoutOrNull(maxTotalMs) { + val ceiling = if (maxTotalMs <= 0) Long.MAX_VALUE else maxTotalMs + withTimeoutOrNull(ceiling) { while (remaining.isNotEmpty()) { val progressed = withTimeoutOrNull(timeoutMs) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt index 19afb00934..89360facbf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt @@ -1179,7 +1179,3 @@ internal val KEEP_ALIVE_ID = "f".repeat(64) */ private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000L internal const val DEFAULT_DOWNLOAD_IDLE_MS = 60_000L - -// IdleClock and receiveWithinIdle — the idle-watchdog primitives this file -// introduced — now live in IdleWatchdog.kt, shared by every accessory whose -// timeout is measured from the relay's most recent message. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md index 5d2034bc88..246f83fe2d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md @@ -22,7 +22,9 @@ The shared primitives are in `IdleWatchdog.kt` (`IdleClock` + `receiveWithinIdle use them when writing a new accessory. Because an idle window alone is unbounded against a relay that trickles messages forever, the loops that could run away also take a hard wall-clock ceiling (`maxTotalMs` / `maxPageMs`, default 10x the idle -window) as a backstop. +window) as a backstop; a non-positive ceiling means uncapped. The one deliberate +exception is the write side: `publishAndConfirm`'s `timeoutInSeconds` is a fixed +window to collect the `OK`s — a bounded confirmation round-trip, not a stream. ## One-shot reads (subscribe → collect → return) @@ -31,7 +33,7 @@ window) as a backstop. | `fetchAll(relay, filter, timeoutMs)` | `NostrClientFetchAllExt` | Get every event matching a filter in one REQ, deduped by id, until EOSE or a full idle window of silence. **No verify, no store** — just the events. | | `fetchFirst(relay, filter, timeoutMs)` | `NostrClientFetchFirstExt` | Get the first matching event and stop (returns `null` on none/timeout). | | `fetchAllPages(relay, filters, timeoutMs)` | `NostrClientFetchAllPagesExt` | Fully retrieve a result set larger than the relay's per-REQ cap (strfry `limit`, ~500) by walking a `created_at` cursor. Bound it with the filter's `limit`. | -| `fetchAllPagesFromPool(filters, ...)` | `NostrClientFetchAllPagesPoolExt` | Same paging, across several relays at once, deduped across them. | +| `fetchAllPagesFromPool(filters, ...)` | `NostrClientFetchAllPagesPoolExt` | Same paging, across several relays at once. No cross-relay dedup — the `WithHooks` variant below dedups. | | `fetchAllWithHooks(filters, ...)` | `NostrClientFetchAllWithHooksExt` | `fetchAll` with a suspending per-`(relay, event)` accept hook (verify+store as events arrive), per-relay terminal-reason tracking, optional dead-relay collection (`deadOut` + `classifyDrainFailure`), keep-pending-on-`auth-required` CLOSED (NIP-42 re-fire), and a timeout diagnostic hook. | | `fetchAllPagesFromPoolWithHooks(filters, ...)` | `NostrClientFetchAllWithHooksExt` | `fetchAllPagesFromPool` with the same suspending accept hook, run single-threaded in one consumer; deduped across relays by `SeenIds` before the hook. | diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchFirstIdleTimeoutTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchFirstIdleTimeoutTest.kt new file mode 100644 index 0000000000..d918396017 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchFirstIdleTimeoutTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Pins [fetchFirst]'s timeout to the package-wide idle-window convention: + * [timeoutMs] is silence measured from the most recent relay signal, not an + * absolute deadline across the whole multi-relay wait — with [maxTotalMs] as + * the wall-clock ceiling. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class FetchFirstIdleTimeoutTest { + /** Captures the subscription listener so the test can play the relays. */ + private class ScriptedClient : INostrClient by EmptyNostrClient() { + var listener: SubscriptionListener? = null + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + this.listener = listener + } + } + + private val relayA = RelayUrlNormalizer.normalize("wss://a.example.com") + private val relayB = RelayUrlNormalizer.normalize("wss://b.example.com") + + private fun event(i: Int) = + Event( + id = i.toString(16).padStart(64, '0'), + pubKey = "f".repeat(64), + createdAt = i.toLong(), + kind = 1, + tags = emptyArray(), + content = "e$i", + sig = "0".repeat(128), + ) + + private fun filters(vararg relays: NormalizedRelayUrl) = relays.associateWith { listOf(Filter(kinds = listOf(1))) } + + @Test + fun arrivingSignalsRestartTheIdleWindow() = + runTest { + val client = ScriptedClient() + launch { + // Terminal chatter every 250ms keeps the 300ms window alive long + // enough for the slow relay's event at 900ms — an absolute + // deadline would have returned null at 300ms. + delay(250) + client.listener!!.onClosed("rate limited", relayA, null) + delay(250) + client.listener!!.onClosed("rate limited", relayA, null) + delay(250) + client.listener!!.onClosed("rate limited", relayA, null) + delay(150) + client.listener!!.onEvent(event(1), false, relayB, null) + } + val result = + client.fetchFirst( + filters = filters(relayA, relayB), + timeoutMs = 300, + ) + assertEquals(event(1).id, result?.id, "signals must restart the window; the slow relay's event still lands") + } + + @Test + fun totalSilenceReturnsNullAfterOneIdleWindow() = + runTest { + val client = ScriptedClient() + val start = currentTime + val result = + client.fetchFirst( + filters = filters(relayA), + timeoutMs = 300, + ) + assertNull(result) + assertEquals(300L, currentTime - start, "a silent relay costs exactly one idle window") + } + + @Test + fun wallClockCeilingStopsEndlessTerminalChatter() = + runTest { + val client = ScriptedClient() + val chatter = + launch { + // relayA re-CLOSEs forever (a reconnect loop); relayB never + // answers. Every signal restarts the window, so only the + // ceiling can end the wait. + while (true) { + delay(200) + client.listener!!.onClosed("auth-required: again", relayA, null) + } + } + val start = currentTime + val result = + client.fetchFirst( + filters = filters(relayA, relayB), + timeoutMs = 300, + maxTotalMs = 1_000, + ) + chatter.cancel() + assertNull(result) + assertEquals(1_000L, currentTime - start, "the ceiling must end an endlessly-restarted wait") + } + + @Test + fun effectivelyInfiniteIdleWindowDoesNotOverflowTheCeiling() = + runTest { + val client = ScriptedClient() + launch { + delay(100) + client.listener!!.onEvent(event(1), false, relayA, null) + } + // Long.MAX_VALUE * 10 wraps to -10; the default ceiling must + // degrade to "uncapped", not to an instantly-expired wait. + val result = + client.fetchFirst( + filters = filters(relayA), + timeoutMs = Long.MAX_VALUE, + ) + assertEquals(event(1).id, result?.id, "an overflowed default ceiling must mean uncapped, not instant timeout") + } +} From ac6034f764a831f47cd71228cf249b4318ae7276 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 03:13:53 +0000 Subject: [PATCH 077/227] refactor: drop fetchAllPages' maxPageMs ceiling The per-page wall-clock ceiling added alongside the idle-window change did not do what it claimed. fetchAllPages waits inside a while(true): when a page's wait ends, the loop advances the until cursor and issues the next REQ rather than returning, so a ceiling bounds one page, never the call. Measured against a relay trickling events forever with an unbounded filter, maxPageMs=400 produced 8 REQs and no return -- the walk ran until the caller cancelled, exactly as it would with no ceiling at all. It also made truncation unsafe. Cutting a page mid-stream advances until to the oldest event received so far, which only preserves the set if the relay streams strictly newest-first -- NIP-01 recommends that but does not require it -- so a ceiling firing on an out-of-order relay can skip the not-yet-sent events above that cursor. That is the same class of silent gap the idle window was introduced to close. What actually bounds a paged download is the filter's limit (already the documented way) or cancelling the caller, which the ensureActive() at the top of each page honors. Removed from fetchAllPages, fetchAllPagesFromPool and fetchAllPagesFromPoolWithHooks; a regression test pins the real contract so nobody re-adds a ceiling believing it caps the walk. maxTotalMs stays on fetchAll/fetchAllWithHooks, fetchFirst and multi-relay count: those wait in a single loop and return, so there the ceiling genuinely ends the call (each is covered by a test asserting it does). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb --- .../NostrClientFetchAllPagesExt.kt | 28 ++++---- .../NostrClientFetchAllPagesPoolExt.kt | 6 +- .../NostrClientFetchAllWithHooksExt.kt | 2 - .../relay/client/accessories/README.md | 23 +++++-- ...NostrClientFetchAllPagesIdleTimeoutTest.kt | 66 ++++++++++++++++--- 5 files changed, 89 insertions(+), 36 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index a1886854b5..52aa48affd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -30,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.withTimeoutOrNull import kotlin.coroutines.coroutineContext /** @@ -76,9 +75,17 @@ import kotlin.coroutines.coroutineContext * from the relay's **most recent message**, not from the page's start: every arriving * event resets it, so a slow relay actively streaming a large page is never cropped * mid-delivery. A page only gives up after this much silence without an EOSE. - * @param maxPageMs Hard wall-clock ceiling per page (default 10x the idle window) — the - * idle window alone is unbounded against a misbehaving relay that trickles events - * forever without ever sending EOSE. Pass [Long.MAX_VALUE] for an uncapped page. + * + * Deliberately no wall-clock ceiling here, unlike [fetchAll]'s `maxTotalMs`. A ceiling + * would bound one *page*, not this call: the loop below reacts to a page ending by + * advancing the cursor and issuing the next REQ, so a relay trickling events forever + * against an unbounded filter would just be re-paged forever — measurably so (see + * NostrClientFetchAllPagesIdleTimeoutTest). Worse, cutting a page mid-stream advances + * `until` to the oldest event received *so far*, which only preserves the set if the + * relay streams strictly newest-first (NIP-01 recommends but does not require it) — + * otherwise the not-yet-sent events above that cursor are skipped. What actually + * bounds this walk is a [Filter.limit] (the documented way to cap a download) or + * cancelling the caller, which the [ensureActive] at the top of each page honors. * @param onEvent Called once for every distinct event delivered, in page order. * @return Total number of distinct events delivered across all pages. */ @@ -86,7 +93,6 @@ suspend fun INostrClient.fetchAllPages( relay: NormalizedRelayUrl, filters: List, timeoutMs: Long = 30_000L, - maxPageMs: Long = timeoutMs * 10, onNewPage: ((Long) -> Unit)? = null, onEvent: (Event) -> Unit, ): Int { @@ -237,14 +243,8 @@ suspend fun INostrClient.fetchAllPages( // Wait for the page's terminal signal (EOSE / CLOSED / cannot-connect), // giving up only after [timeoutMs] of silence — the wait resets on every - // event — or at the [maxPageMs] wall-clock ceiling. A non-positive - // ceiling means uncapped, mirroring the idle window's `<= 0` = disabled - // convention (it also absorbs a `timeoutMs * 10` overflow from a caller - // passing an effectively-infinite idle window). - val ceiling = if (maxPageMs <= 0) Long.MAX_VALUE else maxPageMs - withTimeoutOrNull(ceiling) { - doneChannel.receiveWithinIdle(clock, timeoutMs) - } + // arriving event, so an actively streaming page is never cut mid-delivery. + doneChannel.receiveWithinIdle(clock, timeoutMs) unsubscribe(subId) doneChannel.close() @@ -297,7 +297,6 @@ suspend fun INostrClient.fetchAllPages( relay: String, filters: List, timeoutMs: Long = 30_000L, - maxPageMs: Long = timeoutMs * 10, onNewPage: ((Long) -> Unit)? = null, onEvent: (Event) -> Unit, ): Int = @@ -305,7 +304,6 @@ suspend fun INostrClient.fetchAllPages( relay = RelayUrlNormalizer.normalize(relay), filters = filters, timeoutMs = timeoutMs, - maxPageMs = maxPageMs, onNewPage = onNewPage, onEvent = onEvent, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt index ac669908a3..be860889f7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt @@ -52,8 +52,8 @@ import kotlinx.coroutines.sync.Semaphore * A `search` filter is fetched as a single relevance page — see [fetchAllPages]. * @param timeoutMs per-page idle window handed to each relay's [fetchAllPages] — * measured from that relay's most recent message (every event resets it), not - * from the page's start. - * @param maxPageMs per-page wall-clock ceiling handed to each relay's [fetchAllPages]. + * from the page's start. As in [fetchAllPages] there is no wall-clock ceiling; + * bound a relay's walk with a [Filter.limit], or cancel the caller. * @param maxConcurrentRelays upper bound on relays paginating at once (≥ 1). * @param onNewPage optional `(until, relay)` tick before each non-first page. * @param onRelayStart optional hook fired as each relay's download begins. @@ -64,7 +64,6 @@ import kotlinx.coroutines.sync.Semaphore suspend fun INostrClient.fetchAllPagesFromPool( filters: Map>, timeoutMs: Long = 30_000L, - maxPageMs: Long = timeoutMs * 10, maxConcurrentRelays: Int = 8, onNewPage: ((until: Long, relay: NormalizedRelayUrl) -> Unit)? = null, onRelayStart: ((relay: NormalizedRelayUrl) -> Unit)? = null, @@ -85,7 +84,6 @@ suspend fun INostrClient.fetchAllPagesFromPool( relay = relay, filters = filtersForRelay, timeoutMs = timeoutMs, - maxPageMs = maxPageMs, onNewPage = onNewPage?.let { cb -> { until -> cb(until, relay) } }, ) { event -> onEvent(event, relay) } onRelayComplete?.invoke(relay, total) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt index bc4bff4d44..978eec3b05 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt @@ -257,7 +257,6 @@ suspend fun INostrClient.fetchAllWithHooks( suspend fun INostrClient.fetchAllPagesFromPoolWithHooks( filters: Map>, timeoutMs: Long = 30_000L, - maxPageMs: Long = timeoutMs * 10, maxConcurrentRelays: Int = 8, onEvent: suspend (relay: NormalizedRelayUrl, event: Event) -> Boolean, ): List> { @@ -289,7 +288,6 @@ suspend fun INostrClient.fetchAllPagesFromPoolWithHooks( fetchAllPagesFromPool( filters = filters, timeoutMs = timeoutMs, - maxPageMs = maxPageMs, maxConcurrentRelays = maxConcurrentRelays, ) { event, relay -> eventChannel.trySend(relay to event) } } finally { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md index 246f83fe2d..06c56d6be2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md @@ -19,11 +19,24 @@ from the relay's most recent message**, never a wall-clock deadline: each arrivi event / result / terminal signal resets it, so an actively streaming relay is never cut off mid-delivery — the operation only gives up after a full window of silence. The shared primitives are in `IdleWatchdog.kt` (`IdleClock` + `receiveWithinIdle`); -use them when writing a new accessory. Because an idle window alone is unbounded -against a relay that trickles messages forever, the loops that could run away also -take a hard wall-clock ceiling (`maxTotalMs` / `maxPageMs`, default 10x the idle -window) as a backstop; a non-positive ceiling means uncapped. The one deliberate -exception is the write side: `publishAndConfirm`'s `timeoutInSeconds` is a fixed +use them when writing a new accessory. + +An idle window alone never expires against a relay that trickles messages forever, +so the accessories that wait in **one** loop and then return — `fetchAll` / +`fetchAllWithHooks`, `fetchFirst`, multi-relay `count` — also take a wall-clock +ceiling (`maxTotalMs`, default 10x the idle window; non-positive means uncapped). +There the ceiling genuinely ends the call. + +`fetchAllPages` deliberately has **no** ceiling. A per-page ceiling would bound a +page, not the call: the paging loop reacts to a page ending by advancing the cursor +and issuing the next `REQ`, so an endless trickle just gets re-paged forever (a +ceiling of 400 ms against one measured 8 `REQ`s and no return). It also makes +truncation unsafe — cutting a page mid-stream advances `until` to the oldest event +received *so far*, which only preserves the set if the relay streams strictly +newest-first, which NIP-01 recommends but does not require. Bound a paged download +with the filter's `limit`, or by cancelling the caller. + +The write side is its own case: `publishAndConfirm`'s `timeoutInSeconds` is a fixed window to collect the `OK`s — a bounded confirmation round-trip, not a stream. ## One-shot reads (subscribe → collect → return) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt index d67d44dbe6..b6367b0f2d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt @@ -31,8 +31,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.TimeSource @@ -49,7 +51,10 @@ import kotlin.time.TimeSource class NostrClientFetchAllPagesIdleTimeoutTest { /** Captures the subscription listener so the test can play a relay. */ private class ScriptedClient : INostrClient by EmptyNostrClient() { + @Volatile var listener: SubscriptionListener? = null + + @Volatile var subscribeCount = 0 override fun subscribe( @@ -64,16 +69,18 @@ class NostrClientFetchAllPagesIdleTimeoutTest { private val relay = RelayUrlNormalizer.normalize("wss://slow.example.com") - private fun event(i: Int) = - Event( - id = i.toString(16).padStart(64, '0'), - pubKey = "f".repeat(64), - createdAt = i.toLong(), - kind = 1, - tags = emptyArray(), - content = "e$i", - sig = "0".repeat(128), - ) + private fun event( + i: Int, + createdAt: Long = i.toLong(), + ) = Event( + id = i.toString(16).padStart(64, '0'), + pubKey = "f".repeat(64), + createdAt = createdAt, + kind = 1, + tags = emptyArray(), + content = "e$i", + sig = "0".repeat(128), + ) @Test fun streamingPageOutlivesTheIdleWindow() = @@ -142,4 +149,43 @@ class NostrClientFetchAllPagesIdleTimeoutTest { assertTrue(elapsedMs >= 300, "must wait out at least one idle window, took ${elapsedMs}ms") assertTrue(elapsedMs < 5_000, "a stalled page must end promptly after the idle window, took ${elapsedMs}ms") } + + /** + * Documents why [fetchAllPages] has no wall-clock ceiling, unlike the + * single-wait accessories (`fetchAll`/`fetchFirst`/`count`, whose `maxTotalMs` + * really does end the call). + * + * A per-page ceiling cannot bound this walk: when a page ends, the loop advances + * the cursor and fires the NEXT `REQ`, so an endless trickle against an unbounded + * filter is merely re-paged. This pins that reality — the walk runs until the + * caller cancels — so nobody re-adds a `maxPageMs` believing it caps anything. + * What actually bounds a download is the filter's `limit`. + */ + @Test + fun anEndlessTrickleIsBoundedByCancellationNotByAWallClock() = + runBlocking { + val client = ScriptedClient() + var ts = 10_000_000L + var i = 1 + val feeder = + launch { + // Trickles forever, strictly decreasing created_at, never EOSE. + while (true) { + delay(40) + client.listener?.onEvent(event(i++, ts--), false, relay, null) + } + } + + val returned = + withTimeoutOrNull(1_500) { + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), // unbounded: no limit + timeoutMs = 200, + ) { } + } + feeder.cancel() + + assertNull(returned, "an unbounded filter against an endless trickle ends only by cancellation") + } } From ba35a87a7c06fbde6db0bbd207a74fa034c02e3d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 03:26:58 +0000 Subject: [PATCH 078/227] refactor: drop the ceiling params; bound waits by progress instead Follow-up audit on the timeout normalization. Three findings. 1. The maxTotalMs I added to fetchFirst and multi-relay count was the wrong shape twice over. A hard wall-clock bound already composes at the call site -- withTimeoutOrNull(ms) { fetchFirst(...) } -- so putting it in the signature duplicates what the caller has for free. And it was papering over the real defect: repeat chatter from a relay already accounted for (a CLOSED/reconnect loop, a duplicate COUNT) was treated as activity and restarted the idle window, so a flapping relay could hold the call open indefinitely. Both now reset the window only on genuine progress -- an event, or the first terminal signal from a relay still being waited on -- which is the rule the negentropy watchdog already applies to NOTICE/CLOSED chatter, and which makes both calls self-bounding at one window per relay. Ceiling params removed; the overflow guard they needed goes with them. 2. fetchFirst could drop a match: an event landing after the last terminal signal but before unsubscribe was left unread in the channel and the fetch reported nothing found. Added the post-loop drain that fetchAllWithHooks already does. Covered by a test. 3. fetchAllPages published its per-page counters across threads without a barrier on the idle path. received/delivered/pageMinTs/idsAtPageMin are written on the relay reader thread and read by the driver once the wait ends; the EOSE path gets happens-before from the channel, the idle path had none, so the driver could read a stale pageMinTs (ending the walk early) or an unsafely published idsAtPageMin. The volatile IdleClock bump now runs in a finally, so it covers every event including the early-returning duplicate and orders after the counters. Also folded the single-relay count channel close into its finally, so a throw mid-wait cleans up like every sibling accessory. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb --- .../client/accessories/NostrClientCountExt.kt | 69 ++++++++------- .../NostrClientFetchAllPagesExt.kt | 75 +++++++++------- .../accessories/NostrClientFetchFirstExt.kt | 86 +++++++++++-------- .../relay/client/accessories/README.md | 46 ++++++---- .../accessories/FetchFirstIdleTimeoutTest.kt | 68 +++++++++++---- 5 files changed, 209 insertions(+), 135 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt index e9125e602b..c8b8e65233 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt @@ -68,23 +68,22 @@ suspend fun INostrClient.count( } } - addConnectionListener(listener) + return try { + addConnectionListener(listener) - val result = - try { - count(subId = subId, filters = mapOf(relay to listOf(filter))) + count(subId = subId, filters = mapOf(relay to listOf(filter))) - withTimeoutOrNull(timeoutMs) { - resultChannel.receive() - } - } finally { - unsubscribe(subId) - removeConnectionListener(listener) + withTimeoutOrNull(timeoutMs) { + resultChannel.receive() } - - resultChannel.close() - - return result + } finally { + // Every cleanup step belongs in the finally: closing the channel used to + // sit after it, so a throw (or cancellation) mid-wait skipped it while the + // sibling accessories all cleaned up fully. + unsubscribe(subId) + removeConnectionListener(listener) + resultChannel.close() + } } /** @@ -92,23 +91,22 @@ suspend fun INostrClient.count( * (one filter per relay) and suspends until all results arrive * or the timeout expires. * - * [timeoutMs] is an **idle window measured from the most recent message**, not a + * [timeoutMs] is an **idle window measured from the most recent progress**, not a * wall-clock deadline for the whole batch — the package-wide accessory - * convention: each arriving COUNT result restarts it, so a large fan-out where - * results keep trickling in is never cut short; the wait only gives up after a - * full window with no relay answering. [maxTotalMs] (default 10x the idle - * window) is the wall-clock ceiling against a misbehaving relay re-sending - * results forever; a non-positive value means uncapped (which also absorbs a - * `timeoutMs * 10` overflow from an effectively-infinite idle window). + * convention: each *new* relay's COUNT result restarts it, so a large fan-out + * where results keep trickling in is never cut short. A relay re-sending a result + * it already gave is not progress and does not restart the window, which makes + * the call self-bounding (at most one window per relay). A caller wanting a hard + * wall-clock bound has `withTimeoutOrNull(ms) { count(...) }` — at the cost of + * discarding the partial map, which is why this returns whatever arrived instead. * * @param filters Map of relay -> filter to count. - * @param timeoutMs Idle window between responses (default 15 s). + * @param timeoutMs Idle window between new responses (default 15 s). * @return Map of relay -> [CountResult] for every relay that responded in time. */ suspend fun INostrClient.count( filters: Map>, timeoutMs: Long = 15_000, - maxTotalMs: Long = timeoutMs * 10, ): Map { if (filters.isEmpty()) return emptyMap() @@ -140,15 +138,22 @@ suspend fun INostrClient.count( count(subId = subId, filters = mapOf(relay to filterList)) } - // Each receive is bounded by the idle window alone; every arriving result - // restarts it on the next loop iteration. The outer ceiling bounds the whole - // wait against a relay that keeps re-sending results. - val ceiling = if (maxTotalMs <= 0) Long.MAX_VALUE else maxTotalMs - withTimeoutOrNull(ceiling) { - while (results.size < filters.size) { - val next = withTimeoutOrNull(timeoutMs) { resultChannel.receive() } ?: break - results[next.first] = next.second - } + // One idle window per new relay result. The inner loop absorbs repeats + // (a relay answering twice) inside the SAME window, so only genuinely + // new information pushes the deadline out — bounding the call at one + // window per relay without needing a wall-clock ceiling. + while (results.size < filters.size) { + val progressed = + withTimeoutOrNull(timeoutMs) { + while (true) { + val (relay, result) = resultChannel.receive() + // put() returns the previous value: null means this relay + // had not answered yet, i.e. real progress. + if (results.put(relay, result) == null) break + } + true + } + if (progressed == null) break } } finally { subIdToRelay.keys.forEach { unsubscribe(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index 52aa48affd..8688815352 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -178,40 +178,53 @@ suspend fun INostrClient.fetchAllPages( relay: NormalizedRelayUrl, forFilters: List?, ) { - clock.bump() - received++ - // Drop a boundary-second event we already delivered on an - // earlier page (the inclusive re-fetch returns it again). - if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return + // The bump is in a finally so it runs for EVERY event — + // including the duplicate that returns early below, which is + // still a sign of life — and, being a volatile write, runs + // AFTER the counters below. That ordering matters: these + // counters are written on the relay's reader thread and read + // by the driver coroutine once the wait ends. The EOSE path + // gets its happens-before from the channel, but the idle + // path has no such edge, so without the release write the + // driver could read a stale `pageMinTs` (ending the walk + // early) or an unsafely published `idsAtPageMin`. + try { + received++ + // Drop a boundary-second event we already delivered on an + // earlier page (the inclusive re-fetch returns it again). + if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return - // Count this event against every active filter it satisfies - // (one event can match more than one). Only a non-search filter - // may advance the `until` cursor: a search hit — possibly old, - // relevance-ranked — must not drag the cursor back and make the - // next page skip events a co-resident normal filter still needs. - var atLeastOne = false - var advancesCursor = false - for ((index, filter) in activeFilters) { - if (matchCountPerFilter[index] < (filter.limit ?: Int.MAX_VALUE) && filter.match(event)) { - matchCountPerFilter[index]++ - atLeastOne = true - if (filter.search == null) advancesCursor = true - } - } - if (atLeastOne) { - onEvent(event) - delivered++ - // Track the oldest advancing second and the ids delivered - // in it — that becomes the next boundary and its dedup set. - if (advancesCursor) { - if (event.createdAt < pageMinTs) { - pageMinTs = event.createdAt - idsAtPageMin.clear() - idsAtPageMin.add(event.id) - } else if (event.createdAt == pageMinTs) { - idsAtPageMin.add(event.id) + // Count this event against every active filter it satisfies + // (one event can match more than one). Only a non-search filter + // may advance the `until` cursor: a search hit — possibly old, + // relevance-ranked — must not drag the cursor back and make the + // next page skip events a co-resident normal filter still needs. + var atLeastOne = false + var advancesCursor = false + for ((index, filter) in activeFilters) { + if (matchCountPerFilter[index] < (filter.limit ?: Int.MAX_VALUE) && filter.match(event)) { + matchCountPerFilter[index]++ + atLeastOne = true + if (filter.search == null) advancesCursor = true } } + if (atLeastOne) { + onEvent(event) + delivered++ + // Track the oldest advancing second and the ids delivered + // in it — that becomes the next boundary and its dedup set. + if (advancesCursor) { + if (event.createdAt < pageMinTs) { + pageMinTs = event.createdAt + idsAtPageMin.clear() + idsAtPageMin.add(event.id) + } else if (event.createdAt == pageMinTs) { + idsAtPageMin.add(event.id) + } + } + } + } finally { + clock.bump() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt index fb25ae4672..45e28e7072 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt @@ -69,20 +69,24 @@ suspend fun INostrClient.fetchFirst( * every relay reached a terminal state — EOSE, CLOSED, or cannot-connect — with * nothing matching, or the line went quiet). * - * [timeoutMs] is an **idle window measured from the most recent message**, not a - * wall-clock deadline — the package-wide accessory convention: every arriving - * signal (a terminal state from one relay of many) restarts it, so the fetch only - * gives up after a full window of total silence. [maxTotalMs] (default 10x the - * idle window) is the wall-clock ceiling that bounds a relay emitting endless - * terminal chatter (e.g. a CLOSED/reconnect loop) without ever delivering an - * event; a non-positive value means uncapped (which also absorbs a - * `timeoutMs * 10` overflow from an effectively-infinite idle window). + * [timeoutMs] is an **idle window measured from the most recent progress**, not a + * wall-clock deadline — the package-wide accessory convention. Progress means a + * signal that actually advances the fetch: an event, or the first terminal state + * from a relay still being waited on. Repeat chatter from a relay already + * accounted for (a CLOSED/reconnect loop) is *not* progress and does not restart + * the window — the same rule the negentropy watchdog applies to NOTICE/CLOSED + * error chatter, and what keeps a flapping relay from holding this open forever. + * + * That makes the call self-bounding: at most one progress signal per relay, each + * granting a fresh window. There is deliberately no ceiling parameter — a caller + * who wants a hard wall-clock bound already has one in + * `withTimeoutOrNull(ms) { fetchFirst(...) }`, which costs nothing here since a + * timed-out fetch returns `null` either way. */ suspend fun INostrClient.fetchFirst( subscriptionId: String = newSubId(), filters: Map>, timeoutMs: Long = 30_000L, - maxTotalMs: Long = timeoutMs * 10, ): Event? { val eventChannel = Channel(UNLIMITED) val doneChannel = Channel(UNLIMITED) @@ -127,37 +131,49 @@ suspend fun INostrClient.fetchFirst( try { subscribe(subscriptionId, filters, listener) - // Each wait is bounded by the idle window alone; any arriving signal - // restarts it on the next loop iteration. The outer ceiling stays far - // above the window so legitimate multi-relay stragglers still land. - val ceiling = if (maxTotalMs <= 0) Long.MAX_VALUE else maxTotalMs - withTimeoutOrNull(ceiling) { - while (remaining.isNotEmpty()) { - val progressed = - withTimeoutOrNull(timeoutMs) { - select { - eventChannel.onReceive { event -> - result = event - remaining.clear() - } - doneChannel.onReceive { relay -> - // A relay sends its matching events before its EOSE, so an event may - // already be buffered when this completion fires. select() picks a ready - // clause at random, so without this drain we could treat the relay as done - // and exit while its event still sits unread in the channel. - val buffered = eventChannel.tryReceive().getOrNull() - if (buffered != null) { - result = buffered + // One idle window per unit of progress. The inner loop keeps consuming + // non-progress signals INSIDE the same window, so repeat chatter from an + // already-accounted-for relay cannot push the deadline out; only a real + // advance escapes to the outer loop and earns a fresh window. + while (remaining.isNotEmpty()) { + val progressed = + withTimeoutOrNull(timeoutMs) { + while (true) { + val advanced = + select { + eventChannel.onReceive { event -> + result = event remaining.clear() - } else { - remaining.remove(relay) + true + } + doneChannel.onReceive { relay -> + // A relay sends its matching events before its EOSE, so an event may + // already be buffered when this completion fires. select() picks a ready + // clause at random, so without this drain we could treat the relay as done + // and exit while its event still sits unread in the channel. + val buffered = eventChannel.tryReceive().getOrNull() + if (buffered != null) { + result = buffered + remaining.clear() + true + } else { + // Only the FIRST terminal signal from a relay we are still + // waiting on advances the fetch; a repeat is chatter. + remaining.remove(relay) + } } } - } + if (advanced) break } - if (progressed == null) break - } + true + } + if (progressed == null) break } + + // An event can land after the last terminal signal but before we + // unsubscribe; without this drain it would be dropped and the fetch + // would report "nothing found" while holding a match. + if (result == null) result = eventChannel.tryReceive().getOrNull() } finally { unsubscribe(subscriptionId) eventChannel.close() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md index 06c56d6be2..8933529397 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md @@ -15,26 +15,36 @@ Import as `com.vitorpamplona.quartz.nip01Core.relay.client.accessories.` ( ## Timeout convention Every `timeoutMs` / `idleTimeoutMs` in this package is an **idle window measured -from the relay's most recent message**, never a wall-clock deadline: each arriving -event / result / terminal signal resets it, so an actively streaming relay is never -cut off mid-delivery — the operation only gives up after a full window of silence. -The shared primitives are in `IdleWatchdog.kt` (`IdleClock` + `receiveWithinIdle`); -use them when writing a new accessory. +from the relay's most recent progress**, never a wall-clock deadline: real progress +resets it, so an actively streaming relay is never cut off mid-delivery — the +operation only gives up after a full window of silence. The shared primitives are in +`IdleWatchdog.kt` (`IdleClock` + `receiveWithinIdle`); use them in a new accessory. -An idle window alone never expires against a relay that trickles messages forever, -so the accessories that wait in **one** loop and then return — `fetchAll` / -`fetchAllWithHooks`, `fetchFirst`, multi-relay `count` — also take a wall-clock -ceiling (`maxTotalMs`, default 10x the idle window; non-positive means uncapped). -There the ceiling genuinely ends the call. +**Progress, not merely traffic.** A message that tells us nothing new — a relay +re-CLOSEing after we already recorded it as done, a duplicate COUNT — must not +restart the window, or a flapping relay keeps the call alive indefinitely. This is +the rule the negentropy watchdog already applies to `NOTICE`/`CLOSED` chatter, and +it is what makes `fetchFirst` and multi-relay `count` self-bounding: at most one +window per relay. -`fetchAllPages` deliberately has **no** ceiling. A per-page ceiling would bound a -page, not the call: the paging loop reacts to a page ending by advancing the cursor -and issuing the next `REQ`, so an endless trickle just gets re-paged forever (a -ceiling of 400 ms against one measured 8 `REQ`s and no return). It also makes -truncation unsafe — cutting a page mid-stream advances `until` to the oldest event -received *so far*, which only preserves the set if the relay streams strictly -newest-first, which NIP-01 recommends but does not require. Bound a paged download -with the filter's `limit`, or by cancelling the caller. +**No accessory takes a wall-clock ceiling parameter.** A hard bound composes at the +call site — `withTimeoutOrNull(ms) { fetchFirst(...) }` — so duplicating it in every +signature buys nothing. Prefer the idle window inside (the caller cannot implement +it; it needs the message stream) and the wall clock outside. Two consequences worth +knowing: + +- `fetchAllPages` has no ceiling and could not usefully have one. A per-page cap + bounds a *page*, not the call: the loop reacts to a page ending by advancing the + cursor and issuing the next `REQ`, so an endless trickle is just re-paged (a + 400 ms cap measured 8 `REQ`s and no return). It also makes truncation unsafe — + cutting a page mid-stream advances `until` to the oldest event received *so far*, + which only preserves the set if the relay streams strictly newest-first, which + NIP-01 recommends but does not require. Bound a paged download with the filter's + `limit`, or by cancelling. +- `fetchAll` / `fetchAllWithHooks` keep a pre-existing `maxTotalMs`, and it earns + its place: an endless *event* trickle there is genuine progress, so the call never + self-terminates, and the internal cap returns the events collected so far where an + external `withTimeoutOrNull` would discard them. The write side is its own case: `publishAndConfirm`'s `timeoutInSeconds` is a fixed window to collect the `OK`s — a bounded confirmation round-trip, not a stream. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchFirstIdleTimeoutTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchFirstIdleTimeoutTest.kt index d918396017..d52656fc62 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchFirstIdleTimeoutTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchFirstIdleTimeoutTest.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeoutOrNull import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -59,6 +60,8 @@ class FetchFirstIdleTimeoutTest { private val relayA = RelayUrlNormalizer.normalize("wss://a.example.com") private val relayB = RelayUrlNormalizer.normalize("wss://b.example.com") + private val relayC = RelayUrlNormalizer.normalize("wss://c.example.com") + private val relayD = RelayUrlNormalizer.normalize("wss://d.example.com") private fun event(i: Int) = Event( @@ -74,28 +77,29 @@ class FetchFirstIdleTimeoutTest { private fun filters(vararg relays: NormalizedRelayUrl) = relays.associateWith { listOf(Filter(kinds = listOf(1))) } @Test - fun arrivingSignalsRestartTheIdleWindow() = + fun genuineProgressRestartsTheIdleWindow() = runTest { val client = ScriptedClient() launch { - // Terminal chatter every 250ms keeps the 300ms window alive long - // enough for the slow relay's event at 900ms — an absolute + // Each relay's FIRST terminal signal is real progress and buys a + // fresh window, carrying the fetch well past a single 300ms window + // so the slow relay's event at 900ms still lands. An absolute // deadline would have returned null at 300ms. delay(250) client.listener!!.onClosed("rate limited", relayA, null) delay(250) - client.listener!!.onClosed("rate limited", relayA, null) + client.listener!!.onClosed("rate limited", relayB, null) delay(250) - client.listener!!.onClosed("rate limited", relayA, null) + client.listener!!.onClosed("rate limited", relayC, null) delay(150) - client.listener!!.onEvent(event(1), false, relayB, null) + client.listener!!.onEvent(event(1), false, relayD, null) } val result = client.fetchFirst( - filters = filters(relayA, relayB), + filters = filters(relayA, relayB, relayC, relayD), timeoutMs = 300, ) - assertEquals(event(1).id, result?.id, "signals must restart the window; the slow relay's event still lands") + assertEquals(event(1).id, result?.id, "progress must restart the window; the slow relay's event still lands") } @Test @@ -113,14 +117,16 @@ class FetchFirstIdleTimeoutTest { } @Test - fun wallClockCeilingStopsEndlessTerminalChatter() = + fun repeatTerminalChatterDoesNotRestartTheIdleWindow() = runTest { val client = ScriptedClient() val chatter = launch { // relayA re-CLOSEs forever (a reconnect loop); relayB never - // answers. Every signal restarts the window, so only the - // ceiling can end the wait. + // answers. Only relayA's FIRST CLOSED is progress — it removes + // relayA from `remaining`. The repeats say nothing new, so they + // must not push the deadline out (the rule the negentropy + // watchdog already uses for NOTICE/CLOSED chatter). while (true) { delay(200) client.listener!!.onClosed("auth-required: again", relayA, null) @@ -131,28 +137,52 @@ class FetchFirstIdleTimeoutTest { client.fetchFirst( filters = filters(relayA, relayB), timeoutMs = 300, - maxTotalMs = 1_000, ) chatter.cancel() assertNull(result) - assertEquals(1_000L, currentTime - start, "the ceiling must end an endlessly-restarted wait") + // First CLOSED at 200ms is the only progress; the window then expires + // 300ms later despite chatter at 400/600/800… + assertEquals(500L, currentTime - start, "repeat chatter must not keep the wait alive") } @Test - fun effectivelyInfiniteIdleWindowDoesNotOverflowTheCeiling() = + fun anEventArrivingAfterTheLastTerminalSignalIsStillReturned() = runTest { val client = ScriptedClient() launch { delay(100) - client.listener!!.onEvent(event(1), false, relayA, null) + // The only relay EOSEs, emptying `remaining` and ending the loop — + // then its matching event lands before we unsubscribe. Without the + // post-loop drain this returns null while holding a match. + client.listener!!.onEose(relayA, null) + client.listener!!.onEvent(event(7), false, relayA, null) } - // Long.MAX_VALUE * 10 wraps to -10; the default ceiling must - // degrade to "uncapped", not to an instantly-expired wait. val result = client.fetchFirst( filters = filters(relayA), - timeoutMs = Long.MAX_VALUE, + timeoutMs = 300, ) - assertEquals(event(1).id, result?.id, "an overflowed default ceiling must mean uncapped, not instant timeout") + assertEquals(event(7).id, result?.id, "an event racing the final EOSE must not be dropped") + } + + @Test + fun aHardWallClockBoundIsTheCallersToApply() = + runTest { + val client = ScriptedClient() + val chatter = + launch { + while (true) { + delay(50) + client.listener!!.onClosed("flapping", relayA, null) + } + } + // No ceiling parameter: composing withTimeoutOrNull at the call site + // is the wall-clock bound, and costs nothing because a timed-out + // fetchFirst yields null either way. + val start = currentTime + val result = withTimeoutOrNull(120) { client.fetchFirst(filters = filters(relayA, relayB), timeoutMs = 10_000) } + chatter.cancel() + assertNull(result) + assertEquals(120L, currentTime - start, "the caller's timeout bounds the call") } } From 3ef61bafcb1d9e98c88d886ebb256b47b93d8c0e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 04:08:30 +0000 Subject: [PATCH 079/227] refactor: rename accessory timeoutMs to idleTimeoutMs Every wait in the accessories package is an idle window measured from the relay's most recent progress, so the parameter now says so. The name is the contract: a caller reading timeoutMs reasonably expects a deadline, which is exactly the misreading that made fetchAllPages' hard per-page cap look correct for so long. Renamed across the public surface -- fetchAll (7 overloads), fetchAllWithHooks, fetchAllPages (2), fetchAllPagesFromPool, fetchAllPagesFromPoolWithHooks, fetchFirst, count (2), countMerged -- plus the quartz wrappers whose own parameter is a pure pass-through of that window (KeyPackageFetcher, RecipientRelayFetcher, FollowerCrawler.Config) and every call site across commons, cli, desktopApp and amethyst. Deliberately NOT renamed, because these are genuine wall-clock bounds and the differing name is the tell: - publishAndConfirm's timeoutInSeconds -- one fixed window to collect the OKs, a bounded confirmation round-trip rather than a stream. - GrapeRankCrawler.Config.timeoutMs -- a hard per-drain gate (withTimeoutOrNull(config.timeoutMs)) that also drives parking. - Context.awaitReply's timeoutMs, Context.syncIncoming, and the non-accessory app-layer helpers (RelayProber, FeedMetadataCoordinator, RelayAuthPromptBus). This is a source-breaking change for named-argument callers of quartz. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb --- .../amethyst/model/AccountConcordActions.kt | 4 +-- .../model/AccountRelayGroupActions.kt | 2 +- .../gateways/AccountNappletGateways.kt | 2 +- .../gateways/NappletResourceFetcher.kt | 2 +- .../loggedIn/buzz/AgentConsoleViewModel.kt | 2 +- .../screen/loggedIn/buzz/BuzzDmDiscovery.kt | 4 +-- .../loggedIn/buzz/BuzzDmListViewModel.kt | 4 +-- .../loggedIn/buzz/BuzzRelayImportViewModel.kt | 4 +-- .../loggedIn/relays/eventsync/EventSync.kt | 2 +- .../wallet/wizard/CashuWalletDiscovery.kt | 2 +- .../appfunctions/AmethystAppFunctions.kt | 30 ++++++++-------- .../com/vitorpamplona/amethyst/cli/Context.kt | 20 +++++------ .../amethyst/cli/commands/AwaitCommands.kt | 2 +- .../amethyst/cli/commands/DmCommands.kt | 4 +-- .../amethyst/cli/commands/GitReadCommands.kt | 6 ++-- .../cli/commands/GroupAddMemberCommand.kt | 2 +- .../cli/commands/KeyPackageCommands.kt | 2 +- .../cli/commands/graperank/GrapeRankCrawl.kt | 2 +- .../nip17Dm/DmInboxRelayResolver.kt | 2 +- .../DesktopRelaySubscriptionsCoordinator.kt | 2 +- .../experimental/graperank/FollowerCrawler.kt | 6 ++-- .../quartz/marmot/RecipientRelayFetcher.kt | 4 +-- .../mip00KeyPackages/KeyPackageFetcher.kt | 4 +-- .../relay/client/accessories/IdleWatchdog.kt | 9 ++--- .../client/accessories/NostrClientCountExt.kt | 22 ++++++------ .../accessories/NostrClientFetchAllExt.kt | 34 +++++++++---------- .../NostrClientFetchAllPagesExt.kt | 12 +++---- .../NostrClientFetchAllPagesPoolExt.kt | 6 ++-- .../NostrClientFetchAllWithHooksExt.kt | 22 ++++++------ .../accessories/NostrClientFetchFirstExt.kt | 6 ++-- .../relay/client/accessories/README.md | 23 ++++++++----- .../accessories/FetchAllIdleTimeoutTest.kt | 10 +++--- .../accessories/FetchFirstIdleTimeoutTest.kt | 19 ++++++----- ...NostrClientFetchAllPagesIdleTimeoutTest.kt | 13 ++++--- .../relay/prodbench/BulkDownloadBenchmark.kt | 6 ++-- .../relay/prodbench/ByIdFetchBenchmark.kt | 2 +- 36 files changed, 152 insertions(+), 146 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index 6fc115f11d..be17eefef0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -971,7 +971,7 @@ class AccountConcordActions( val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(account.signer.pubKey)) // Stock relays like relay.ditto.pub can be slow (~10–20s to first response), so give // the fetch a generous window to drain every relay before we pick the newest copy. - val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = 30_000L) + val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = 30_000L) val newest = events.filterIsInstance().maxByOrNull { it.createdAt } val entryCount = newest?.let { runCatching { it.decrypt(account.signer).size }.getOrElse { -1 } } ?: 0 Log.d( @@ -1015,7 +1015,7 @@ class AccountConcordActions( } if (filters.isEmpty()) return val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } } - account.client.fetchAll(filters = byRelay, timeoutMs = 20_000L) + account.client.fetchAll(filters = byRelay, idleTimeoutMs = 20_000L) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt index d8be5a68c3..a5bb8e9a7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt @@ -133,7 +133,7 @@ class AccountRelayGroupActions( if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) { account.client.fetchAllWithHooks( filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))), - timeoutMs = 8_000, + idleTimeoutMs = 8_000, pendingOnAuthRequired = true, ) { _, _ -> false } results = account.client.publishAndCollectResults(signed, setOf(relay)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index 51f482ba94..139a935d5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -255,7 +255,7 @@ class AccountNappletGateways( emptyList() } else { runCatching { - account.client.fetchAll(filters = relays.associateWith { filters }, timeoutMs = QUERY_TIMEOUT.inWholeMilliseconds) + account.client.fetchAll(filters = relays.associateWith { filters }, idleTimeoutMs = QUERY_TIMEOUT.inWholeMilliseconds) }.getOrDefault(emptyList()) } val fromCache = filters.flatMap { filter -> account.cache.filter(filter).mapNotNull { it.event } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index a5e67039e0..c64fb12eca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -144,7 +144,7 @@ class NappletResourceFetcher( val relays = account.homeRelays.flow.value if (relays.isEmpty()) return null return runCatching { - account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = NOSTR_FETCH_TIMEOUT_MS) + account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = NOSTR_FETCH_TIMEOUT_MS) }.getOrDefault(emptyList()) .maxByOrNull { it.createdAt } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt index f0c06abf53..98d19c7937 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt @@ -153,7 +153,7 @@ class AgentConsoleViewModel : ViewModel() { // (pendingOnAuthRequired) so it authenticates on the `auth-required` CLOSED and retries. account.client.fetchAllWithHooks( filters = relays.associateWith { filters }, - timeoutMs = 8_000, + idleTimeoutMs = 8_000, pendingOnAuthRequired = true, ) { _, _ -> false } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt index 6f3ea3d510..f1fd9926d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmDiscovery.kt @@ -97,7 +97,7 @@ private suspend fun runBuzzDmDiscovery( // rather than returning empty. account.client.fetchAllWithHooks( filters = relays.associateWith { discoveryFilters }, - timeoutMs = 8_000, + idleTimeoutMs = 8_000, pendingOnAuthRequired = true, ) { relay, event -> (event as? MemberAddedNotificationEvent)?.let { recordDiscovery(me, it, relay) } @@ -135,7 +135,7 @@ private suspend fun fetchDmMetadata( .groupBy({ it.value }, { it.key }) .mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) } if (byRelay.isEmpty()) return - account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false } + account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index a63b122d6d..a619471e3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -200,7 +200,7 @@ class BuzzDmListViewModel : ViewModel() { ) account.client.fetchAllWithHooks( filters = relays.associateWith { filters }, - timeoutMs = 8_000, + idleTimeoutMs = 8_000, pendingOnAuthRequired = true, ) { relay, event -> (event as? MemberAddedNotificationEvent)?.channel()?.let { memberChannels[it] = relay } @@ -215,7 +215,7 @@ class BuzzDmListViewModel : ViewModel() { .groupBy({ it.value }, { it.key }) .mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) } if (byRelay.isEmpty()) return - account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false } + account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt index 59a0aa3247..9cded7263f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt @@ -149,7 +149,7 @@ class BuzzRelayImportViewModel : ViewModel() { ), ), ), - timeoutMs = 8_000, + idleTimeoutMs = 8_000, pendingOnAuthRequired = true, ) { _, event -> (event as? MemberAddedNotificationEvent)?.channel()?.let { channelIds.add(it) } @@ -172,7 +172,7 @@ class BuzzRelayImportViewModel : ViewModel() { Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds.toList())), ), ), - timeoutMs = 8_000, + idleTimeoutMs = 8_000, pendingOnAuthRequired = true, ) { _, _ -> false } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt index d589c6de78..653003ecf4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt @@ -502,7 +502,7 @@ class EventSync( try { client.fetchAllPagesFromPool( filters = perRelayFilters, - timeoutMs = RELAY_TIMEOUT_MS, + idleTimeoutMs = RELAY_TIMEOUT_MS, maxConcurrentRelays = MAX_CONCURRENT_RELAYS, onNewPage = { until, sourceRelay -> _liveActivity.value.runningRelays[sourceRelay] diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletDiscovery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletDiscovery.kt index a68f4e356b..40ce68ec00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletDiscovery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletDiscovery.kt @@ -199,7 +199,7 @@ class CashuWalletDiscovery( fetchAllPages( relay = relay, filters = filters, - timeoutMs = RELAY_TIMEOUT_MS, + idleTimeoutMs = RELAY_TIMEOUT_MS, onEvent = onEvent, ) }.onFailure { diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index f02f086b07..56acb7bfb0 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -153,12 +153,12 @@ class AmethystAppFunctions { // Quartz's INostrClient.fetchAll handles subscribe → drain on // EOSE/closed/cannot-connect → unsubscribe → dedup by id → sort - // newest-first. Wraps everything in a withTimeoutOrNull(timeoutMs) + // newest-first. Wraps everything in a withTimeoutOrNull(idleTimeoutMs) // so a slow relay can't stall the dispatch. val events = client.fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ) val candidates = @@ -397,7 +397,7 @@ class AmethystAppFunctions { return Amethyst.instance.client .fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ).mapNotNull { it as? TextNoteEvent } .take(limit) } @@ -449,7 +449,7 @@ class AmethystAppFunctions { val events = client.fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ) val hits = @@ -521,7 +521,7 @@ class AmethystAppFunctions { client .fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ).mapNotNull { it as? MetadataEvent } .filter { it.pubKey == pubkey } .maxByOrNull { it.createdAt } @@ -569,7 +569,7 @@ class AmethystAppFunctions { val events = client.fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ) val hits = @@ -643,7 +643,7 @@ class AmethystAppFunctions { val events = client.fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ) val hits = @@ -686,7 +686,7 @@ class AmethystAppFunctions { val events = client.fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ) val hits = @@ -733,7 +733,7 @@ class AmethystAppFunctions { val events = client.fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ) val hits = @@ -785,7 +785,7 @@ class AmethystAppFunctions { val events = client.fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ) val receipts = events.mapNotNull { it as? LnZapEvent } @@ -880,7 +880,7 @@ class AmethystAppFunctions { client .fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ).mapNotNull { it as? GiftWrapEvent } val seen = HashSet() @@ -947,7 +947,7 @@ class AmethystAppFunctions { val events = client.fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ) val hits = @@ -991,7 +991,7 @@ class AmethystAppFunctions { val events = client.fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ) val streams = @@ -1691,7 +1691,7 @@ class AmethystAppFunctions { return client .fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ).mapNotNull { it as? MetadataEvent } .maxByOrNull { it.createdAt } ?.contactMetaData() @@ -2027,7 +2027,7 @@ class AmethystAppFunctions { val events = client.fetchAll( filters = relays.associateWith { listOf(filter) }, - timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ) val hits = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index c0887ed607..b36cb843e3 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -547,7 +547,7 @@ class Context( if (needAuth.isEmpty()) break // A cheap REQ whose only purpose is to force the AUTH handshake to completion. val warmFilter = listOf(Filter(kinds = listOf(event.kind), limit = 1)) - drain(needAuth.associateWith { warmFilter }, timeoutMs = 8_000, pendingOnAuthRequired = true) + drain(needAuth.associateWith { warmFilter }, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) results = results + client.publishAndCollectResults(event, needAuth, timeoutSecs) attempt++ } @@ -565,7 +565,7 @@ class Context( * When [deadOut] is provided, every relay that reported it could not be * connected to (`onCannotConnect`) is added to it, so callers can prune * proven-dead relays from future routing instead of paying the full - * [timeoutMs] on them again. Slow-but-connected relays are NOT reported — + * [idleTimeoutMs] on them again. Slow-but-connected relays are NOT reported — * only hard connect failures, so a temporarily-busy relay isn't discarded. * * With [pendingOnAuthRequired], a relay that refuses the REQ with an @@ -573,24 +573,24 @@ class Context( * NIP-42 responder answers the challenge and the client re-fires this same * subscription (`syncFilters`), so the post-auth events are collected instead of * returning empty. If auth never satisfies it, the relay simply falls through to - * the [timeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served + * the [idleTimeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served * only to a connection authenticated as the derived stream key. */ suspend fun drain( filters: Map>, - timeoutMs: Long = 8_000, + idleTimeoutMs: Long = 8_000, diagnoseSlow: Boolean = false, deadOut: MutableMap? = null, pendingOnAuthRequired: Boolean = false, ): List> = client.fetchAllWithHooks( filters = filters, - timeoutMs = timeoutMs, + idleTimeoutMs = idleTimeoutMs, pendingOnAuthRequired = pendingOnAuthRequired, deadOut = deadOut, onTimeout = if (diagnoseSlow) { - { stalled, doneReasons, collected -> logSlowDrain(timeoutMs, stalled, doneReasons, collected) } + { stalled, doneReasons, collected -> logSlowDrain(idleTimeoutMs, stalled, doneReasons, collected) } } else { null }, @@ -604,7 +604,7 @@ class Context( * "relay is slow" and "we never connected" are easy to tell apart. */ private fun logSlowDrain( - timeoutMs: Long, + idleTimeoutMs: Long, stalled: Set, doneReasons: Map, collected: List>, @@ -615,7 +615,7 @@ class Context( val slowDetail = stalled.take(12).joinToString(", ") { "${it.url}(${eventsPer[it] ?: 0}ev)" } val cannotDetail = cannot.entries.take(8).joinToString(", ") { "${it.key.url}=${it.value.removePrefix("cannot:").take(40)}" } System.err.println( - "[drain] timeout ${timeoutMs}ms: ${stalled.size} slow(no EOSE), ${cannot.size} cannot-connect, ${closed.size} closed" + + "[drain] timeout ${idleTimeoutMs}ms: ${stalled.size} slow(no EOSE), ${cannot.size} cannot-connect, ${closed.size} closed" + (if (slowDetail.isNotEmpty()) " | slow: $slowDetail" else "") + (if (cannotDetail.isNotEmpty()) " | cannot: $cannotDetail" else ""), ) @@ -641,12 +641,12 @@ class Context( */ suspend fun drainAllPages( filters: Map>, - timeoutMs: Long = 30_000, + idleTimeoutMs: Long = 30_000, maxConcurrentRelays: Int = 8, ): List> = client.fetchAllPagesFromPoolWithHooks( filters = filters, - timeoutMs = timeoutMs, + idleTimeoutMs = idleTimeoutMs, maxConcurrentRelays = maxConcurrentRelays, ) { _, event -> verifyAndStore(event) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt index a7aeb22980..b0536cf90a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt @@ -112,7 +112,7 @@ object AwaitCommands { val event = ctx.client.fetchFirst( filters = relays.associateWith { listOf(filter) }, - timeoutMs = 3_000, + idleTimeoutMs = 3_000, ) if (event is KeyPackageEvent) { Output.emit( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt index 2049fe927b..57f5bf06a9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt @@ -357,7 +357,7 @@ object DmCommands { .groupBy { it.relay } .mapValues { (_, v) -> v.map { it.filter } } - val raw = ctx.drain(filters, timeoutMs = timeoutSecs * 1000) + val raw = ctx.drain(filters, idleTimeoutMs = timeoutSecs * 1000) val messages = decryptDms(ctx, raw, peerHex) val out = @@ -415,7 +415,7 @@ object DmCommands { .groupBy { it.relay } .mapValues { (_, v) -> v.map { it.filter } } - val raw = ctx.drain(filters, timeoutMs = 3_000) + val raw = ctx.drain(filters, idleTimeoutMs = 3_000) val messages = decryptDms(ctx, raw, peerHex) // Match against the text body for kind:14 and against the URL // for kind:15 — both are exposed as `searchText` so callers diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt index e1c9664660..d261e62891 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt @@ -103,7 +103,7 @@ object GitReadCommands { ctx .drainAllPages( relays.associateWith { listOf(Filter(kinds = listOf(itemKind), tags = mapOf("a" to listOf(repoAddress)), limit = limit)) }, - timeoutMs = READ_TIMEOUT_MS, + idleTimeoutMs = READ_TIMEOUT_MS, ).asSequence() .map { it.second } .filter { it.kind == itemKind } @@ -160,7 +160,7 @@ object GitReadCommands { relays.associateWith { listOf(Filter(kinds = STATUS_KINDS + listOf(CommentEvent.KIND, GitReplyEvent.KIND), tags = mapOf("e" to listOf(id)))) }, - timeoutMs = READ_TIMEOUT_MS, + idleTimeoutMs = READ_TIMEOUT_MS, ).map { it.second } .distinctBy { it.id } @@ -208,7 +208,7 @@ object GitReadCommands { ctx .drainAllPages( relays.associateWith { listOf(Filter(kinds = STATUS_KINDS, tags = mapOf("e" to chunk))) }, - timeoutMs = READ_TIMEOUT_MS, + idleTimeoutMs = READ_TIMEOUT_MS, ).map { it.second } }.filterIsInstance() .distinctBy { it.id } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt index ba0550cce6..161fe5c844 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt @@ -97,7 +97,7 @@ object GroupAddMemberCommand { client = ctx.client, targetPubKey = pub, relays = kpRelays, - timeoutMs = 10_000, + idleTimeoutMs = 10_000, ) if (kpEvent == null) { report.add(mapOf("pubkey" to pub, "status" to "no_key_package")) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt index ed44f9080e..df49d3ca74 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt @@ -99,7 +99,7 @@ object KeyPackageCommands { client = ctx.client, targetPubKey = targetHex, relays = relays, - timeoutMs = 10_000, + idleTimeoutMs = 10_000, ) if (event == null) { return Output.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankCrawl.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankCrawl.kt index d34a31f1ee..4e21f6cee5 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankCrawl.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankCrawl.kt @@ -287,7 +287,7 @@ object GrapeRankCrawl { // Default null → pull EVERY follower each relay holds; --max // N caps the total per relay for a quick spot check. maxPerRelay = args.flag("max")?.toIntOrNull(), - timeoutMs = args.timeoutMs(15), + idleTimeoutMs = args.timeoutMs(15), maxConcurrentRelays = relayConcurrency, insertBatchSize = args.intFlag(FLAG_INSERT_BATCH, INSERT_BATCH_DEFAULT), ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt index 6c75108430..f861340680 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt @@ -147,7 +147,7 @@ class DmInboxRelayResolver( if (writeRelays.isNotEmpty()) { relays = RecipientRelayFetcher - .fetchRelayLists(unauthenticatedClient, pubkey, writeRelays, timeoutMs = 5_000L) + .fetchRelayLists(unauthenticatedClient, pubkey, writeRelays, idleTimeoutMs = 5_000L) .dmInbox } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index b70c4e2bea..ffa8f497ec 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -221,7 +221,7 @@ class DesktopRelaySubscriptionsCoordinator( val events = client.fetchAll( filters = indexRelays.associateWith { listOf(filter) }, - timeoutMs = 8.seconds.inWholeMilliseconds, + idleTimeoutMs = 8.seconds.inWholeMilliseconds, ) events.forEach { consumeEvent(it, null) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/FollowerCrawler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/FollowerCrawler.kt index 14cbd15adf..e3597329a7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/FollowerCrawler.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/FollowerCrawler.kt @@ -75,14 +75,14 @@ class FollowerCrawler( * stops paging once it's reached, so a non-null value cuts the crawl short at * that many per relay. Leave it null for completeness; set it only to bound a * spot check. The per-page size is the relay's own default either way. - * @param timeoutMs per-page EOSE timeout for a relay before its next page fires. + * @param idleTimeoutMs per-page EOSE timeout for a relay before its next page fires. * @param maxConcurrentRelays how many relays page at once (a global fan-out cap). * @param insertBatchSize verified events group-committed per [IEventStore.batchInsert]. */ class Config( val relays: Set, val maxPerRelay: Int? = null, - val timeoutMs: Long = 15_000, + val idleTimeoutMs: Long = 15_000, val maxConcurrentRelays: Int = 16, val insertBatchSize: Int = 500, ) @@ -162,7 +162,7 @@ class FollowerCrawler( client.fetchAllPagesFromPool( filters = perRelay, - timeoutMs = config.timeoutMs, + idleTimeoutMs = config.idleTimeoutMs, maxConcurrentRelays = config.maxConcurrentRelays, onRelayComplete = { relay, total -> if (total > 0) log("[followers] ${relay.url}: $total kind:3 pages drained") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt index 9014af621e..21d9778a2a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt @@ -81,7 +81,7 @@ object RecipientRelayFetcher { client: INostrClient, pubKey: HexKey, seedRelays: Set, - timeoutMs: Long = 8_000L, + idleTimeoutMs: Long = 8_000L, ): Lists { if (seedRelays.isEmpty()) return Lists(emptyList(), emptyList(), null) @@ -99,7 +99,7 @@ object RecipientRelayFetcher { val events = client.fetchAll( filters = seedRelays.associateWith { listOf(filter) }, - timeoutMs = timeoutMs, + idleTimeoutMs = idleTimeoutMs, ) var dm: ChatMessageRelayListEvent? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFetcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFetcher.kt index c38ac27a38..19459b09f7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFetcher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFetcher.kt @@ -75,11 +75,11 @@ object KeyPackageFetcher { client: INostrClient, targetPubKey: HexKey, relays: Set, - timeoutMs: Long = 30_000, + idleTimeoutMs: Long = 30_000, ): KeyPackageEvent? { if (relays.isEmpty()) return null val filter = MarmotFilters.keyPackagesByAuthor(targetPubKey) - val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs) + val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = idleTimeoutMs) // fetchAll returns events sorted by created_at DESC, so the first // KeyPackageEvent is the most recent one any relay had. return events.firstNotNullOfOrNull { it as? KeyPackageEvent } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/IdleWatchdog.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/IdleWatchdog.kt index c1be16acdb..9a07815cec 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/IdleWatchdog.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/IdleWatchdog.kt @@ -30,10 +30,11 @@ import kotlin.time.TimeSource * fetch/sync loops. [bump] on every sign of life from the relay; [elapsedMs] reports * the silence since the last bump (or since construction, before the first bump). * - * This is the timeout convention for every accessory in this package: a `timeoutMs` - * (or `idleTimeoutMs`) is an **idle window measured from the relay's most recent - * message**, not a wall-clock deadline — an actively streaming relay is never cut - * off mid-delivery, only one that goes silent. + * This is the timeout convention for every accessory in this package: an + * `idleTimeoutMs` is an **idle window measured from the relay's most recent + * progress**, not a wall-clock deadline — an actively streaming relay is never cut + * off mid-delivery, only one that goes silent. The name is the contract: a + * parameter here is called `idleTimeoutMs` precisely because it is not a deadline. * * [bump] is on the per-event hot path (a connection listener may bump for every * message the relay sends — millions during a large download), so it must not diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt index c8b8e65233..09f8941053 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt @@ -38,19 +38,19 @@ import kotlinx.coroutines.withTimeoutOrNull * Sends a NIP-45 COUNT query to a single relay and suspends until * the result arrives or the timeout expires. * - * A COUNT exchange is a single response message, so [timeoutMs] here is + * A COUNT exchange is a single response message, so [idleTimeoutMs] here is * trivially the package-wide idle-window convention (time since the most * recent message): no message can arrive before the one that completes it. * * @param relay Target relay to query. * @param filter The filter to count against. - * @param timeoutMs How long to wait for the response (default 15 s). + * @param idleTimeoutMs How long to wait for the response (default 15 s). * @return The [CountResult], or `null` on timeout. */ suspend fun INostrClient.count( relay: NormalizedRelayUrl, filter: Filter, - timeoutMs: Long = 15_000, + idleTimeoutMs: Long = 15_000, ): CountResult? { val subId = newSubId() val resultChannel = Channel(UNLIMITED) @@ -73,7 +73,7 @@ suspend fun INostrClient.count( count(subId = subId, filters = mapOf(relay to listOf(filter))) - withTimeoutOrNull(timeoutMs) { + withTimeoutOrNull(idleTimeoutMs) { resultChannel.receive() } } finally { @@ -91,7 +91,7 @@ suspend fun INostrClient.count( * (one filter per relay) and suspends until all results arrive * or the timeout expires. * - * [timeoutMs] is an **idle window measured from the most recent progress**, not a + * [idleTimeoutMs] is an **idle window measured from the most recent progress**, not a * wall-clock deadline for the whole batch — the package-wide accessory * convention: each *new* relay's COUNT result restarts it, so a large fan-out * where results keep trickling in is never cut short. A relay re-sending a result @@ -101,12 +101,12 @@ suspend fun INostrClient.count( * discarding the partial map, which is why this returns whatever arrived instead. * * @param filters Map of relay -> filter to count. - * @param timeoutMs Idle window between new responses (default 15 s). + * @param idleTimeoutMs Idle window between new responses (default 15 s). * @return Map of relay -> [CountResult] for every relay that responded in time. */ suspend fun INostrClient.count( filters: Map>, - timeoutMs: Long = 15_000, + idleTimeoutMs: Long = 15_000, ): Map { if (filters.isEmpty()) return emptyMap() @@ -144,7 +144,7 @@ suspend fun INostrClient.count( // window per relay without needing a wall-clock ceiling. while (results.size < filters.size) { val progressed = - withTimeoutOrNull(timeoutMs) { + withTimeoutOrNull(idleTimeoutMs) { while (true) { val (relay, result) = resultChannel.receive() // put() returns the previous value: null means this relay @@ -177,20 +177,20 @@ suspend fun INostrClient.count( * * @param relays List of relays to query. * @param filter The filter to count against. - * @param timeoutMs Idle window between responses (default 15 s) — see [count]. + * @param idleTimeoutMs Idle window between responses (default 15 s) — see [count]. * @return A merged [CountResult], or `null` if no relay responded. */ suspend fun INostrClient.countMerged( relays: List, filter: Filter, - timeoutMs: Long = 15_000, + idleTimeoutMs: Long = 15_000, ): CountResult? { if (relays.isEmpty()) return null val results = count( filters = relays.associateWith { listOf(filter) }, - timeoutMs = timeoutMs, + idleTimeoutMs = idleTimeoutMs, ) if (results.isEmpty()) return null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllExt.kt index 4874f59f92..a2b98a4ab4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllExt.kt @@ -31,47 +31,47 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer suspend fun INostrClient.fetchAll( relay: String, filter: Filter, - timeoutMs: Long = 30_000L, -) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter)), timeoutMs) + idleTimeoutMs: Long = 30_000L, +) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter)), idleTimeoutMs) suspend fun INostrClient.fetchAll( relay: String, filters: List, - timeoutMs: Long = 30_000L, -) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to filters), timeoutMs) + idleTimeoutMs: Long = 30_000L, +) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to filters), idleTimeoutMs) suspend fun INostrClient.fetchAll( subscriptionId: String = newSubId(), relay: String, filters: List, - timeoutMs: Long = 30_000L, -) = fetchAll(subscriptionId, mapOf(RelayUrlNormalizer.normalize(relay) to filters), timeoutMs) + idleTimeoutMs: Long = 30_000L, +) = fetchAll(subscriptionId, mapOf(RelayUrlNormalizer.normalize(relay) to filters), idleTimeoutMs) suspend fun INostrClient.fetchAll( relay: NormalizedRelayUrl, filter: Filter, - timeoutMs: Long = 30_000L, -) = fetchAll(newSubId(), mapOf(relay to listOf(filter)), timeoutMs) + idleTimeoutMs: Long = 30_000L, +) = fetchAll(newSubId(), mapOf(relay to listOf(filter)), idleTimeoutMs) suspend fun INostrClient.fetchAll( relay: NormalizedRelayUrl, filters: List, - timeoutMs: Long = 30_000L, -) = fetchAll(newSubId(), mapOf(relay to filters), timeoutMs) + idleTimeoutMs: Long = 30_000L, +) = fetchAll(newSubId(), mapOf(relay to filters), idleTimeoutMs) suspend fun INostrClient.fetchAll( subscriptionId: String = newSubId(), relay: NormalizedRelayUrl, filters: List, - timeoutMs: Long = 30_000L, -) = fetchAll(subscriptionId, mapOf(relay to filters), timeoutMs) + idleTimeoutMs: Long = 30_000L, +) = fetchAll(subscriptionId, mapOf(relay to filters), idleTimeoutMs) /** * Subscribe [filters], collect every (deduped) event, and return once every * relay reached a terminal state (EOSE, CLOSED, or cannot-connect) or the - * line went quiet for [timeoutMs]. + * line went quiet for [idleTimeoutMs]. * - * [timeoutMs] is an **idle window, not a hard cap**: every arriving event or + * [idleTimeoutMs] is an **idle window, not a hard cap**: every arriving event or * terminal signal resets it, so a slow relay actively streaming a large * backlog is never cropped mid-delivery. The fetch only gives up after a full * window of silence — or at the [maxTotalMs] wall-clock ceiling (default 10x @@ -85,13 +85,13 @@ suspend fun INostrClient.fetchAll( suspend fun INostrClient.fetchAll( subscriptionId: String = newSubId(), filters: Map>, - timeoutMs: Long = 30_000L, - maxTotalMs: Long = timeoutMs * 10, + idleTimeoutMs: Long = 30_000L, + maxTotalMs: Long = idleTimeoutMs * 10, ): List { val seenIds = mutableSetOf() return fetchAllWithHooks( filters = filters, - timeoutMs = timeoutMs, + idleTimeoutMs = idleTimeoutMs, subscriptionId = subscriptionId, maxTotalMs = maxTotalMs, ) { _, event -> seenIds.add(event.id) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index 8688815352..63600ff4b5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -71,7 +71,7 @@ import kotlin.coroutines.coroutineContext * * @param relay The relay to query. * @param filters Filters to apply on every page (the `until` field is overwritten per page). - * @param timeoutMs Idle window per page — like every accessory timeout, it is measured + * @param idleTimeoutMs Idle window per page — like every accessory timeout, it is measured * from the relay's **most recent message**, not from the page's start: every arriving * event resets it, so a slow relay actively streaming a large page is never cropped * mid-delivery. A page only gives up after this much silence without an EOSE. @@ -92,7 +92,7 @@ import kotlin.coroutines.coroutineContext suspend fun INostrClient.fetchAllPages( relay: NormalizedRelayUrl, filters: List, - timeoutMs: Long = 30_000L, + idleTimeoutMs: Long = 30_000L, onNewPage: ((Long) -> Unit)? = null, onEvent: (Event) -> Unit, ): Int { @@ -255,9 +255,9 @@ suspend fun INostrClient.fetchAllPages( subscribe(subId, mapOf(relay to activeFilters.map { it.value }), listener) // Wait for the page's terminal signal (EOSE / CLOSED / cannot-connect), - // giving up only after [timeoutMs] of silence — the wait resets on every + // giving up only after [idleTimeoutMs] of silence — the wait resets on every // arriving event, so an actively streaming page is never cut mid-delivery. - doneChannel.receiveWithinIdle(clock, timeoutMs) + doneChannel.receiveWithinIdle(clock, idleTimeoutMs) unsubscribe(subId) doneChannel.close() @@ -309,14 +309,14 @@ suspend fun INostrClient.fetchAllPages( suspend fun INostrClient.fetchAllPages( relay: String, filters: List, - timeoutMs: Long = 30_000L, + idleTimeoutMs: Long = 30_000L, onNewPage: ((Long) -> Unit)? = null, onEvent: (Event) -> Unit, ): Int = fetchAllPages( relay = RelayUrlNormalizer.normalize(relay), filters = filters, - timeoutMs = timeoutMs, + idleTimeoutMs = idleTimeoutMs, onNewPage = onNewPage, onEvent = onEvent, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt index be860889f7..7de482b0a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt @@ -50,7 +50,7 @@ import kotlinx.coroutines.sync.Semaphore * @param filters per-relay filter lists; the key set is the relays queried, in * iteration order (pass a [LinkedHashMap]/`associateWith` result to control it). * A `search` filter is fetched as a single relevance page — see [fetchAllPages]. - * @param timeoutMs per-page idle window handed to each relay's [fetchAllPages] — + * @param idleTimeoutMs per-page idle window handed to each relay's [fetchAllPages] — * measured from that relay's most recent message (every event resets it), not * from the page's start. As in [fetchAllPages] there is no wall-clock ceiling; * bound a relay's walk with a [Filter.limit], or cancel the caller. @@ -63,7 +63,7 @@ import kotlinx.coroutines.sync.Semaphore */ suspend fun INostrClient.fetchAllPagesFromPool( filters: Map>, - timeoutMs: Long = 30_000L, + idleTimeoutMs: Long = 30_000L, maxConcurrentRelays: Int = 8, onNewPage: ((until: Long, relay: NormalizedRelayUrl) -> Unit)? = null, onRelayStart: ((relay: NormalizedRelayUrl) -> Unit)? = null, @@ -83,7 +83,7 @@ suspend fun INostrClient.fetchAllPagesFromPool( fetchAllPages( relay = relay, filters = filtersForRelay, - timeoutMs = timeoutMs, + idleTimeoutMs = idleTimeoutMs, onNewPage = onNewPage?.let { cb -> { until -> cb(until, relay) } }, ) { event -> onEvent(event, relay) } onRelayComplete?.invoke(relay, total) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt index 978eec3b05..f3726f5fe2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt @@ -41,13 +41,13 @@ import kotlinx.coroutines.withTimeoutOrNull * funnel every arriving event through the suspending [onEvent] hook (verify / * persist / filter — return `true` to keep it in the result), and return the * accepted `(relay, event)` pairs once every relay reached a terminal state - * (EOSE, CLOSED, or cannot-connect) or the line went quiet for [timeoutMs]. + * (EOSE, CLOSED, or cannot-connect) or the line went quiet for [idleTimeoutMs]. * - * [timeoutMs] is an **idle window, not a hard cap**: the clock only runs while + * [idleTimeoutMs] is an **idle window, not a hard cap**: the clock only runs while * the relays are silent, and every arriving event or terminal signal resets * it. A slow relay actively streaming a large backlog is therefore never * cropped mid-delivery — the fetch ends when the work is done or when nothing - * has arrived for [timeoutMs] (a stall). The terminal conditions (EOSE / + * has arrived for [idleTimeoutMs] (a stall). The terminal conditions (EOSE / * CLOSED / cannot-connect per relay) are what bound the fetch; the timeout's * only job is detecting relays that will never reach one. [maxTotalMs] * (default 10x the idle window) is the wall-clock ceiling that keeps a @@ -62,20 +62,20 @@ import kotlinx.coroutines.withTimeoutOrNull * as a hard failure via [classifyDrainFailure] (connect refused / DNS / TLS / * dead HTTP upgrade — NOT slow relays or 429s) is recorded, so callers can * prune proven-dead relays from future routing instead of paying the full - * [timeoutMs] on them again. + * [idleTimeoutMs] on them again. * - **[pendingOnAuthRequired]** — a relay that refuses the REQ with an * `auth-required:` CLOSED is kept pending rather than treated as terminal: * the caller's NIP-42 responder answers the challenge and the client re-fires * this same subscription, so the post-auth events are collected instead of * returning empty. If auth never satisfies it, the relay simply falls through - * to the [timeoutMs]. + * to the [idleTimeoutMs]. * - **[onTimeout]** — diagnostic hook fired when the idle window elapsed with * relays still pending: receives the stalled set, the terminal reasons seen so * far (`"eose"` / `"closed:"` / `"cannot:"`), and what was collected. */ suspend fun INostrClient.fetchAllWithHooks( filters: Map>, - timeoutMs: Long = 8_000L, + idleTimeoutMs: Long = 8_000L, subscriptionId: String = newSubId(), pendingOnAuthRequired: Boolean = false, deadOut: MutableMap? = null, @@ -87,10 +87,10 @@ suspend fun INostrClient.fetchAllWithHooks( * restores an upper bound while staying far above the idle window, so a * legitimately streaming relay still finishes its backlog. Pass * [Long.MAX_VALUE] for a deliberately uncapped drain; a non-positive value - * also uncaps (absorbing a `timeoutMs * 10` overflow from an + * also uncaps (absorbing an `idleTimeoutMs * 10` overflow from an * effectively-infinite idle window). */ - maxTotalMs: Long = timeoutMs * 10, + maxTotalMs: Long = idleTimeoutMs * 10, onEvent: suspend (relay: NormalizedRelayUrl, event: Event) -> Boolean, ): List> { if (filters.isEmpty()) return emptyList() @@ -190,7 +190,7 @@ suspend fun INostrClient.fetchAllWithHooks( // Slow path: both dry — arm one idle wait for the next signal. if (pending == null) { val progressed = - withTimeoutOrNull(timeoutMs) { + withTimeoutOrNull(idleTimeoutMs) { select { eventChannel.onReceive { pending = it } doneChannel.onReceive { (relay, reason) -> @@ -256,7 +256,7 @@ suspend fun INostrClient.fetchAllWithHooks( */ suspend fun INostrClient.fetchAllPagesFromPoolWithHooks( filters: Map>, - timeoutMs: Long = 30_000L, + idleTimeoutMs: Long = 30_000L, maxConcurrentRelays: Int = 8, onEvent: suspend (relay: NormalizedRelayUrl, event: Event) -> Boolean, ): List> { @@ -287,7 +287,7 @@ suspend fun INostrClient.fetchAllPagesFromPoolWithHooks( try { fetchAllPagesFromPool( filters = filters, - timeoutMs = timeoutMs, + idleTimeoutMs = idleTimeoutMs, maxConcurrentRelays = maxConcurrentRelays, ) { event, relay -> eventChannel.trySend(relay to event) } } finally { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt index 45e28e7072..b4fa621ae2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt @@ -69,7 +69,7 @@ suspend fun INostrClient.fetchFirst( * every relay reached a terminal state — EOSE, CLOSED, or cannot-connect — with * nothing matching, or the line went quiet). * - * [timeoutMs] is an **idle window measured from the most recent progress**, not a + * [idleTimeoutMs] is an **idle window measured from the most recent progress**, not a * wall-clock deadline — the package-wide accessory convention. Progress means a * signal that actually advances the fetch: an event, or the first terminal state * from a relay still being waited on. Repeat chatter from a relay already @@ -86,7 +86,7 @@ suspend fun INostrClient.fetchFirst( suspend fun INostrClient.fetchFirst( subscriptionId: String = newSubId(), filters: Map>, - timeoutMs: Long = 30_000L, + idleTimeoutMs: Long = 30_000L, ): Event? { val eventChannel = Channel(UNLIMITED) val doneChannel = Channel(UNLIMITED) @@ -137,7 +137,7 @@ suspend fun INostrClient.fetchFirst( // advance escapes to the outer loop and earns a fresh window. while (remaining.isNotEmpty()) { val progressed = - withTimeoutOrNull(timeoutMs) { + withTimeoutOrNull(idleTimeoutMs) { while (true) { val advanced = select { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md index 8933529397..14472bb416 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md @@ -14,11 +14,16 @@ Import as `com.vitorpamplona.quartz.nip01Core.relay.client.accessories.` ( ## Timeout convention -Every `timeoutMs` / `idleTimeoutMs` in this package is an **idle window measured -from the relay's most recent progress**, never a wall-clock deadline: real progress -resets it, so an actively streaming relay is never cut off mid-delivery — the -operation only gives up after a full window of silence. The shared primitives are in -`IdleWatchdog.kt` (`IdleClock` + `receiveWithinIdle`); use them in a new accessory. +Every wait in this package is an **idle window measured from the relay's most recent +progress**, never a wall-clock deadline: real progress resets it, so an actively +streaming relay is never cut off mid-delivery — the operation only gives up after a +full window of silence. The shared primitives are in `IdleWatchdog.kt` (`IdleClock` + +`receiveWithinIdle`); use them in a new accessory. + +**The parameter is named `idleTimeoutMs`, never `timeoutMs`** — the name is the +contract, so a caller can't mistake it for a deadline. The sole exception is +`publishAndConfirm`'s `timeoutInSeconds`, which genuinely *is* a fixed window (see +below); the differing name is the tell. **Progress, not merely traffic.** A message that tells us nothing new — a relay re-CLOSEing after we already recorded it as done, a duplicate COUNT — must not @@ -53,9 +58,9 @@ window to collect the `OK`s — a bounded confirmation round-trip, not a stream. | Function | File | Use when | | --- | --- | --- | -| `fetchAll(relay, filter, timeoutMs)` | `NostrClientFetchAllExt` | Get every event matching a filter in one REQ, deduped by id, until EOSE or a full idle window of silence. **No verify, no store** — just the events. | -| `fetchFirst(relay, filter, timeoutMs)` | `NostrClientFetchFirstExt` | Get the first matching event and stop (returns `null` on none/timeout). | -| `fetchAllPages(relay, filters, timeoutMs)` | `NostrClientFetchAllPagesExt` | Fully retrieve a result set larger than the relay's per-REQ cap (strfry `limit`, ~500) by walking a `created_at` cursor. Bound it with the filter's `limit`. | +| `fetchAll(relay, filter, idleTimeoutMs)` | `NostrClientFetchAllExt` | Get every event matching a filter in one REQ, deduped by id, until EOSE or a full idle window of silence. **No verify, no store** — just the events. | +| `fetchFirst(relay, filter, idleTimeoutMs)` | `NostrClientFetchFirstExt` | Get the first matching event and stop (returns `null` on none/timeout). | +| `fetchAllPages(relay, filters, idleTimeoutMs)` | `NostrClientFetchAllPagesExt` | Fully retrieve a result set larger than the relay's per-REQ cap (strfry `limit`, ~500) by walking a `created_at` cursor. Bound it with the filter's `limit`. | | `fetchAllPagesFromPool(filters, ...)` | `NostrClientFetchAllPagesPoolExt` | Same paging, across several relays at once. No cross-relay dedup — the `WithHooks` variant below dedups. | | `fetchAllWithHooks(filters, ...)` | `NostrClientFetchAllWithHooksExt` | `fetchAll` with a suspending per-`(relay, event)` accept hook (verify+store as events arrive), per-relay terminal-reason tracking, optional dead-relay collection (`deadOut` + `classifyDrainFailure`), keep-pending-on-`auth-required` CLOSED (NIP-42 re-fire), and a timeout diagnostic hook. | | `fetchAllPagesFromPoolWithHooks(filters, ...)` | `NostrClientFetchAllWithHooksExt` | `fetchAllPagesFromPool` with the same suspending accept hook, run single-threaded in one consumer; deduped across relays by `SeenIds` before the hook. | @@ -79,7 +84,7 @@ window to collect the `OK`s — a bounded confirmation round-trip, not a stream. | Function | File | Use when | | --- | --- | --- | -| `count(relay, filter, timeoutMs)` | `NostrClientCountExt` | NIP-45 `COUNT` against one relay (`null` on timeout / no support). | +| `count(relay, filter, idleTimeoutMs)` | `NostrClientCountExt` | NIP-45 `COUNT` against one relay (`null` on timeout / no support). | | `countMerged(relays, filter, ...)` | `NostrClientCountExt` | Merged count across relays. | ## Negentropy (NIP-77) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchAllIdleTimeoutTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchAllIdleTimeoutTest.kt index 1188e843ce..d6704de0ce 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchAllIdleTimeoutTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchAllIdleTimeoutTest.kt @@ -89,7 +89,7 @@ class FetchAllIdleTimeoutTest { val collected = client.fetchAllWithHooks( filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))), - timeoutMs = 300, + idleTimeoutMs = 300, ) { _, _ -> true } feeder.join() assertEquals(10, collected.size, "an actively streaming relay must never be cropped") @@ -111,7 +111,7 @@ class FetchAllIdleTimeoutTest { val collected = client.fetchAllWithHooks( filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))), - timeoutMs = 300, + idleTimeoutMs = 300, onTimeout = { stalled, _, _ -> stalledRelays = stalled }, ) { _, _ -> true } assertEquals(2, collected.size, "events before the stall are kept") @@ -136,7 +136,7 @@ class FetchAllIdleTimeoutTest { val events = client.fetchAll( filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))), - timeoutMs = 300, + idleTimeoutMs = 300, ) feeder.join() assertEquals(10, events.size, "fetchAll shares the idle-window semantics") @@ -162,7 +162,7 @@ class FetchAllIdleTimeoutTest { val collected = client.fetchAllWithHooks( filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))), - timeoutMs = 300, + idleTimeoutMs = 300, maxTotalMs = 1_000, onTimeout = { stalled, _, _ -> stalledRelays = stalled }, ) { _, _ -> true } @@ -186,7 +186,7 @@ class FetchAllIdleTimeoutTest { val collected = client.fetchAllWithHooks( filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))), - timeoutMs = 300, + idleTimeoutMs = 300, ) { _, _ -> true } assertEquals(1, collected.size) assertTrue(currentTime - start < 300, "a terminal EOSE must not wait out the window") diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchFirstIdleTimeoutTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchFirstIdleTimeoutTest.kt index d52656fc62..bfe595a655 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchFirstIdleTimeoutTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchFirstIdleTimeoutTest.kt @@ -38,10 +38,11 @@ import kotlin.test.assertEquals import kotlin.test.assertNull /** - * Pins [fetchFirst]'s timeout to the package-wide idle-window convention: - * [timeoutMs] is silence measured from the most recent relay signal, not an - * absolute deadline across the whole multi-relay wait — with [maxTotalMs] as - * the wall-clock ceiling. + * Pins [fetchFirst]'s timeout to the package-wide convention: `idleTimeoutMs` is + * silence measured from the most recent *progress*, not an absolute deadline + * across the whole multi-relay wait. Repeat chatter from a relay already + * accounted for is not progress, which is what makes the call self-bounding + * without a ceiling parameter — a hard bound is the caller's `withTimeoutOrNull`. */ @OptIn(ExperimentalCoroutinesApi::class) class FetchFirstIdleTimeoutTest { @@ -97,7 +98,7 @@ class FetchFirstIdleTimeoutTest { val result = client.fetchFirst( filters = filters(relayA, relayB, relayC, relayD), - timeoutMs = 300, + idleTimeoutMs = 300, ) assertEquals(event(1).id, result?.id, "progress must restart the window; the slow relay's event still lands") } @@ -110,7 +111,7 @@ class FetchFirstIdleTimeoutTest { val result = client.fetchFirst( filters = filters(relayA), - timeoutMs = 300, + idleTimeoutMs = 300, ) assertNull(result) assertEquals(300L, currentTime - start, "a silent relay costs exactly one idle window") @@ -136,7 +137,7 @@ class FetchFirstIdleTimeoutTest { val result = client.fetchFirst( filters = filters(relayA, relayB), - timeoutMs = 300, + idleTimeoutMs = 300, ) chatter.cancel() assertNull(result) @@ -160,7 +161,7 @@ class FetchFirstIdleTimeoutTest { val result = client.fetchFirst( filters = filters(relayA), - timeoutMs = 300, + idleTimeoutMs = 300, ) assertEquals(event(7).id, result?.id, "an event racing the final EOSE must not be dropped") } @@ -180,7 +181,7 @@ class FetchFirstIdleTimeoutTest { // is the wall-clock bound, and costs nothing because a timed-out // fetchFirst yields null either way. val start = currentTime - val result = withTimeoutOrNull(120) { client.fetchFirst(filters = filters(relayA, relayB), timeoutMs = 10_000) } + val result = withTimeoutOrNull(120) { client.fetchFirst(filters = filters(relayA, relayB), idleTimeoutMs = 10_000) } chatter.cancel() assertNull(result) assertEquals(120L, currentTime - start, "the caller's timeout bounds the call") diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt index b6367b0f2d..fb9855a7a6 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt @@ -40,7 +40,7 @@ import kotlin.time.TimeSource /** * Pins [fetchAllPages]'s timeout to the package-wide idle-window convention: - * `timeoutMs` is silence measured from the relay's MOST RECENT message, not a + * `idleTimeoutMs` is silence measured from the relay's MOST RECENT message, not a * wall-clock deadline for the page. A relay that keeps streaming — however * slowly — must never have a page cropped mid-delivery. * @@ -106,7 +106,7 @@ class NostrClientFetchAllPagesIdleTimeoutTest { client.fetchAllPages( relay = relay, filters = listOf(Filter(kinds = listOf(1), limit = 6)), - timeoutMs = 500, + idleTimeoutMs = 500, onNewPage = { pages++ }, ) { got.add(it) } feeder.join() @@ -140,7 +140,7 @@ class NostrClientFetchAllPagesIdleTimeoutTest { client.fetchAllPages( relay = relay, filters = listOf(Filter(kinds = listOf(1), limit = 3)), - timeoutMs = 300, + idleTimeoutMs = 300, ) { got.add(it) } feeder.join() val elapsedMs = start.elapsedNow().inWholeMilliseconds @@ -151,9 +151,8 @@ class NostrClientFetchAllPagesIdleTimeoutTest { } /** - * Documents why [fetchAllPages] has no wall-clock ceiling, unlike the - * single-wait accessories (`fetchAll`/`fetchFirst`/`count`, whose `maxTotalMs` - * really does end the call). + * Documents why [fetchAllPages] has no wall-clock ceiling — nor should any + * accessory: a hard bound composes at the call site as `withTimeoutOrNull`. * * A per-page ceiling cannot bound this walk: when a page ends, the loop advances * the cursor and fires the NEXT `REQ`, so an endless trickle against an unbounded @@ -181,7 +180,7 @@ class NostrClientFetchAllPagesIdleTimeoutTest { client.fetchAllPages( relay = relay, filters = listOf(Filter(kinds = listOf(1))), // unbounded: no limit - timeoutMs = 200, + idleTimeoutMs = 200, ) { } } feeder.cancel() diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/BulkDownloadBenchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/BulkDownloadBenchmark.kt index 3d42b1b367..c79883c4c2 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/BulkDownloadBenchmark.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/BulkDownloadBenchmark.kt @@ -141,7 +141,7 @@ class BulkDownloadBenchmark { client.fetchAllPages( relay = relayUrl, filters = listOf(Filter(kinds = listOf(1), since = lo, until = hi)), - timeoutMs = LOCAL_PAGE_TIMEOUT_MS, + idleTimeoutMs = LOCAL_PAGE_TIMEOUT_MS, ) { event -> count.incrementAndGet() bytes.addAndGet(event.content.length.toLong()) @@ -376,7 +376,7 @@ class BulkDownloadBenchmark { client.fetchAllPages( relay = relay, filters = listOf(Filter(kinds = listOf(PROD_KIND), limit = PROD_MAX_EVENTS)), - timeoutMs = 30_000L, + idleTimeoutMs = 30_000L, onNewPage = { pages++ }, ) { event -> count.incrementAndGet() @@ -672,7 +672,7 @@ class BulkDownloadBenchmark { client.fetchAllPages( relay = relay, filters = listOf(Filter(kinds = listOf(PROD_KIND), limit = PROD_MAX_EVENTS)), - timeoutMs = 30_000L, + idleTimeoutMs = 30_000L, onNewPage = { pages++ }, ) { event -> count.incrementAndGet() diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ByIdFetchBenchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ByIdFetchBenchmark.kt index 251b043eff..6dd57131e0 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ByIdFetchBenchmark.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ByIdFetchBenchmark.kt @@ -280,7 +280,7 @@ class ByIdFetchBenchmark { client.fetchAllPages( relay = relay, filters = listOf(Filter(kinds = listOf(KIND))), - timeoutMs = ENUM_PAGE_TIMEOUT_MS, + idleTimeoutMs = ENUM_PAGE_TIMEOUT_MS, ) { event -> ids.add(event.id) } println(" enumerated ${ids.size} ids in %.1fs (paged, untimed baseline for the matrix)".format((System.nanoTime() - t) / 1e9)) ids.toList() From 64073f8fa92a7284e204794339efef9af6837eff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 04:28:18 +0000 Subject: [PATCH 080/227] perf: drop per-event iterator alloc; harden count/fetchFirst drain loops Audit follow-up on the timeout work. - fetchAllPages matched each event with `for ((i, f) in activeFilters)`. That destructuring form allocates an Iterator on every event, on the relay's reader thread, for the whole download -- millions of short-lived objects in a bulk walk. Switched to an indexed loop, which is why quartz uses the fast* operators elsewhere in hot event paths (those only cover Array, so a List needs the index form). - count() and fetchFirst() drain their channels in an inner loop that only suspends when the channel is empty, so a backlog was consumed with no cancellation check: neither the idle window expiring nor the caller giving up could interrupt it mid-drain. Added an explicit ensureActive(), matching the check fetchAllPages already does per page. - count() could also lose a result that arrived after the last window closed but before unsubscribe, understating the returned map. Added the post-loop tryReceive drain that fetchAllWithHooks and fetchFirst have. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb --- .../client/accessories/NostrClientCountExt.kt | 14 ++++++++++++++ .../accessories/NostrClientFetchAllPagesExt.kt | 11 ++++++++++- .../client/accessories/NostrClientFetchFirstExt.kt | 8 ++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt index 09f8941053..0f19d9d75c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt @@ -32,7 +32,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip45Count.HyperLogLog import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.coroutineContext /** * Sends a NIP-45 COUNT query to a single relay and suspends until @@ -146,6 +148,11 @@ suspend fun INostrClient.count( val progressed = withTimeoutOrNull(idleTimeoutMs) { while (true) { + // Cancellation (this window expiring, or the caller giving up) + // only lands at a suspension point, and receive() does not + // suspend while the channel has buffered results — so check + // explicitly rather than draining a backlog uninterruptibly. + coroutineContext.ensureActive() val (relay, result) = resultChannel.receive() // put() returns the previous value: null means this relay // had not answered yet, i.e. real progress. @@ -155,6 +162,13 @@ suspend fun INostrClient.count( } if (progressed == null) break } + + // A result can land after the last window closed but before we unsubscribe; + // it costs nothing to keep, and dropping it would understate the count. + while (true) { + val (relay, result) = resultChannel.tryReceive().getOrNull() ?: break + results[relay] = result + } } finally { subIdToRelay.keys.forEach { unsubscribe(it) } removeConnectionListener(listener) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index 63600ff4b5..f43aed278f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -201,7 +201,16 @@ suspend fun INostrClient.fetchAllPages( // next page skip events a co-resident normal filter still needs. var atLeastOne = false var advancesCursor = false - for ((index, filter) in activeFilters) { + // Indexed loop, not `for ((i, f) in activeFilters)`: this runs for + // EVERY event on the relay's reader thread (millions in a bulk + // download) and the destructuring form allocates an Iterator per + // event. Same reason quartz uses the `fast*` operators elsewhere + // in hot event paths — those only cover Array, so a List needs + // the index form. + for (i in activeFilters.indices) { + val active = activeFilters[i] + val index = active.index + val filter = active.value if (matchCountPerFilter[index] < (filter.limit ?: Int.MAX_VALUE) && filter.match(event)) { matchCountPerFilter[index]++ atLeastOne = true diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt index b4fa621ae2..2a80fcfa45 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt @@ -29,8 +29,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.selects.select import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.coroutineContext suspend fun INostrClient.fetchFirst( relay: String, @@ -139,6 +141,12 @@ suspend fun INostrClient.fetchFirst( val progressed = withTimeoutOrNull(idleTimeoutMs) { while (true) { + // Cancellation (this window expiring, or the caller giving up) + // only lands at a suspension point, and select() completes + // without suspending while either channel has something + // buffered — so check explicitly rather than draining a + // backlog of chatter uninterruptibly. + coroutineContext.ensureActive() val advanced = select { eventChannel.onReceive { event -> From 30742ab352b2f233eae938f7e75d7dd8927ef3a4 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:35:55 +0000 Subject: [PATCH 081/227] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-hi-rIN/strings.xml | 81 ++ .../src/main/res/values-hu-rHU/strings.xml | 781 +++++++++++++++++- .../values-hu-rHU/strings.xml | 80 ++ docs/changelog/translators.json | 23 +- 4 files changed, 954 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 43aed37ee7..14c4d09d1d 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1942,14 +1942,95 @@ + + %1$s \u00b7 %2$d पुनःप्रसारक + %1$s \u00b7 %2$d पुनःप्रसारक + + जालभ्रमण + ध्वनिचित्राभिलेख + विषयसूचक + विषय सूची + वार्तालाप + खोज + लुप्त घटनाओं को ढूँढें + घटना अवलोकन + घटनाओं को विभेदक अनुसार ले आता है जिसका उल्लेख आपके पटल पर अमुक करता है पर जिसकी प्राप्ती अभी नहीं हुई। एक उद्धरण अथवा एक प्रत्युत्तर का पूर्वपत्र अथवा एक सूत्र का मूल। + घटनाओं का अवलोकन करता है जो वर्तमान में प्रदर्शित हो रहे हैं नए प्रत्युत्तर प्रतिक्रियाएँ उद्धरण ज्साप तथा वृत्तान्तों के लिए जिससे गिनतियों का नवीकरण होता है जब आप पढ रहे हैं। + संलग्न + पुनःप्रसारक जानकारी + अन्य + पुनःप्रसारक सूची खोजकर्ता + परिचय अवलोकन + लेखा जानकारी + मुख्य सूचनावली + पुनःप्रसारक समूह + + %1$d समूह + %1$d समूह + + अस्थायी चर्चाएँ + स्थानीय चर्चाएँ + वर्तमानप्रवाह चर्चा + निप॰२९ समूह जिनसे आप जुडे हैं। प्रत्येक समूह एक जालावास पुनःप्रसारक में रहता है। इसलिए क्रमक प्रत्येक पुनःप्रसारक से संयोजन करता है जो आपके किसी समूह का जालावास है। + चर्चाशालाएँ जो कोई इतिहास नहीं रखते। सन्देश केवल तब तक रहते हैं जब तक आप संयोजित हैं। इसलिए ये ग्राहकता बनाए रखते हैं कुछ भी प्राप्त होने के लिए। + स्थान आधारित शालाएँ उन क्षेत्रों के लिए जिनका आप अनुगमन करते हैं। पृष्ट उन पुनःप्रसारको से जो इनके जालावास हैं। + चर्चा तथा ज्साप उद्देश्य जो वर्तमानप्रवाहों से संलग्न हैं जिन्हें आप खोले हुए हैं अथवा अनुगमन करते हैं। + सीधासन्देश आगतपेटिका + धनकोष + नटज्साप आगतपेटिका + टकसाल निर्देशिका + धनकोष संयोजन + समुदाय चर्चाएँ + समुदाय सूचनावलियाँ + आपके आगतपेटिका पुनःप्रसारक तथा कुछ अल्पमात्रा परिभ्रमणवर्ती दृष्टान्त पुनःप्रसारक जिनपर आपके अनुचरित पत्र प्रकाशित करते हैं। यदि कोई उल्लेख अन्यत्र भेजा गया। + आपके सीधासन्देश पुनःप्रसारक। जहाँ उपहारकोषयुक्त सन्देश भेजे जाते हैं। + मुख्य पुनःप्रसारक प्रत्येक चर्चा का जिन्हें आप खोल रखे हैं अथवा जिनसे आप जुड चुके हैं। + पुनःप्रसारक जिनपर प्रत्येक समुदाय अपने पत्र प्रकाशित करते हैं। + समूह सन्देश तथा कुंचिकापेटलियाँ। प्रत्येक समूह के पुनःप्रसारकों पर। + शाला के पुनःप्रसारक। जब वह खुला हो। + आपके अपने परिचय तथा स्थापना विकल्प तथा पाण्डुलिपियाँ। आपके मुख्य पुनःप्रसारकों पर। + वर्तमानतः पटल पर लोगों के परिचय। + खोजता है किन पुनःप्रसारकों पर प्रत्येक व्यक्ति प्रकाशन करता है। जिससे कि उनके पत्र सम्यक स्थल से प्राप्प हो। + अनुचरण सूचियाँ। आपकी सूचनावली तथा आपका विश्वासजाल का निर्माण के लिए उपयुक्त। + वृत्तान्त जो आपके अनुचरितों ने लिखा वर्तमानतः आपके पटल पर दिखनेवाले परिचयों के विषय में। पृष्ट प्रत्येक पुनःप्रसारक से जिनपर वे पत्र प्रकाशन करते हैं। + अनुचरित से वृत्तान्त + आपके अपने धनकोष घटनाएँ। पुनःपठित उन पुनःप्रसारकों से जिनपर आपने उनके प्रकाशन किए। + सुनता है आपके नटज्साप पुनःप्रसारकों पर तथा आपके आगतपेटिका तथा सीधासन्देश पुनःप्रसारकों पर। जिससे कि कोई भी भुगतान छूट ना जाए। + पुनःप्रसारकों का वीक्षण करता है यह देखने के लिए कि कौनसे टकसाल हैं तथा लोग किनकी अनुशम्सा करते हैं। + आपके संयोजित धनकोष से सूचनाएँ। + सक्रिय पुनःप्रसारक ग्राहकताएँ + + %1$d छलनी + %1$d छलनियाँ + + + %1$d पुनःप्रसारक + %1$d पुनःप्रसारक + + + %1$d छलनी आरोपित नहीं अब तक + %1$d छलनियाँ आरोपित नहीं अब तक + + %1$s \u00b7 %2$s + किसी लेखा प्रति आरोपित नहीं + सभी + सभी + आपके द्वारा अनुचरित लोग + चयनित लोगों की सूची + मौनकृत लोग + आपके समुदाय + एक प्रिय कलनविधि सूचनावली + %1$dप्रतिशतप्रतिशत सब में से + ग्राहकताएँ छलनियाँ पुनःप्रसारक अनुरोध अनु॰ संयोजन क्यों निदानतन्त्र + आपके अनुचरितों के पत्र। पठित उन पुनःप्रसारकों से जिनपर उनमें से प्रत्येक प्रकाशन करते हैं। आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026 सदैव सक्रिय सूचना सेवा अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे। diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 5c65609348..bb09b99b56 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -20,13 +20,19 @@ +%1$d\nválasz +%1$d\nválasz + + %1$d Ön által követett személy jelentette + %1$d Ön által követett személy jelentette + Az esemény épp betöltődik vagy nem található az átjátszólistában 👀 Csatorna profilképe A hivatkozott esemény nem található Nem sikerült visszafejteni az üzenetet + Visszafejtés… + Ön: %1$s Csoport profilképe Szókimondó tartalom Átjátszóval kapcsolatos megjegyzések @@ -304,6 +310,10 @@ Ez a meghívási hivatkozás érvénytelen, vagy nem nyitható meg ezzel a fiókkal. Ez a meghívó az alkalmazás egy újabb verziójával készült, és itt még nem nyitható meg. Kérjen egy frissített linket, vagy a frissítés után próbálja újra. Ezt a meghívási hivatkozást visszavonták, és már nem használható. Kérjen egy újat. + Ez a meghívó hivatkozás lejárt és már nem használható. Kérjen egy új hivatkozást. + A közösség neve csak a csatlakozás után válik láthatóvá + A csatlakozás kapcsolódik a meghívóhoz tartozó átjátszókhoz, közzéteszi a fiókja által aláírt csatlakozási értesítést, és hozzáadja a közösséget az Ön listájához. Semmi sem kerül elküldésre, amíg a Csatlakozás gombra nem koppint. + Átjátszók, amelyekkel ez a meghívó felveszi a kapcsolatot: %1$s Concord-csatornák Még nem csatlakozott egyetlen Concord-csatornához sem. Hozzon létre egyet, vagy nyisson meg egy meghívóhivatkozást. Még nincsenek csatornák. @@ -319,7 +329,12 @@ Biztosan törli a csatornát? Biztosan törli a(z) #%1$s csatornát? Ez a művelet nem vonható vissza, és a csatorna nem hozható létre újra ugyanezzel az azonosítóval. Törlés + Közösség elhagyása + Elhagyja a közösséget? + Elhagyja a következőt: %1$s? Eltávolításra kerül ezen fiók listájáról, és leáll a szinkronizálása az Ön eszközein. A közösség nem kap értesítést, és Ön nem kerül le a tagok listájáról. Azok az üzenetek, amelyeket már nem tud visszafejteni, helyreállíthatatlanná válhatnak, és csak új meghívóval térhet vissza. + Ön hozta létre ezt a közösséget. A kilépés nem törli, és nem is adja át másnak, de eldobja a listáján tárolt tulajdonosi kulcsot — a jövőben nem fogja tudni kezelni. Ahol a közösség titkosított síkjait közzéteszik és olvassák. + Ezt a közösséget feloszlatták, és most már csak olvasható. Az előzményeket továbbra is olvashatja, de új üzeneteket már nem lehet közzétenni. %1$s gépel… %1$s és %2$s gépel… Többen is gépelnek… @@ -332,6 +347,14 @@ %1$d csatorna %1$d csatorna + + %1$d munkaterület + %1$d munkaterület + + + %1$d csatorna + %1$d csatorna + %1$d tag %1$d tag @@ -363,6 +386,13 @@ Eltávolítja a tagot? Ez megváltoztatja a közösség titkosítási kulcsát, így ez a tag már nem tudja elolvasni az ezt követően küldött üzeneteket. Mindenki más kulcsa automatikusan frissül. Ez a művelet nem vonható vissza. Eltávolítás + Szerepkörök… + Szerepkörök hozzárendelése + Válassza ki az összes szerepkört, amelyet ennek a tagnak be kell töltenie. A jelölés megszüntetése eltávolítja azt. + Mentés + Ön nem áll magasabb rangban, mint ez a tag + Nincsenek Ön által hozzárendelhető szerepkörök + Nem sikerült frissíteni a tag szerepköreit. Tulajdonos Adminisztrátor Kitiltva @@ -381,6 +411,7 @@ Szál + Kép küldése %1$d válasz @@ -393,7 +424,13 @@ Nem sikerült megtalálni ezt az üzenetet Minden átjátszó le lett kérve · koppintson ide a megtekintéshez + Ki jelentette ezt? + Eltüntetés + Elrejtés + Valaki, akit Ön követ, megjelölte ezt a fiókot + Jelentve + +%1$d "Hiba a válaszok betöltésekor: " Próbálja újra Még nincsenek értesítések. @@ -402,6 +439,8 @@ Frissítés Új csevegési profil: Elhagyás + Eltávolítás az Üzenetekből + Hozzáadás az Üzenetekhez Követés megszüntetése Csatorna létrehozva "A csatornainformáció a következőre módosult:" @@ -491,6 +530,15 @@ Kép webcímének megosztása Előnézet előállítása… Megosztva az Amethysten keresztül· + Megosztás QR-kódként + Webes hivatkozás + Nostr hivatkozás + Olvassa be bármilyen telefonkamerával + Olvassa be egy Nostr alkalmazással + Kép + Ezt a jegyzetet mutató webes hivatkozást tartalmazó QR-kód + Ezt a jegyzetet mutató Nostr hivatkozást tartalmazó QR-kód + A bélyegkép elrejtve érzékeny tartalom miatt Szerző-azonosító Bejegyzés-azonosító Szöveg másolása @@ -718,6 +766,10 @@ Kapcsolódás %1$s km Health Connectből + + %1$d tevékenység + %1$d tevékenység + Futás Gyaloglás Kerékpározás @@ -816,6 +868,7 @@ Ez a nKisalkalmazás az Ön privát tárhelyét szeretné használni. Ez a nKisalkalmazás ki szeretne fizetni egy Lightning számlát. + ⚠ Ez a nApplet egy olyan Lightning számlát akar kifizetni, amelyen NINCS összeg megadva — a kedvezményezett dönti el, mennyit von le. Csak akkor engedélyezze, ha megbízik benne. Ez a nKisalkalmazás le szeretne kérni egy webes erőforrást. Ez a nKisalkalmazás fel szeretne tölteni egy fájlt az Ön médiakiszolgálójára. Ez a nKisalkalmazás értesítéseket szeretne megjeleníteni Önnek. @@ -828,11 +881,56 @@ kind-only summary would hide what is actually being signed. These replaceable lists are already cached on the account, so the dialog diffs the proposed list against the current one and reports what actually changes rather than a raw total. --> + + %1$d új fiókot követ + %1$d új fiókot követ + + + MEGSZÜNTETI %1$d fiók követését + MEGSZÜNTETI %1$d fiók követését + + + hozzáad %1$d átjátszót + hozzáad %1$d átjátszót + + + ELTÁVOLÍT %1$d átjátszót + ELTÁVOLÍT %1$d átjátszót + + + némít további %1$d személyt + némít további %1$d személyt + + + VISSZAVONJA %1$d személy némítását + VISSZAVONJA %1$d személy némítását + + ⚠ Ez elkezdi követni a következőt: %1$s. + ⚠ Ez MEGSZÜNTETI %1$s követését. + ⚠ Ez némítja a következőt: %1$s. + ⚠ Ez VISSZAVONJA %1$s némítását. + ⚠ Ez felülírja a követési listáját: %1$s. + ⚠ Ez felülírja az átjátszók listáját: %1$s. A bejegyzései és olvasásai vele együtt mozognak. + ⚠ Ez felülírja a némítási listáját: %1$s. A némított szavak és hashtagek nem jelennek meg itt — koppintson az „Esemény megjelenítése” elemre a teljes listához. + %1$s és %2$s + Ez változatlan formában újból közzéteszi az Ön meglévő listáját. + + ⚠ Ez egy %1$d bejegyzésből álló listát ír. Az Amethyst nem rendelkezik gyorsítótárazott másolattal az összehasonlításhoz. + ⚠ Ez egy %1$d bejegyzésből álló listát ír. Az Amethyst nem rendelkezik gyorsítótárazott másolattal az összehasonlításhoz. + + + ⚠ Ez az Ön %1$d eseményének törlését kéri. + ⚠ Ez az Ön %1$d eseményének törlését kéri. + + + %1$d címkét tartalmaz. Koppintson az „Esemény megjelenítése” elemre, hogy pontosan lássa, mi kerülne aláírásra. + %1$d címkét tartalmaz. Koppintson az „Esemény megjelenítése” elemre, hogy pontosan lássa, mi kerülne aláírásra. + Kapcsolódás a NOSTR-hoz szeretne csatlakozni a saját Nostr-fiókjához @@ -869,9 +967,13 @@ saját privát üzenetek olvasása + elolvashatja a magánüzeneteit vele: %1$s + Mindig engedélyezze a következőnek: %1$s + Az Amethyst nem tudta visszafejteni ezt az üzenetet. Lehet, hogy nem ennek a fióknak címezték. + Üzenetek vele: Összekapcsolt alkalmazások Minden engedély visszavonása @@ -883,6 +985,93 @@ Megtagadás alkalmazások, engedélyek, aláíró, napplet, nsite, webalkalmazás, bizalom, kapcsolódott + Távoli aláíró + aláíró bunker nip46 nostr kapcsolódás távoli aláírás + Engedélyezze más alkalmazásoknak a saját kulcsával történő aláírást. Az Amethyst figyeli az Ön bejövő átjátszóit, és aláírás előtt ellenőrzi minden alkalmazás jogosultságait — ugyanazokat a megbízhatósági szinteket használva, mint a Kapcsolódó alkalmazások. + Működjön távoli aláíróként + + Figyelés %1$d átjátszón + Figyelés %1$d átjátszón + + Nincsenek bejövő átjátszók beállítva. Adjon hozzá bejövő átjátszókat, hogy az alkalmazások elérhessék az aláíróját. + + Figyelés %1$d átjátszón + Figyelés %1$d átjátszón, mindegyikhez kapcsolódva + + %2$d/%1$d átjátszóhoz kapcsolódva + Az Ön bunker címe + Illessze be ezt egy másik alkalmazásba, hogy a kulcsához kapcsolja azt. + Másolás + Bunker címe másolva + Új cím + Új bunker cím generálva + Új címet generál? + Ez egy új bunker címet hoz létre, és minden jelenleg kapcsolódó alkalmazást leválaszt. Bárki, aki rendelkezik a régi címmel — beleértve a spammereket is — többé nem érheti el Önt. Kapcsolja össze újra a saját alkalmazásait az új kód beolvasásával. + Generálás + Mégsem + Alkalmazás kapcsolása + Illesszen be egy nostrconnect:// hivatkozást + Kapcsolódás + Kapcsolódó alkalmazások kezelése + Még nincsenek alkalmazások kapcsolva az Ön aláírójához.\n\nOlvassa be vagy illessze be egy alkalmazás kódját a kapcsolódáshoz. A kapcsolódó alkalmazások itt jelennek meg, az inaktívak pedig egy hét után automatikusan eltávolításra kerülnek. + utoljára használva: %1$s + Kapcsolódva + Offline + Újrakapcsolódás + Újrakapcsolódás az átjátszókhoz… + Átjátszók + Az Ön bejövő átjátszóin keresztül ír alá — ez az alkalmazás nem hozott sajátot, így nem tart fenn extra háttérkapcsolatot. + Az Amethyst mindegyikhez fenntart egy háttérkapcsolatot, amíg ez az alkalmazás kapcsolódva marad. Felejtse el az alkalmazást a kapcsolatuk eldobásához. + + %1$d átjátszó + %1$d átjátszó + + Az alkalmazás kapcsolódott. Az Amethyst mostantól a háttérben ír alá a számára. + Kapcsolódva: %1$s. Az Amethyst mostantól a háttérben ír alá a számára. + Nem érvényes nostrconnect:// hivatkozás + Ez a hivatkozás nem tartalmaz átjátszót a kapcsolódáshoz + Nem sikerült kapcsolódni: %1$s + A kapcsolat elutasítva + Ez az alkalmazás a következőt kéri: + Ez egy csak olvasható fiók, és nem tud aláírni. + Távoli aláíró alkalmazás + Az Amethyst fenntart egy háttérkapcsolatot (amely folyamatos értesítésként jelenik meg), így bezárt állapotban is tud válaszolni az aláírási kérésekre. + Aláírás más alkalmazások számára + Aláíró bekapcsolása + Élő + + %1$d aláírási kérés + %1$d aláírási kérés + + Összes kijelölése + Kijelölés megszüntetése + Jegyezze meg ezeket minden alkalmazáshoz + mint %1$s + %1$d engedélyezése + %1$d elutasítása + Legutóbbi tevékenység + Még nincsenek kiszolgált kérések. + elutasítva + Aláírt egy eseményt (típus: %1$d) + Titkosított egy üzenetet + Visszafejtett egy üzenetet + Megosztotta az Ön nyilvános kulcsát + Kapcsolódva + Ping + Kilistázta az átjátszókat + %1$s + Aláírási kérések + Teljes képernyős figyelmeztetések, amelyek arra kérik Önt, hogy hagyjon jóvá egy alkalmazást, amely az Amethysten keresztül szeretne aláírni, titkosítani vagy visszafejteni az Ön kulcsával. + Jóváhagyja az aláírási kérést? + Egy alkalmazás kapcsolódni szeretne + Koppintson az áttekintéshez + Olvassa be egy alkalmazásnak a kulcsához való kapcsolásához + Kód beolvasása + Hivatkozás beillesztése + + %1$d kapcsolódó alkalmazás + %1$d kapcsolódó alkalmazás + Aláírási bizalmi szint Képességek Alkalmazás elfelejtése @@ -953,6 +1142,7 @@ Korábbi kiadások elrejtése Rövidek Nyilvános csevegések + Helyszíncsatornák Követési csomagok Élő közvetítések Hangszobák @@ -1081,6 +1271,7 @@ Tér indítása Ütemezés későbbre Válasszon kezdési időpontot + Ügynöki konzol Hangszoba-kiszolgálók Válassza ki, hogy az Amethyst mely MoQ-kiszolgálókon tegye közzé a hangszobáit. Új tér indításakor alapértelmezetten az első bejegyzés kerül felhasználásra. Saját kiszolgálók @@ -1407,11 +1598,88 @@ Csak a profilképek gyorsítótárazása A helyi gyorsítótár korlátozása csak a profilképekre. A hírfolyamban megjelenő képek és videók közvetlenül az eredeti kiszolgálókról lesznek letöltve. Nincsenek Blossom-kiszolgálók beállítva. Használhatja az Amethyst listáját, vagy hozzáadhat egyet az alábbiakból ↓ + Feltöltési viselkedés + Feltöltések tükrözése + A feltöltés után másolja át a fájlt a többi Blossom kiszolgálójára, hogy elérhető maradjon, ha az egyik offline állapotba kerül. + Média optimalizálása a kiszolgálón + Feltöltés a kiszolgáló /media végpontján keresztül, így az eltávolíthatja a metaadatokat és tömörítheti a fájlt. A tárolt fájl eltérhet az eredetitől. + Tárolt fájlok kezelése + Az én Blossom fájljaim + Frissítés + Összes szinkronizálása + Néhány fájlja még nincs meg az összes kiszolgálóján. + Fájlok másolása a kiszolgálók között… + Szinkronizálás kész + Mégsem + Blossom szinkronizálás + Megjeleníti a folyamatot, miközben a fájljait átmásolja a Blossom kiszolgálókra. + Nem találhatók tárolt fájlok a Blossom kiszolgálóin. + Tárolás helye + Fájl részletei + Minden kiszolgálón + Nem minden kiszolgálón + Tükrözés a hiányzókra + Törlés innen… + Törlés a következőről: %1$s + Jelentés + Megnyitás + Ez a kiszolgáló fizetést igényel a feltöltéshez: %1$s + Fizetés szükséges + %1$s egy lightning fizetést számít fel ezen fájl tárolásáért. A folytatáshoz fizessen a kapcsolódó pénztárcájából. + %1$s ezt mondja: „%2$s” + Fizetés + + %1$d satoshi fizetése + %1$d satoshi fizetése + + Adat (blob) jelentése + Ok (opcionális) + Küldés + További műveletek + Fájlok importálása… + Fájlok importálása + Ellenőrizzen más Blossom kiszolgálókat, hogy máshova feltöltött fájlokat találjon, és másolja át azokat a saját kiszolgálóira. + Ellenőrizendő kiszolgálók + Összes engedélyezése + Összes letiltása + Vagy illesszen be egy kiszolgálócímet + Kiszolgálók ellenőrzése + Ellenőrzés… + Nem sikerült elérni ezt a kiszolgálót + Nem találhatók új fájlok a kiválasztott kiszolgálókon. + Először adja hozzá a saját Blossom kiszolgálóit, hogy legyen hova másolni az importált fájlokat. + Saját kiszolgálók kezelése + Már fut egy fájlszinkronizálás. Próbálja meg újra az importálást, miután befejeződött. + + %1$d fájl + %1$d fájl + + + %1$d importálandó fájlt találtunk az Ön kiszolgálóira. + %1$d importálandó fájlt találtunk az Ön kiszolgálóira. + + + %1$d fájl importálása + %1$d fájl importálása + Ajánlott médiakiszolgálók Az Amethyst alapértelmezett listája. Hozzáadhatja őket egyenként, vagy hozzáadhatja a listát. Alapértelmezett lista használata Médiakiszolgáló hozzáadása Médiakiszolgáló törlése + Húzza az átrendezéshez. A feltöltések fentről lefelé haladva próbálkoznak a kiszolgálókkal. + Feltöltési prioritás + Kiszolgáló hozzáadása + Ajánlott + Vagy illesszen be egy kiszolgálócímet + Eszközön lévő gyorsítótár + Elsődleges + Hozzáadva + Kiszolgáló átrendezése + Online + Lassú + Nem elérhető + Ellenőrzés… Fizetési célok Tegye közzé fizetési címeit, hogy mások közvetlenül küldhessenek Önnek fizetséget. Adjon meg fizetési címeket a különböző hálózatokhoz (például: bitcoin, lightning, ethereum). @@ -1422,6 +1690,20 @@ Nem található alkalmazás a fizetés kezeléséhez. Telepítsen egy kompatibilis pénztárcát. Nem található alkalmazás a(z) %1$s fizetések kezeléséhez. Telepítsen egy kompatibilis pénztárcát. Nem sikerült megnyitni a fizetést + BOLT12 Ajánlatok + Tegyen közzé újrafelhasználható BOLT12 ajánlatokat, hogy mások is küldhessenek Önnek zapet a Lightning hálózaton keresztül, anélkül, hogy minden alkalommal külön számlára lenne szükség. + Adjon hozzá egy vagy több BOLT12 ajánlatot (lno1…). Ezek nyilvánosan megosztásra kerülnek, így bárki tud Önnek fizetni. + Nincsenek BOLT12 ajánlatok beállítva. Adjon hozzá egyet alább ↓ + BOLT12 ajánlat (lno1…) + Nem érvényes BOLT12 ajánlat + BOLT12 ajánlat törlése + Fizetés kapcsolódó pénztárcával + Összeg (satoshi) + BOLT12 fizetés elküldve + BOLT12 fizetés sikertelen: %1$s + BOLT12 zap hiba + A BOLT12 hálózaton keresztül fizetve lett, de a pénztárca nem küldött vissza igazolást, így nem került közzétételre zap nyugta. + A BOLT12 hálózaton keresztül fizetve lett, de a fizetési igazolás nem érvényes, így nem került közzétételre zap nyugta. Nem kezdődött el Tömörítés… Feltöltés… @@ -1439,6 +1721,11 @@ Minden követett felhasználó Alapértelmezett követési lista Közelemben + Teleportálás egy helyre… + Kövesse ezt a helyet + Ezen hely követésének megszüntetése + Közzététel ide: + Módosítás Globális Válogatott Saját @@ -1523,8 +1810,15 @@ %1$s satoshi Tőle: %1$s neki: %1$s + Beérkezett fizetések + Értesíti Önt, ha a kapcsolódó pénztárcájába olyan fizetés érkezik, amely nem Nostr zap + Érkezett %1$s satoshi + Új fizetések Válasz Megjelölés olvasottként + Én + Új üzenet + Önt hozzáadták ehhez: %1$s Új üzenetek Új zapek Reakciók @@ -1547,13 +1841,48 @@ %1$s megemlítette Önt Új említések + Üzenetek + Közösségi + Fizetések + Tartalom + Fejlesztő + Játékok + Továbbosztások + Értesítés ha valaki megosztja az Ön bejegyzését + %1$s megosztotta az Ön bejegyzését + Új továbbosztások + Média + Értesítés említésekről egy fényképen vagy videón + %1$s megosztott egy fényképet + %1$s megosztott egy videót + Új média + Cikkek és kiemelések + Értesítés említésekről vagy kiemelésekről egy cikkben + %1$s megemlítette Önt egy cikkben + %1$s kiemelte az Ön cikkét + Új cikkek + Kód és Git + Értesítés a hibákról, javításokról és beolvasztási kérésekről + %1$s nyitott egy hibajegyet + %1$s küldött egy javítást + %1$s nyitott egy beolvasztási kérést + %1$s frissített egy beolvasztási kérést + Új kódtevékenység + Kitűzők + Értesítés kitűzőről + Ön kapott egy kitűzőt + Kiadta: %1$s + Új kitűzők + Nutzap érkezett: %1$s + Láncon belüli zap érkezett: %1$s + %1$s feltett egy kérdést Bejövő hívások Értesítések a bejövő hang- és videóhívásokhoz @@ -1614,17 +1943,105 @@ + + %1$s \u00b7 %2$d átjátszó + %1$s \u00b7 %2$d átjátszó + + Böngészés + Média + Kulcsszavak + Témák + Beszélgetés + Keresés + Hiányzó események keresése + Események figyelése + Lekéri azokat az eseményeket azonosító alapján, amelyekre a képernyőn látható valami hivatkozik, de még nem rendelkezel velük – például egy idézet, egy válasz szülője vagy egy szál gyökere. + Figyeli a jelenleg megjelenített eseményeket új válaszok, reakciók, továbbosztások, zap-ek és jelentések szempontjából, így a számlálók frissülnek olvasás közben. + Kiegészítők + Átjátszó információ + Egyéb + Átjátszólista-kereső + Profilok figyelése + Fiók adatai + Kezdőoldal hírfolyama + Átjátszó csoportok + + %1$d csoport + %1$d csoport + + Ideiglenes csevegések + Helyalapú csevegések + Élő közvetítés csevegés + A NIP-29 csoportok, amelyekhez csatlakozott. Minden csoport egy gazda-átjátszón létezik, így az alkalmazás minden olyan átjátszóhoz kapcsolódik, amely egy csoportját üzemelteti. + Csevegőszobák, amelyek nem őriznek előzményeket – az üzenetek csak a kapcsolódás ideje alatt léteznek, ezért ezek folyamatosan előfizetve maradnak, hogy bármit is fogadni tudjanak. + Helyalapú szobák azokhoz a területekhez, amelyeket követ, lekérdezve az azokat hordozó átjátszóktól. + Csevegés és zap-célok, amelyek a megnyitott vagy követett élő közvetítésekhez kapcsolódnak. + Üzenetek + Pénztárca + Beérkező nutzapek + Pénzverde-jegyzék + Pénztárca-kapcsolat + Közösségi csevegések + Közösségi hírfolyamok + A beérkező üzenetek átjátszói, plusz egy kis, rotáló minta azokból az átjátszókból, ahová a követettjei posztolnak, arra az esetre, ha egy említés máshová került volna kézbesítésre. + A közvetlen üzenetek beérkező átjátszói, ahol a becsomagolt üzenetek kézbesítésre kerülnek. + Az egyes megnyitott vagy csatlakozott csevegések gazda-átjátszója. + Azok az átjátszók, amelyekre az egyes közösségek közzéteszik a terveiket. + Csoportüzenetek és kulcscsomagok az egyes csoportok átjátszóin. + A szoba átjátszói, amíg az meg van nyitva. + Saját profil, -beállítások és -piszkozatok a gazda-átjátszókon. + A képernyőn jelenleg látható emberek profiljai. + Megkeresi, hogy az egyes személyek mely átjátszókon teszik közzé a bejegyzéseiket, így azok a megfelelő helyről tölthetők le. + Követési listák, amelyek az Ön hírfolyama és a bizalmi hálójának felépítéséhez használatosak. + Jelentések, amelyeket a követettjei írtak a képernyőn jelenleg látható profilokról, lekérdezve minden egyes átjátszótól, ahová ezek a követettek teszik közé a bejegyzéseiket. + Jelentések követettektől + Saját pénztárca-események, visszaolvasva azokból az átjátszókból, ahová közzétette őket. + Figyeli a nutzap-átjátszókat, valamint a beérkező- és közvetlen üzenet-átjátszókat, így egy fizetés sem csúszhat el. + Átjátszók között keres, hogy mely pénzverdék léteznek és melyeket ajánlják az emberek. + Értesítések az összekapcsolt pénztárcáról. + Aktív átjátszó-előfizetések + + %1$d szűrő + %1$d szűrő + + + %1$d átjátszó + %1$d átjátszó + + + %1$d szűrő még nincs hozzárendelve + %1$d szűrő még nincs hozzárendelve + + %1$s \u00b7 %2$s + Nincs fiókhoz rendelve + Összes + Mindenki + Azok, akiket követ + Kiválasztott személyek listája + Némított személyek + Közösségeid + Kedvenc algoritmus-hírfolyam + %1$d%% az összesből + előfizetések, szűrők, átjátszók, kérések, kapcsolatok, miért diagnosztika + Bejegyzések azoktól, akiket követ, beolvasva azokból az átjátszókból, ahová közzéteszik a bejegyzéseiket. Kapcsolódás a beérkező üzenetátjátszókhoz\u2026 Folyamatos értesítési szolgáltatás Folyamatos kapcsolatot tart fenn a beérkező üzenetek átjátszóival az értesítések azonnali kézbesítése érdekében. Megjeleníti a folyamatban lévő értesítéseket. Több akkumulátort fogyaszt, de így biztosan nem marad le egyetlen üzenetről sem. + Értesítési szolgáltatás + Be + Ki + Értesítési háttérszolgáltatás + Főkapcsoló a valós idejű háttérértesítésekhez. Kapcsolja ki az akkumulátor kímélése érdekében – olyan, mint a repülőgép-üzemmód. Ha ki van kapcsolva, egyetlen fiók sem marad kapcsolatban a háttérben, és kikapcsolva is marad (újraindítások után is), amíg Ön vissza nem kapcsolja. + Háttérben aktív fiókok + Tartsa ezt a fiókot aktívan a háttérben Értesítések szétválasztása követések szerint Két értesítési fül megjelenítése — Követettek (akiket Ön követ) és Mindenki. Az olvasatlan jelző csak a követett személyek aktivitása esetén világít. Üzenetek megjelenítése @@ -1790,16 +2207,52 @@ Korlátlan Wi-Fi Soha + Wi-Fi Teljes Egyszerűsített Teljesítmény + Teljes + Egyszerű + Gyors Klasszikus Modern Rendszer Világos Sötét Alkalmazásbeállítások + Megjelenés + Média és adat + Általános + Kapcsolat + Útválasztás Tor-on keresztül + Szakaszok és hírfolyamok + Látható lapok + Tartalom a hírfolyamban + Szöveges jegyzetek + Továbbosztások + Hozzászólások és válaszok + Képek + Videók + Rövid videók + Cikkek + Wiki-oldalak + Kiemelések + Szavazások + Apróhirdetések + Torrentek + Hangüzenetek + Élő tevékenységek + Ideiglenes csevegések + Interaktív történetek + Sakkjátszmák + Madármegfigyelések + Igazolások + NIP-tervezetek + Zene és hang + Podcastok + Gyűjtések + Emlékeztetők Kapcsolódás pénztárcához Nyelv Téma @@ -1818,6 +2271,10 @@ Serif Monospace + Rendszer + Sans + Serif + Mono Betűméret A szövegméret méretezése az alkalmazásban Kicsi @@ -1849,11 +2306,17 @@ Visszaállítás alapértelmezettre Helyszín megjelenítése mint Hozzáadja a helyszínének geokivonatát a bejegyzéséhez. A nyilvánosság tudni fogja, hogy a jelenlegi helytől 5 km-en (3 mi) belül tertózkodik + Teleportálás + ✈ Teleportálás ide + Válasszon egy helyet a térképen + Helyszín módosítása a térképen Helyszín-alapú bejegyzés Csak a helyszín követői láthatják. Az általános követők nem fogják látni. Kulcsszóexkluzív bejegyzés + Külső tartalom Megjegyzés egy weboldalhoz Megjegyzés egy külső forráshoz + Megnyitás böngészőben Olvasási idő: %1$d perc %1$d szám @@ -1964,8 +2427,32 @@ Átjátszócsoportok Csoportok Üzenetek + Betöltendő beszélgetések + Válassza ki, milyen csevegések jelenjenek meg a beérkező üzenetekben. Ha kikapcsol egyet, az itt elrejtődik, és az alkalmazás nem tölti le az átjátszóidról. + Privát üzenetek + Végpontok között titkosított, becsomagolt közvetlen üzenetek (NIP-17). + Régi típusú üzenetek + Régebbi, kevésbé privát titkosított közvetlen üzenetek (NIP-04). + Nyilvános csatornák + Nyílt, nyilvános csevegőszobák, amelyeket bárki olvashat és csatlakozhat hozzájuk (NIP-28). + Átjátszó csoportok + Átjátszó által kezelt csoportos csevegések taglistákkal és moderációval (NIP-29). + Titkosított csoportok + MLS végpontok között titkosított csoportos csevegések (Marmot). + Concord közösségek + Titkosított közösségek több csatornával (Concord). + Helyalapú csevegések + Nyilvános, geohash-alapú csevegés egy helyszínhez (Geolocation). + Ideiglenes csevegések + Könnyűsúlyú, átjátszóra korlátozott szobák, amelyek nem őrzik meg az előzményeket. Csoport létrehozása + Új csatorna + Új fórum + Privát csatorna + Rejtve van a csatornalistából és csak meghívásos. Kikapcsolva bárki ezen az átjátszón megtalálhatja és csatlakozhat hozzá. + Fórumcsatorna + Szálazott bejegyzések a csevegés idővonal helyett. Ez később nem módosítható. Ez az átjátszó nem hirdeti a NIP-29 átjátszócsoportok támogatását. Az itt létrehozott csoport nem fog működni. A nevét, a tagjait és az üzeneteit nem az átjátszó fogja kezelni. Válasszon egy olyan átjátszót, amely támogatja az átjátszóalapú csoportokat. Csoport neve Téma (nem kötelező) @@ -1988,6 +2475,7 @@ Tagok Kinevezés adminisztrátorrá Kinevezés moderátorrá + Szerepkör hozzárendelése: %1$s Szerepkör eltávolítása Eltávolítás a csoportból Eltávolítja őt: %1$s, ebből a csoportból? Elveszíti a hozzáférést, amíg újra hozzá nem adják vagy meg nem hívják. @@ -1999,6 +2487,12 @@ Mentés Tagok Csoport szerkesztése + Csoport törlése + Törli a(z) „%1$s” csoportot? Ez mindenki számára eltávolítja a csoportot és annak előzményeit, ez a művelet nem vonható vissza. + Csatorna törlése + Törli a(z) „%1$s” csatornát? Ez mindenki számára eltávolítja a csatornát és annak üzeneteit, ez a művelet nem vonható vissza. + Csatorna archiválása + Csatorna visszaállítása az archívumból Szálak Üzenet kitűzése Üzenet kitűzésének megszüntetése @@ -2011,6 +2505,8 @@ Meghívó előkészítése… Privát Csak meghívóval + ÉLŐ + %1$d+ Adjon meg egy érvényes átjátszó-webcímet (wss://…). Név Leírás @@ -2022,8 +2518,29 @@ Témák bitcoin, nostr, művészet Helyszín (geo-kivonat) - u0nd + Érintse meg a térképikont a hely kiválasztásához + Helyszín hozzáadása + Tűzze ki a csoportját egy térképen, hogy a környéken lévők felfedezhessék. + Helyszín módosítása + Helyszín eltávolítása + Geohash kézi megadása + Helyszín kiválasztása + Mozgassa a térképet, keressen egy helyet, vagy használja a jelenlegi helyzetét. + Keresés városra vagy címre + Nem található egyező hely. + Jelenlegi helyzetem használata + Terület mérete + Helyszín használata + Struktúra + Helyezze ezt a csoportot egy szülő alá a hierarchia felépítéséhez. + Szülőcsoport + Legfelső szintű csoport + Válasszon egy szülőcsoportot + Csoportok keresése + Nincs szülő (legfelső szint) + Ez a csoport a legfelső szinten található + Még nincsenek más csoportok ezen az átjátszón Engedélyek Privát Csak a tagok olvashatják az üzeneteket. @@ -2038,6 +2555,9 @@ Átjátszó hozzáadása a kedvencekhez Még nincsenek szálak. Indítson egyet a + gombbal. + Még nincsenek szálak. + Régebbi szálak betöltése… + Nincsenek régebbi szálak Új szál Névtelen Cím @@ -2047,9 +2567,16 @@ Böngésszen az egy átjátszón tárolt csoportok között. Illesszen be egy átjátszócímet, vagy válasszon egyet alább. Átjátszó címe Böngészés + Ez az átjátszó nem érhető el Tor-on keresztül + %1$s nem válaszol Tor-on keresztül – előfordulhat, hogy a gazdagép blokkolja a Tor kilépési csomópontokat. Kapcsolódik hozzá inkább a nyílt interneten keresztül? + Nyílt internet használata Átjátszók, amelyeken jelen van Népszerű átjátszók Még nincsenek üzenetek + Hozzáadva ide: %1$s + Valaki + Elvetés + Kilépés Csatlakozzon ehhez a csoporthoz az üzenetek küldéséhez. Ez a csoport csak meghívóval érhető el. Meghívóra van szüksége a közzétételhez. @@ -2068,6 +2595,10 @@ %1$d követett személy %1$d Ön által követett személy + + %1$d csoport + %1$d csoport + Privát Neki: Tárgy @@ -2104,6 +2635,17 @@ Küldés közvetlen üzenetként Küldés ide… Új üzenet + Új kiemelés + Új kép + Új rövid videó + Új videó + Új kiemelés + Kiemelt szöveg + Mi volt az, ami megragadta a figyelmét? + Forrás webcíme + Saját jegyzet (nem kötelező) + Adja hozzá a gondolatait… + Kiemelés Webcím másolása a vágólapra Bejegyzés-azonosító másolása a vágólapra Média hozzáadása a galériához @@ -2152,6 +2694,8 @@ Nem sikerült a pénzkivétel a pénztárcából Nem sikerült a Cashut beváltani A pénzverde a következő hibaüzenetet küldte:%1$s + Nem biztonságos Cashu pénzverde-cím + Az Amethyst nem lépett kapcsolatba a token pénzverdéjével. %1$s Cashu megkapva %1$s satoshi érkezett a pénztárcájába. (Költség: %2$s satoshi) A rendszeren nem található kompatibilis Cashu pénztárca @@ -2735,6 +3279,7 @@ Statikus kép %1$s profilképe %1$s átjátszó + Átjátszók által elfogadva Átjátszólista kibontása Bejegyzésbeállítások Szavazás @@ -2750,11 +3295,133 @@ %1$s létrehozta a(z) %2$s nevű csatornát %1$s létrehozta a csatornát %1$s frissítette a csatorna profilját + (szerkesztve) + (különbség csonkítva) + %1$s csatlakozott + %1$s hozzáadva: %2$s által + %1$s kilépett + %1$s eltávolítva: %2$s által + %1$s beállította a témát erre: „%2$s” + %1$s törölte a témát + %1$s beállította a célt erre: „%2$s” + %1$s törölte a célt + %1$s nyilvánossá tette ezt a csatornát – bárki megtalálhatja és csatlakozhat hozzá + %1$s priváttá tette ezt a csatornát – csak meghívással + %1$s módosította a láthatóságot erre: %2$s + %1$s beállította, hogy az üzenetek %2$s után tűnjenek el + %1$s kikapcsolta az eltűnő üzeneteket + %1$s archiválta ezt a csatornát + %1$s visszaállította ezt a csatornát + %1$s létrehozta ezt a csatornát + %1$s törölte ezt a csatornát + %1$s törölt egy üzenetet + %1$s törölt egy üzenetet: %2$s + %1$s elindította ezt a beszélgetést + %1$s: %2$s + 🔊 %1$s elindított egy huddle-t + 🔊 %1$s csatlakozott a huddle-höz + 🔊 %1$s kilépett a huddle-ből + 🔊 A huddle véget ért + ⚙ %1$s kért egy feladatot + ⚙ %1$s elfogadta a feladatot + ⚙ Feladat előrehaladása + ⚙ Feladat eredménye + ⚙ Feladat megszakítva + ⚠ Feladathiba + ▲ %1$s felpontozott egy bejegyzést + ▼ %1$s lepontozott egy bejegyzést + Vászon + Még nem osztottak meg vásznat ebben a csatornában. + Vászon szerkesztése + Vászon mentése + Vászon (Markdown) + Szerkesztés + Üzenet szerkesztése + Szerkesztés + (szerkesztve) + Üzenet szerkesztése + %1$s ír… + %1$s és %2$s írnak… + Többen írnak… + Buzz-munkaterületek + Ahol emberek és ügynökök együtt építenek + Kilépés a munkaterületről + Sajtá munkaterületek + Még nincsenek munkaterületek + A Buzz-munkaterületek olyan átjátszók, ahol emberek és mesterséges intelligencia-ügynökök építenek együtt. Csatlakozzon egyhez, hogy itt is megjelenjen. + Csoportok böngészése + Ügynök-konzol + Költségek · Személyiségek · Élő megfigyelő + Új fórumtéma + Miről szeretne beszélgetni? + Téma közzététele + Fórumtéma + Még nincsenek válaszok. Legyen Ön az első, aki válaszol. + Közvetlen üzenetek + Buzz-üzenetek + Privát beszélgetések a munkaterületeken + Új üzenet + Még nincsenek közvetlen üzenetek + Indítson privát beszélgetést bárkivel egy Buzz-munkaterületen. + Több + Tag hozzáadása + Valaki hozzáadása ehhez a beszélgetéshez + Nem érvényes az npub vagy hexadecimális kulcs + Csak Ön + Munkaterület + Címzett + Valaki hozzáadása (npub vagy hexadecimális) + Keresés név, NIP-05 vagy npub szerint + Nem található senki. Próbáljon másik nevet, NIP-05 címet, vagy illesszen be egy npub-ot. + Beszélgetés indítása + Megnyitás… + Eltávolítás + Munkaterület-meghívás + Csatlakozás ehhez a munkaterülethez + Munkaterület + Szerepkör + A munkaterületet egy biztonságos beépített böngészőben nyitja meg, hogy átnézhesse a feltételeket és befejezze a csatlakozást. A saját Amethyst kulcsával jelentkezik be – jelszó nem szükséges. + Folytatás a böngészőben + Ez nem tűnik érvényes Buzz-meghívó-hivatkozásnak. + Ez a meghívó lejárt. Kérjen egy újat. + Fejezze be a feltételek elfogadását a böngészőben, majd térjen vissza – a munkaterület várni fogja. + Saját munkaterületek megnyitása + Munkaterület-csatornák importálása + Saját csatornák keresése… + Saját csatornák ezen a munkaterületen + Hozzáadás + Összes hozzáadása + Hozzáadva + Nem találhatók csatornák + Úgy tűnik, még nem tagja egyetlen csatornának sem ezen az átjátszón. Először fogadja el a munkaterület-meghívót a böngészőben, majd próbálja újra. + Csatornák + Fórumok + Archivált + Dolgozás… + Tag hozzáadása + Emberek hozzáadása ehhez a munkaterülethez + Meghívási hivatkozás létrehozása + Meghívási hivatkozás + Megosztás + Másolás + Nem sikerült létrehozni a meghívót + OK + Csatorna rögzítése + Csatorna rögzítésének megszüntetése + Még nincsenek beszélgetések + + %1$d rejtett beszélgetés + %1$d rejtett beszélgetés + + + %1$d további beszélgetés megtekintése + Mind a %1$d beszélgetés megtekintése + Üzenet kézbesítése Bezárás Várakozás egy átjátszóra az üzenet elfogadásához @@ -2927,6 +3594,7 @@ Témák (vesszővel elválasztva) Mentés Git-tárolók + Kiemelések nOldal: %1$s nKisalkalmazás: %1$s Engedélyek: @@ -3770,6 +4438,11 @@ „Munkabizonyítás” bányászata Azokat a bejegyzéseket jeleníti meg, amelyek még mindig a NIP-13 szerinti munkabizonyítást végzik, így azok befejeződhetnek, miután kilép az alkalmazásból. Mégse + Küldés most + Leállítja a bányászatot? + Ez a bejegyzés még bányászza a munkabizonyítást. Elküldheti most munkabizonyítás nélkül, vagy eldobhatja. + Küldés munkabizonyítás nélkül + Bejegyzés eldobása ≈ %1$s bejegyzésenként ezen az eszközön <1 mp @@ -3946,7 +4619,113 @@ Hiba történt a JSON elemzésekor a(z) %1$s számlahívásából. Ellenőrizze a felhasználó lightning-beállításait Nem lehetett lightning-számlát létrehozni. Üzenet a következőtől: %1$s: %2$s Nem sikerült Lightning-számlát létrehozni a következőből: %1$s: A „PR” elem nem található a kapott JSON-ban. + Helyalapú csevegés megnyitása + Helyalapú csevegés + Helyalapú csatorna + Csevegjen azokkal, akik egy hely közelében vannak, geohash alapján. + Helyi + Legjobb, ha fizikailag egy hely környékén – a saját környékén, városában vagy régiójában – lévő emberekkel szeretne beszélgetni. + Válasszon egy helyszínt + Névtelen – területenként eldobható identitás, sosem a saját npub. + Bitchat-kompatibilis; eléri a közeli felhasználókat ugyanazokon az átjátszókon. + Nyilvános és ideiglenes – nincs előzmény, a cellában bárki olvashatja. + Visszamaradó feladatok + Munkafolyamat-futtatások + Ügynökmunka + Új futtatás + Jóváhagyásra vár + Jelenleg dolgozik + Kiszállítva + Lezárva + Jóváhagyásra vár + Jóváhagyásra vár + (nincs leírás) + Munkafolyamat: %1$s + általa: + vár rá: + Ön a jóváhagyó, de ez a bejelentkezés nem tud aláírni egy döntést. + Megtagadás + Jóváhagyás és PR nyitása + Jóváhagyja ezt a futtatást? + Megtagadja ezt a futtatást? + A futtató leküldi az ágat és nyit egy beolvasztási kérést a következőhöz: „%1$s”. A jóváhagyás soha nem egyesít vagy telepít – az továbbra is GitHubon történik. + Az ügynök munkája a következőhöz: „%1$s” el fog veszni. Ez nem vonható vissza. + ez a futtatás + Mégse + Jóváhagyásra vár + Futtatás elindítva + Nem sikerült futtatni – ellenőrizze, hogy tud-e bejegyzéseket közzétenni ezen a munkaterületen + Jóváhagyva – a futtató megnyit egy beolvasztási kérést + Megtagadva – a munka eldobásra került + Nem sikerült közzétenni a döntésedet – ellenőrizze, hogy tud-e bejegyzéseket közzétenni ezen a munkaterületen + PR megtekintése + Megtagadva – munka eldobva + Megszakítva + Sikertelen: %1$s + Sikertelen + Sorban + Dolgozás + Jóváhagyásra vár + Jóváhagyva + Kiszállítva + Sikertelen + Megszakítva + Megtagadva + Munkafolyamat elindítása + A futtató elvégzi a munkát, majd megáll, hogy egy ember jóváhagyja, mielőtt PR-t nyit. Az egész csatorna látja a futtatást. + Még nincsenek munkafolyamatok. Nyissa meg a fenti menüt, és válassza az „Új definíció…” lehetőséget egy létrehozásához, majd indítsa el. + Mit kellene csinálnia? + Futtatás indítása + Munkafolyamat + Még nincsenek munkafolyamatok definiálva + Válasszon egy munkafolyamatot + Új definíció… + Új munkafolyamat-definíció + Elnevezi a csatorna számára és közzéteszi a YAML receptjét (kind-30620). Egy valódi Buzz-átjátszó futtatja a YAML-t. Saját tárhely esetén a futtató a konfigurált parancsot futtatja – itt a definíció csak elnevezi és katalogizálja a futtatást. + Név + összeállítás és tesztelés + YAML-recept + Nem sikerült közzétenni a definíciót – ellenőrizze, hogy tud-e bejegyzéseket közzétenni ezen a munkaterületen + Közzététel… + Definíció létrehozása + Még nincsenek futtatások + Koppintson az „Futtatás indítása” gombra egy munkafolyamat elindításához. A futtató elvégzi a munkát, és megáll egy jóváhagyási pontnál – egy embernek kell engedélyeznie, mielőtt bármi kiszállításra kerülne. + Igazolások + Egy igazolás megtartása + Egy igazolás megtartása ehhez a fiókhoz. Ez automatikusan csatolásra kerül, amikor hitelesít egy Buzz-átjátszón. + Engedélyek: %1$s + bármilyen típus, bármikor (korlátozás nélkül) + Eltávolítás + Illesszen be egy tulajdonos által aláírt hitelesítési címkét, amelyet ennek a fióknak állítottak ki, hogy virtuális tagként hitelesítse magát a Buzz-munkaterületükön. + hitelesítési címke JSON + Igazolás megtartása + Helyi kulcs szükséges + A NIP-OA igazolás egy aláírás egy kötelezettségvállalás-kivonat felett, nem egy Nostr-esemény, így azt csak olyan aláíró állíthatja elő, amely az Ön nyers privát kulcsát birtokolja. Ez a fiók távoli (NIP-46 bunker) vagy külső (NIP-55) aláírót használ, amely nem tud igazolást aláírni. + Engedélyezze egy ügynök nyilvános kulcsát, hogy az Ön munkaterületén tegyen közzé bejegyzést anélkül, hogy regisztrálná a kulcsát. Az ügynök az alábbi aláírt címkét csatolja az eseményeihez. + Ügynök (keressen név szerint, vagy illesszen be egy npub / hexadecimális kulcsot) + Adjon meg egy npub-ot vagy 64 karakteres hexadecimális kulcsot. + Ügynök módosítása + Feltételek (nem kötelező) – hagyja üresen a korlátozás nélküli igazoláshoz. + Korlátozás típusra (0–65535) + Csak ez utáni események (unix másodperc) + Csak ez előtti események (unix másodperc) + Igazolás előállítása + Aláírt igazolás + Címke másolása + ⚠ Csak az ügynök üzemeltetőjének adja oda. Amíg érvényes és Ön továbbra is tagja a munkaterületnek, az átjátszó lehetővé teszi, hogy ez az ügynök a fenti feltételek mellett tagként tegyen közzé bejegyzéseket. + Új személyiség + Személyiség szerkesztése + Azonosító (személyazonosító) + a-z, 0-9, „-” vagy „_”. Létrehozás után nem módosítható. + Megjelenített név + Rendszer-utasítás + Modell (nem kötelező) + Szolgáltató (nem kötelező) + Futtatókörnyezet (nem kötelező) + Avatar webcíme (nem kötelező) + Közzététel… + Személyiség közzététele diff --git a/commons/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commons/src/commonMain/composeResources/values-hu-rHU/strings.xml index 37c6920c2c..c19ba182da 100644 --- a/commons/src/commonMain/composeResources/values-hu-rHU/strings.xml +++ b/commons/src/commonMain/composeResources/values-hu-rHU/strings.xml @@ -1,17 +1,95 @@ + Üdvözöljük az Amethystben + Jelentkezzen be Nostr a-fiókjába + Asztali Nostr-kliens + Jelentkezzen be Nostr-a kulcsával + nsec a teljes hozzáféréshez, bunker:// a távoli aláíróhoz, vagy npub a csak olvasható módhoz + Bejelentkezés kulccsal + Bejelentkezés + Új kulcs előállítása + Új előállítása + Adja meg a privát kulcsát (nsec) vagy a nyilvános kulcsát (npub) + nsec, bunker:// vagy npub + nsec1… / bunker://… / npub1… + Kulcs megjelenítése + Kulcs elrejtése + FONTOS: Mentse el a kulcsait! + A titkos kulcsa (nsec) az EGYETLEN módja annak, hogy hozzáférjen a fiókjához. Ha elveszíti, akkor a fiókja végleg elvész. Mentse el egy biztonságos helyre! + Nyilvános kulcs (megosztható): + Titkos kulcs (SOHA ne ossza meg!): + Elmentettem a kulcsaimat, folytatás + Másolás + Beillesztés + Mégse + OK + Mentés + Törlés + Megosztás + Érvénytelen kulcsformátum. Ellenőrizze, és próbálja újra. + Hálózati hiba. Ellenőrizze a kapcsolatot. + Hiba történt. Próbálja újra. + Frissítés + Próbálja újra + A hírfolyam üres + Hiba történt a hírfolyam betöltésekor: %s + Keresés + Keressen felhasználókat, bejegyzéseket és kulcsszavakat. + Üzenetek + Az Ön titkosított közvetlen üzenetei itt fognak megjelenni. + Értesítések + Az említések, válaszok és reakciók itt fognak megjelenni. + Felhasználó profilképe + Navigáció + Betöltés: + Teljesen betöltve: + (teljesen betöltve) + Előzmények átjátszónként + Újra megpróbálja, amint újra megnyitja ezt a képernyőt + %1$s korábbi üzenet + Naprakész + Elérte a(z) %1$s üzeneteinek elejét + %1$s · %2$s · betöltve ekkortól: %3$s + %1$s · %2$s + várakozás erre: %1$s + Néhány átjátszó nem válaszolt + %1$s nem érhető el · koppintson a részletekért + %1$s · előzmények átjátszónként + ekkortól: %1$s + Eltüntetés + + %1$d átjátszó + %1$d relé + + válasz neki: + nOldal: %1$s + nKisalkalmazás: %1$s + nKisalkalmazás + nOldal + Amihez hozzáférhet + Gyökéroldal + Forrás: + Kiszolgálók: + Megnyitás + Közvetlen webcím használata + Becenév + Ez jelenik meg Önnek ezen felhasználó neve helyett az alkalmazásban bárhol. Titkosítva tárolódik el a kapcsolatkártyájára: csak Ön olvashatja. Írjon be kettőspontot (:) az egyéni emodzsik használatához. + Becenév + Privát megjegyzés erről a felhasználóról + Mentés + Mégse Nyitva Beolvasztva Lezárva @@ -53,6 +131,7 @@ Forgalmi dugó Útesemény Az erre küldött Zapek megoszlanak a következők között: + %1$d%% Értéket az értékért Megnyitás Olvasás @@ -61,6 +140,7 @@ Típus Követelmények Támogatott NIP-ek + %1$d ms Elfogadott típusok Helyszín Ott leszek diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 7897499063..87f8b8a5ae 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -84,27 +84,34 @@ "Dutch" ] }, + { + "user": "summoner001", + "languages": [ + "Hungarian" + ] + }, { "user": "maxblake2015", "languages": [ "Polish" ] }, + { + "user": "rajs19420616", + "languages": [ + "Hindi" + ] + }, { "user": "vitorpamplona", "languages": [ "Czech", "German", + "Polish", "Portuguese, Brazilian", "Swedish" ] }, - { - "user": "rajs19420616", - "languages": [ - "Hindi" - ] - }, { "user": "greenart7c3", "languages": [] @@ -265,10 +272,6 @@ "user": "D4rkFIow", "languages": [] }, - { - "user": "summoner001", - "languages": [] - }, { "user": "fiddleway", "languages": [] From 140048a209db01c203a7919a94a0a163964abfd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 14:05:08 +0000 Subject: [PATCH 082/227] docs(commons): propose an extended poll results page Design proposal for a dedicated NIP-88 poll results surface showing per-option vote counts and the full list of who voted for what, shared between Android and Desktop. Also documents four correctness gaps in the current tally that the page would otherwise inherit: multi-choice percentages divide by selections instead of voters, single-choice polls count every response tag instead of the first, votes outside the poll timeframe still count, and unknown option codes inflate the denominator. All four collapse into making ResponseTally poll-aware. Plus the data-completeness gap: Android never queries a poll's own `relay` tags for kind 1018, so its tallies are systematically short compared to Desktop, which already works around this per-card. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv --- commons/plans/2026-08-03-poll-results-page.md | 323 ++++++++++++++++++ commons/plans/README.md | 1 + 2 files changed, 324 insertions(+) create mode 100644 commons/plans/2026-08-03-poll-results-page.md diff --git a/commons/plans/2026-08-03-poll-results-page.md b/commons/plans/2026-08-03-poll-results-page.md new file mode 100644 index 0000000000..0cb3c8d17c --- /dev/null +++ b/commons/plans/2026-08-03-poll-results-page.md @@ -0,0 +1,323 @@ +--- +title: "feat(polls): extended poll results page (counts + who voted for what)" +type: feature +status: proposed +date: 2026-08-03 +owner: commons +consumers: amethyst, desktopApp +--- + +# feat(polls): extended poll results page + +> **Status:** proposal. Nothing implemented. This document surveys what exists, +> names four correctness bugs the current tally has, and specifies a shared +> results surface for Android and Desktop. _Authored 2026-08-03._ + +## 1. Motivation + +Today a NIP-88 poll (kind 1068) renders its results **inline in the feed**, and +the results are deliberately tiny: a progress bar, a percentage, and up to four +voter avatars per option (`Poll.kt:571 UserGallery` shows `take(4)` then `+N`). +There is no way to answer the two questions people actually ask about a poll: + +1. **How many votes did each answer get?** — the card shows only a percentage, + never a count, and the percentage denominator is wrong for multi-choice + polls (§3.1). +2. **Who voted for what?** — Android gives you at most four avatars and no way + to see the fifth. Desktop already went further (`DesktopPollCard.kt:576 + VoterListPopup`), which proves the demand but leaves the two platforms + divergent and the logic unshared. + +A dedicated results page also unlocks the things a feed card can never host: +filtering the voter list to people you follow, searching it, sorting it, and +being honest about *how complete the tally is* — which today it silently is not +(§3.4). + +## 2. What exists today + +| Layer | Component | File | +|---|---|---| +| Protocol | `PollEvent` (1068): `options()`, `relays()`, `pollType()`, `endsAt()`, `hasEnded()` | `quartz/…/nip88Polls/poll/PollEvent.kt:63` | +| Protocol | `PollResponseEvent` (1018): `responses()`, `poll()` | `quartz/…/nip88Polls/response/PollResponseEvent.kt:62` | +| Tally | `PollResponsesCache` + `ResponseTally` + `TallyResults` — the single source of truth, hung off `Note.pollState()` | `commons/…/model/nip88Polls/PollResponsesCache.kt` | +| Attach | `Note.poll` / `pollState()` | `commons/…/model/Note.kt:178` | +| Ingest | `consume(PollResponseEvent)` → `getOrCreateNote(pollId).pollState().addResponse(...)` | `amethyst/…/model/LocalCache.kt:2936` | +| Write | `Account.pollRespond(event, responses)` | `amethyst/…/model/Account.kt:2018` | +| UI (Android) | `RenderPoll` / `RenderPollCard` / `RenderResults` / `RenderClosedItem` / `UserGallery`, view-state `PollCard` + `PollItemCard` | `amethyst/…/ui/note/types/Poll.kt` | +| UI (Desktop) | `DesktopPollCard`, `PollResultRow`, `VoterGallery`, `VoterListPopup` | `desktopApp/…/ui/note/DesktopPollCard.kt:95` | +| Feed | `Route.Polls` tab + `PollsFeedFilter` / `OpenPollsFeedFilter` / `ClosedPollsFeedFilter` | `amethyst/…/ui/screen/loggedIn/polls/` | +| Zap polls | `ZapPollEvent` (6969) + `PollNoteViewModel` (amount-weighted, zap-receipt tally) | `quartz/…/experimental/zapPolls/`, `amethyst/…/ui/note/ZapPollNoteViewModel.kt` | + +**Reuse verdict:** `PollResponsesCache` is the right foundation and should not +be replaced — but it must become *poll-aware* (§3). The results page needs **no +new event kind, no new cache, and no new feed filter**; it needs a poll-aware +tally, one shared subscription, one shared ViewModel, and two thin platform +shells. + +## 3. Correctness gaps to fix first + +These are not cosmetic. Three of them make the numbers on the current card +wrong, and the results page would inherit all of them. Each is cheap to fix and +the fix improves the inline card too. + +### 3.1 Multi-choice percentages use the wrong denominator + +`ResponseTally.totalVotes()` (`PollResponsesCache.kt:52`) sums the per-option +voter sets, so a voter who picks 3 options is counted 3 times. `filterTo` +divides by that (`:88-95`). On a multi-choice poll the percentages therefore sum +to 100% and read as "share of selections" — but users read a poll bar as "share +of people". Desktop already diverges here, using `tallyState.votes.size` +("distinct voters", `DesktopPollCard.kt:196`) for its footer while its bars use +the selections denominator. + +**Proposal:** make the basis explicit and platform-consistent. + +```kotlin +fun totalVoters(): Int = votes.size // distinct pubkeys +fun totalSelections(): Int = tally.entries.sumOf { it.value.size } +``` + +- single-choice → identical, no visible change; +- multi-choice → percent = `optionVoters / totalVoters`, bars may sum past 100%, + and each row is captioned **"N of M voters"** so the reading is unambiguous. + +### 3.2 Single-choice polls count every `response` tag + +NIP-88: *"for singlechoice polls, the first response tag is to be considered the +actual response."* `votesByOption()` (`PollResponsesCache.kt:129-143`) iterates +**all** response tags regardless of poll type, so one malformed (or hostile) +1018 with a `response` tag per option gets counted in every bucket. +`ResponseTally` cannot fix this today because it never sees the `PollEvent`. + +### 3.3 Votes outside the poll timeframe still count + +NIP-88 keeps the latest response *within the poll timeframe*. `latestByAuthor()` +(`Note.kt:1685`) only compares `createdAt` — a vote stamped after `endsAt` wins +over a legitimate earlier one, and a closed poll's totals keep moving. + +### 3.4 Unknown option codes inflate the tally + +A `response` tag whose code isn't in `PollEvent.options()` creates a phantom +bucket that still contributes to `totalVotes()`, dragging every real +percentage down. It should be dropped (and counted separately, so the results +page can show "3 responses ignored (invalid)"). + +### 3.5 The fix: a poll-aware tally + +All four collapse into one change — give the tally the poll it belongs to: + +```kotlin +// commons/…/model/nip88Polls/PollTallyPolicy.kt (new) +@Immutable +class PollTallyPolicy( + val validCodes: Set, + val type: PollType, + val createdAt: Long, + val endsAt: Long?, +) { + fun isInWindow(createdAt: Long) = createdAt >= this.createdAt && (endsAt == null || createdAt <= endsAt) + fun accept(codes: List): Set = + when (type) { + PollType.SINGLE_CHOICE -> setOfNotNull(codes.firstOrNull { it in validCodes }) + PollType.MULTI_CHOICE -> codes.filterTo(mutableSetOf()) { it in validCodes } + } +} +``` + +`PollResponsesCache` gains `var policy: PollTallyPolicy?`, set by +`LocalCache.consume(PollEvent)` (and by `DesktopLocalCache.consume`) when the +poll event itself arrives — responses routinely arrive **before** the poll they +reference, so `ResponseTally` must recompute when the policy lands, and must +degrade to today's permissive behaviour while `policy == null`. +`ResponseTally` then also exposes `rejected: Int` for the "ignored" caption. + +Note the ordering hazard is real and already present: `consume(PollResponseEvent)` +calls `getOrCreateNote(pollId).pollState().addResponse(...)` +(`LocalCache.kt:2936`) on a note that may still be an empty placeholder. + +## 4. Data completeness (the second reason to fix this now) + +**Android never queries a poll's own relays for votes.** Kind 1018 rides along +in the generic engagement filter (`FilterRepliesAndReactionsToNotes.kt:75`), +whose relay set is `Note.relayUrlsForReactions()` = *author inbox relays + relays +the note was seen on* (`Note.kt:339`). NIP-88 votes are published to the relays +listed in the poll's own `relay` tags (`EventBroadcaster.kt:233` does this on the +write side), which the viewer usually isn't subscribed to. Desktop diagnosed and +fixed this per-card (`DesktopPollCard.kt:132-157`); Android tallies are +systematically short. + +That same filter also carries `limit = 100` and never pages, so any poll with +more than 100 responses truncates silently. + +**Proposal — one shared assembler, used by both platforms and by the results page:** + +- `commons/…/relayClient/…/polls/PollResponsesFilterAssembler.kt` — a + compose-scoped `Subscribable` keyed by poll id, filtering + `kinds = [1018], tags = {"e": [pollId]}` over `PollEvent.relays() + the + viewer's read relays` (this is desktop's fix, lifted out of the card). +- The results page additionally does a one-shot backfill with **`fetchAllPages`** + from `quartz/…/nip01Core/relay/client/accessories/` — do **not** hand-roll a + paging loop; that package already has it. +- Optional but cheap: a NIP-45 **`count`** call (same package) per relay gives + "relay reports 412 responses, 380 loaded" — the page can then be honest about + incompleteness instead of presenting a partial tally as final. + +## 5. Screen design + +### 5.1 Anatomy + +``` +┌─────────────────────────────────────────────┐ +│ ← Poll results ⟳ │ TopBarWithBackButton +├─────────────────────────────────────────────┤ +│ [avatar] Alice · 2d ago │ author row (UserCompose-style) +│ "Which relay should we default to?" │ poll question (content) +│ ⬤ Open · closes in 4h ⬡ Single choice │ status + type chips +│ 412 voters · 412 selections │ totals (§3.1) +├─────────────────────────────────────────────┤ +│ ▸ nos.lol 189 46% ✓ │ option rows, winner marked, +│ [▓▓▓▓▓▓▓▓▓░░░░░░░░░░] ◍◍◍◍ +185 │ animated bar + avatar stack +│ ▸ relay.damus.io 142 34% │ +│ [▓▓▓▓▓▓░░░░░░░░░░░░░] ◍◍◍◍ +138 │ +│ ▸ purplepag.es 81 20% │ +├─────────────────────────────────────────────┤ +│ You voted: nos.lol [Change vote] │ only when signed in + voted +├─────────────────────────────────────────────┤ +│ [All] [nos.lol] [damus] [purplepag.es] │ option filter chips +│ [Everyone ▾] [Recent ▾] 🔍 search │ audience + sort + search +├─────────────────────────────────────────────┤ +│ [avatar] you nos.lol 2d ago │ voter rows +│ [avatar] Bob (follows) damus 2d ago │ +│ [avatar] Carol nos.lol 1d ago │ +│ … │ +├─────────────────────────────────────────────┤ +│ 380 of ~412 loaded from 6 relays · 3 ignored│ completeness footer (§4) +└─────────────────────────────────────────────┘ +``` + +### 5.2 Behaviour decisions + +| Question | Decision | Why | +|---|---|---| +| Percent basis | single-choice: % of voters; multi-choice: % of voters, bars may exceed 100% in sum, each row captioned "N of M voters" | §3.1 — matches how people read a poll | +| Voter list default | flat list of **all** voters, each row showing which option(s) they chose | answers "who voted for what" in one screen without drilling | +| Option chips | filter the voter list to one option; the summary block never filters | keeps totals stable while browsing | +| Audience filter | `Everyone` (default) / `Following` / `Following + WoT` | reuses `account.allFollows.flow`, already the tally's priority set | +| Muted users | excluded by default via `account.isHidden`, with a footer line "2 hidden by your mute list" and a toggle | consistent with every other user list in the app | +| Sort | `Relevance` (you → follows → pubkey — today's `filterTo` comparator), `Newest`, `Oldest` | relevance is already implemented; the other two are one comparator each | +| Ordering of you | your own row always pinned first in Relevance, and your vote echoed in the "You voted" strip | already the `filterTo` behaviour | +| Live updates | the page is a live view of `pollState().responses`; new votes animate in | it's a StateFlow already; no refresh button needed except for the relay backfill | +| Closed polls | status chip flips to "Ended · 2d ago", "Change vote" disappears, late votes excluded (§3.3) with an "N late votes excluded" footnote | spec compliance, and it explains a number that would otherwise look wrong | +| Empty state | "No votes yet" + the option rows at 0 | | + +### 5.3 Privacy call-out + +NIP-88 responses are **public, unencrypted events** — this page doesn't disclose +anything new, but it makes a fact legible that many users have not internalised. +Two small additions belong with this work: + +- an ⓘ affordance on the results header: *"Poll votes are public. Anyone can see + who voted for what."* +- the same one-liner next to the vote controls in `RenderPollCard` **before** the + first vote — the honest place to say it is where the choice is made, not after. + +There is no "anonymous vote" option to offer; saying so plainly is the whole fix. + +## 6. Code structure + +Following `commons/ARCHITECTURE.md` (state + ViewModels + shared Compose in +`commons`, screens/nav platform-native): + +**New in `commons` (shared):** + +| File | Contents | +|---|---| +| `model/nip88Polls/PollTallyPolicy.kt` | §3.5 | +| `model/nip88Polls/PollResponsesCache.kt` *(edit)* | poll-aware `ResponseTally`, `totalVoters()`, `totalSelections()`, `rejected`, `lateVotes` | +| `viewmodels/nip88Polls/PollResultsViewModel.kt` | holds the poll `Note`; exposes `StateFlow` (header + option rows + filtered/sorted voter rows + completeness meta); owns filter/sort/search `MutableStateFlow`s; drives the backfill. Sibling in style to `viewmodels/LiveStreamTopZappersViewModel.kt`, with the usual nested `Factory` | +| `relayClient/…/polls/PollResponsesFilterAssembler.kt` | §4 | +| `ui/note/polls/PollResultsHeader.kt`, `PollOptionBreakdown.kt`, `PollVoterRow.kt` | slot-based shared composables — the user avatar/name cell is a `@Composable` slot supplied by each platform (`UserCompose` on Android, desktop's own row), per `compose-slot-api-pattern` | + +**New in `amethyst` (Android):** + +- `ui/screen/loggedIn/polls/results/PollResultsScreen.kt` — `LoadNote(noteId) { … }` + + `Scaffold(topBar = TopBarWithBackButton(...))` + `LazyColumn`, modelled on + `ContactListUsersScreen.kt:51`, which is the established "users related to note + X" screen in this codebase. +- `Route.PollResults(val noteId: String)` in `ui/navigation/routes/Routes.kt`, + registered with `composableFromEndArgs` in `AppNavigation.kt` + (the slide-in detail-screen builder). + +**New in `desktopApp`:** + +- `DeckColumnType.PollResults(noteId)` in `ui/deck/DeckColumnType.kt` (plus the + `title()` and `typeKey()` branches — those `when`s are exhaustive) and a render + branch in `DeckColumnContainer.kt`. Keep `VoterListPopup` as the quick peek and + add a **"See all voters"** footer that opens the column. + +**Edited (entry points):** + +- `Poll.kt` — make the totals line and the `UserGallery` `+N` chip clickable → + `nav.nav(Route.PollResults(noteId))`; add a "N votes" count next to the + percentage (currently absent). +- Note dropdown menu — a "Poll results" item when the note is a `PollEvent`. + +**Deliberately not built:** no new feed filter, no new event kind, no second +cache, no per-platform tally logic. + +## 7. Zap polls (kind 6969) — phase 4 + +`ZapPollEvent` results are **amount-weighted** (`PollNoteViewModel.refreshTallies()`, +`ZapPollNoteViewModel.kt:102`), not one-person-one-vote, so they share the page +*shell* but not the tally: + +- option rows show **sats per option** + zapper count, plus the poll's + `consensusThreshold` drawn as a line on the bars; +- voter rows are ordered by amount and show the amount; +- private zaps are visible only where the viewer can decrypt them + (`cachedIsPollOptionZappedBy`, `:230`) — the page must say "some zaps are + private and not shown" rather than quietly under-count. + +Sequencing it last keeps the NIP-88 work from being blocked on reconciling two +very different tally models. + +## 8. Testing + +- Extend `commons/src/commonTest/…/model/nip88Polls/PollResponsesCacheTest.kt`: + single-choice response carrying 3 tags counts once (§3.2); vote after `endsAt` + excluded and counted as late (§3.3); unknown code rejected and excluded from + the denominator (§3.4); multi-choice `totalVoters` vs `totalSelections` (§3.1); + policy arriving *after* responses recomputes the tally (§3.5); duplicate + pubkey latest-wins (existing behaviour, lock it in). +- New `PollResultsViewModelTest` with Turbine: filter/sort/search transitions, + mute exclusion, live insertion of a new vote. +- Manual: a >100-response poll (paging), a poll whose `relay` tags the viewer + isn't connected to (completeness), a multi-choice poll (denominators). + +## 9. Phasing + +| Phase | Scope | Ships value alone? | +|---|---|---| +| 0 | Poll-aware tally (§3) — commons + tests | yes — fixes the inline card's numbers on both platforms | +| 1 | Shared subscription + backfill (§4) | yes — Android tallies stop being short | +| 2 | `PollResultsViewModel` + shared composables + Android screen + route + entry points | yes — the feature | +| 3 | Filters, sort, search, completeness footer, privacy call-out (§5.2–5.3) | polish | +| 4 | Desktop column; then zap polls (§7) | parity, then breadth | + +Phases 0 and 1 are worth landing even if the page itself is deferred: they are +bug fixes wearing a feature's clothes. + +## 10. Open questions + +1. **Multi-choice bars summing past 100%** — the honest rendering, but visually + unusual. Alternative: keep selections as the basis and caption "% of + selections". Needs a call before §3.1 lands. +2. **Should the results page be reachable for polls you haven't voted on yet?** + The inline card deliberately hides results until you vote / opt in + (`Poll.kt:298-336`, `hasViewedPollResults`). Proposal: the page respects the + same gate — reaching it counts as opting in and calls `markPollResultsViewed`, + exactly like today's "View results" link. +3. **WoT tier in the audience filter** — worth it, or do `Everyone` / `Following` + cover the need? +4. **Export/share results** (copy as text, or a rendered image) — desirable, but + listed as out of scope until the page exists. diff --git a/commons/plans/README.md b/commons/plans/README.md index 58a2870bfa..cf0a4a40df 100644 --- a/commons/plans/README.md +++ b/commons/plans/README.md @@ -13,6 +13,7 @@ _Audited 2026-06-30. 6 plans: 2 shipped (archived), 2 in-progress, 2 queued, 0 a | ---- | ------- | | [2026-04-21-event-renderer.md](2026-04-21-event-renderer.md) | Cross-platform UI-agnostic `RenderedEvent` subsystem shared by Amy, Desktop, and Android; not started. | | [2026-05-30-amethyst-to-commons-migration.md](2026-05-30-amethyst-to-commons-migration.md) | Roadmap to move shared `amethyst` Android code into `commons`; keystone `Account`/`LocalCache` extraction not begun. | +| [2026-08-03-poll-results-page.md](2026-08-03-poll-results-page.md) | Extended NIP-88 poll results page (per-option counts + who voted for what) for Android and Desktop; also specifies four tally-correctness fixes and the missing poll-relay subscription. Proposed, not started. | ## Archived (shipped) | Plan | Summary | From 3954c2ebe653f2066ea6292bd642943acca635e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 14:32:26 +0000 Subject: [PATCH 083/227] docs(commons): add annotated mockup for the poll results page Self-contained HTML mockup accompanying the poll results proposal: the Android screen with seven numbered callouts, the Desktop deck column, the loading / closed / empty states, and a side-by-side of the two candidate readings for a multiple-choice percentage. Mockup interiors use the app's real theme values from ui/theme/Color.kt and Theme.kt so the screens read as Amethyst rather than as generic UI; the annotation layer around them uses a separate neutral set. Light and dark both supported, following the viewer's preference. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv --- commons/plans/2026-08-03-poll-results-page.md | 6 + .../assets/2026-08-03-poll-results-page.html | 1861 +++++++++++++++++ 2 files changed, 1867 insertions(+) create mode 100644 commons/plans/assets/2026-08-03-poll-results-page.html diff --git a/commons/plans/2026-08-03-poll-results-page.md b/commons/plans/2026-08-03-poll-results-page.md index 0cb3c8d17c..2a1bd9e142 100644 --- a/commons/plans/2026-08-03-poll-results-page.md +++ b/commons/plans/2026-08-03-poll-results-page.md @@ -12,6 +12,12 @@ consumers: amethyst, desktopApp > **Status:** proposal. Nothing implemented. This document surveys what exists, > names four correctness bugs the current tally has, and specifies a shared > results surface for Android and Desktop. _Authored 2026-08-03._ +> +> **Visual mockup:** [`assets/2026-08-03-poll-results-page.html`](assets/2026-08-03-poll-results-page.html) +> — annotated screens for the Android page, the Desktop deck column, the loading / +> closed / empty states, and a side-by-side of the two multi-choice percentage +> readings from §10.1. Open it in a browser; it renders in Amethyst's own theme +> tokens and follows the viewer's light/dark preference. ## 1. Motivation diff --git a/commons/plans/assets/2026-08-03-poll-results-page.html b/commons/plans/assets/2026-08-03-poll-results-page.html new file mode 100644 index 0000000000..20524de2f1 --- /dev/null +++ b/commons/plans/assets/2026-08-03-poll-results-page.html @@ -0,0 +1,1861 @@ +Poll results — Amethyst design proposal + + + +
+
+ + +
+
+ Design proposal + · + Amethyst + · + NIP‑88 polls + · + 2026‑08‑03 +
+

Poll results, with the counts and the people in them

+

+ A poll in Amethyst currently ends its life as a percentage bar and four avatars in the feed. + This proposes a dedicated results screen that answers the two questions people actually ask — + how many votes each answer got, and who voted for what — shared between Android and Desktop. +

+
+
Event kinds
1068 · 1018
+
Core module
commons
+
Front ends
Android · Desktop
+
New event kinds
0
+
Bugs fixed en route
4
+
+
+ + +
+
+
01 — The gap
+

What a finished poll looks like today

+

+ The inline card is deliberately compact, and that compactness is the whole problem: no vote + counts anywhere, at most four voter avatars, and no route to the fifth. Desktop already grew a + voter popup to work around it, which is the demand showing up as a divergence. +

+
+ +
+
+
Today — inline feed card
+
+
Which relay should Amethyst suggest first to brand-new users?
+
+
+
+
+
nos.lol
+ +
46%
+
+
+
+
+
+
relay.damus.io
+ +
35%
+
+
+
+
+
+
purplepag.es
+ +
19%
+
+
+
+
+

+ The +171 chip is a dead end — it counts people you cannot reach. Percentages appear + without their counts, and on a multiple-choice poll the denominator is wrong + (PollResponsesCache.kt:52). +

+
+ +
+
Proposed — the same card, two additions
+
+
Which relay should Amethyst suggest first to brand-new users?
+
nos.lol
+
relay.damus.io
+
purplepag.es
+
ⓘPoll votes are public. Anyone can see who voted for what.
+
+
View results
+
380 votes
+
+
+

+ Two changes to the card itself: a vote count beside the percentage, and the privacy + line placed where the choice is made rather than after it. The avatar stack and the + count both become the entry point to the full results screen. +

+
+
+
+ + +
+
+
02 — The screen
+

Poll results, Android

+

+ Summary above detail: totals, then per-option breakdown, then every voter with the option they + chose on the same row. Try the option chips — they filter the list below. +

+
+ +
+
+ 1 + 2 + 3 + 4 + 5 + 6 + 7 + +
+
+
←
+
Poll results
+
↻
+
+ +
+
+
+
NF
+
+
Nadia Ferreira
+
npub1v0ry…8qk2 · 2d ago
+
+
+
Which relay should Amethyst suggest first to brand-new users?
+
+ Open · closes in 4h + Single choice +
+
380 voters · 380 selections
+
+ +
+
+
+
+
+
+
nos.lol
+
175 of 380 voters
+
+ +
46%
+
✓
+
+
+
+
+
+
+
relay.damus.io
+
133 of 380 voters
+
+ +
35%
+
+
+
+
+
+
+
purplepag.es
+
72 of 380 voters
+
+ +
19%
+
+
+
+
+ +
+ You voted nos.lol + +
+ +
+
+ + + + +
+
+ Everyone ▾ + Relevance ▾ + ⚲ Search voters +
+
+ +
+
+
VP
+
+
you you
+
npub1gcx…wq7d
+
+
nos.lol
2d ago
+
+
+
BS
+
+
Bruno Sá follows
+
bruno@nostrplebs.com
+
+
relay.damus.io
2d ago
+
+
+
MK
+
+
mira.k follows
+
npub18ht…2m4v
+
+
nos.lol
2d ago
+
+
+
AR
+
+
Ana Reis follows
+
npub1t4qz…p09h
+
+
nos.lol
1d ago
+
+
+
RW
+
+
relaywrangler
+
npub1kk9m…4zzt
+
+
purplepag.es
1d ago
+
+
+
LN
+
+
npub1q8f…j3xw
+
no profile found
+
+
relay.damus.io
22h ago
+
+
+
TS
+
+
tomás
+
npub1w2ns…6vhc
+
+
nos.lol
14h ago
+
+
+
HM
+
+
hodlmatrix
+
npub1xr7d…5ktp
+
+
purplepag.es
9h ago
+
+
+ +
+
⚠380 of ~412 responses loaded from 6 relays
+
3 responses ignored (unknown option) · 2 voters hidden by your mute list
+
+
+
+
+ +
+
+
1
+
+
Refresh is for relays, not for the tally
+

+ The tally is already a live StateFlow — new votes animate in without asking. + The refresh action re-runs the backfill against the poll's own relays, which is the only + part that can be stale. +

+
+
+
+
2
+
+
Both totals, stated plainly
+

+ Voters is distinct pubkeys; selections is how many boxes were ticked. They + are identical on a single-choice poll and diverge on multiple choice — which is exactly + where today's percentages go wrong (§03). +

+
+
+
+
3
+
+
Counts beside percentages, winner marked
+

+ Each row gains N of M voters. The winner keeps the existing green treatment + from RenderClosedItem so the screen reads as the card it came + from, and the bar keeps the same 800 ms tween. +

+
+
+
+
4
+
+
Your own vote, and a way out of it
+

+ Desktop already allows re-voting; Android does not. This strip makes the behaviour the + same on both, and puts your vote where you don't have to hunt the list for yourself. +

+
+
+
+
5
+
+
Filter the list, never the summary
+

+ Option chips scope the voter list only — the bars above stay put, so the totals can't + appear to change while you browse. Audience is Everyone / Following / Following + WoT, + reusing the follow set the tally already sorts by. +

+
+
+
+
6
+
+
One row per person, with their choice on it
+

+ The default is a flat list of everyone, so "who voted for what" is answered without + drilling into an option. You are pinned first, follows next — that ordering already + exists in filterTo. Multiple-choice rows list every option + that person picked. +

+
+
+
+
7
+
+
Say how complete the tally is
+

+ A NIP-45 count against the poll's relays gives the "~412" — so a partial + tally can admit it instead of presenting itself as final. The second line accounts for + every response that was dropped and why. +

+
+
+
+
+
+ + +
+
+
03 — Open question, needs your call
+

What a multiple-choice percentage should mean

+

+ Same poll, same votes, two readings. 240 people voted and ticked 456 boxes between them. + This is the one decision that blocks the tally work, because it changes the numbers on the + existing feed card too. +

+
+ +
+
+
+

Share of voters

+ proposed +
+
+
+
+
+
Groups
168 of 240 voters
+
70%
+
+
+
+
+
+
Polls
132 of 240 voters
+
55%
+
+
+
+
+
+
Search
96 of 240 voters
+
40%
+
+
+
+
+
+
Wallet
60 of 240 voters
+
25%
+
+
+
bars sum to 190% — by design
+
+
+ "7 in 10 people want Groups." Matches how a poll bar is read. Costs you a total that + doesn't add to 100, which the per-row caption explains. +
+
+ +
+
+

Share of selections

+ what ships today +
+
+
+
+
+
Groups
168 of 456 selections
+
37%
+
+
+
+
+
+
Polls
132 of 456 selections
+
29%
+
+
+
+
+
+
Search
96 of 456 selections
+
21%
+
+
+
+
+
+
Wallet
60 of 456 selections
+
13%
+
+
+
bars sum to 100%
+
+
+ "Groups took 37% of the vote." Tidier, and a voter who ticks four boxes counts four + times. Desktop already contradicts this in its own footer, which uses distinct voters. +
+
+
+
+ + +
+
+
04 — States
+

The three the screen has to get right

+

+ Every one of these is a moment where a number could look like a bug unless the screen says + what happened. +

+
+ +
+
+
Loading · partial tally
+
+
+
←
+
Poll results
+
+
+
112 voters so far
+
+
+
+
+
nos.lol
58 of 112 voters
+
52%
+
+
+
+
+
+
relay.damus.io
34 of 112 voters
+
30%
+
+
+
+
+
+
+
+
+
+
+
⚠Loading page 2 of 5 from 6 relays…
+
+
+
+ +
+
Closed poll
+
+
+
←
+
Poll results
+
+
+
+ Ended 2d ago + Single choice +
+
412 voters · final
+
+
+
+
+
nos.lol
189 of 412 voters
+
46%
+
✓
+
+
+
+
+
+
relay.damus.io
142 of 412 voters
+
34%
+
+
+
+
+
+
No new votes accepted. 7 votes arrived after the deadline and are excluded.
+
+
+
+ +
+
No votes yet
+
+
+
←
+
Poll results
+
+
+
Open · closes in 3d
+
+
+
+
+
nos.lol
0 voters
+
0%
+
+
+
+
+
+
relay.damus.io
0 voters
+
0%
+
+
+
+
+ No votes yet + Be the first — your vote will show up here. +
+
+
+
+
+
+ + +
+
+
05 — Desktop
+

The same screen as a deck column

+

+ Desktop navigates by DeckColumnType, not routes, so results open as a + column beside the feed. The existing voter popup stays as the quick peek and grows a + See all voters footer that opens this column. +

+
+ +
+
+
☖
+
✉
+
◎
+
☆
+
+ +
+
Polls Open
+
+
+
Which relay should Amethyst suggest first?
+
Nadia Ferreira · 380 voters
+
+
+
Should the composer default to plain text?
+
Bruno Sá · 96 voters
+
+
+
Pick the next language for the UI
+
mira.k · 1,204 voters
+
+
+
+ +
+
Poll results 380 voters · 6 relays
+
+
Which relay should Amethyst suggest first to brand-new users?
+
+ Open · closes in 4h + Single choice +
+
+
+
+
+
nos.lol
175 of 380 voters
+
46%
+
✓
+
+
+
+
+
+
relay.damus.io
133 of 380 voters
+
35%
+
+
+
+
+
+
purplepag.es
72 of 380 voters
+
19%
+
+
+
+
+ All options + nos.lol · 175 + relay.damus.io · 133 +
+
+
+
VP
+
+
you you
+
+
nos.lol
2d ago
+
+
+
BS
+
+
Bruno Sá follows
+
+
relay.damus.io
2d ago
+
+
+
MK
+
+
mira.k follows
+
+
nos.lol
2d ago
+
+
+
+
+ +
Add a column
+
+
+ + +
+
+
06 — Prerequisites
+

Four tally bugs, and one missing subscription

+

+ The results page would inherit all of these, so they land first. The four tally bugs share one + root cause — ResponseTally never sees the + PollEvent, so it cannot know the poll's type, its valid option codes, + or its window. The fifth is separate: Android asks the wrong relays for votes. Both fixes + improve the feed card on their own. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
What breaksImpactConsequenceWhere
Multiple-choice denominatorwrong numberA voter who picks three options counts three times. Desktop's footer and its own bars already disagree about this.PollResponsesCache.kt:52
Every response tag countedspecNIP-88 keeps only the first tag on a single-choice poll. One malformed vote lands in every bucket.PollResponsesCache.kt:129
Votes after the deadlinespecOnly createdAt is compared, so a late vote beats a valid earlier one and closed polls keep moving.Note.kt:1685
Unknown option codeswrong numberA code that isn't in the poll creates a phantom bucket that still drags every real percentage down.PollResponsesCache.kt:129
Poll relays never queriedmissing dataAndroid reads votes from the author's inbox relays, not the relays the poll publishes votes to — so its tallies are short. Desktop patched this per-card.FilterRepliesAndReactionsToNotes.kt:89
+
+
+ + +
+
+
07 — Sequence
+

What lands, in what order

+

The first two phases are bug fixes wearing a feature's clothes — worth shipping even if the screen never gets built.

+
+ +
+
+
P0
+
Poll-aware tallyNew PollTallyPolicy in commons + tests. Fixes all four numbers above.
+
Ships alone — corrects the feed card on both platforms
+
+
+
P1
+
Shared response subscriptionDesktop's per-card fix lifted into a shared assembler, plus fetchAllPages backfill.
+
Ships alone — Android tallies stop being short
+
+
+
P2
+
The screenViewModel + shared composables + Android route + entry points from the card.
+
The feature
+
+
+
P3
+
Filters, sort, search, completeness footer, privacy lineEverything in §02 notes 5 and 7.
+
Polish
+
+
+
P4
+
Desktop column, then zap pollsKind 6969 shares the shell but not the tally — it is weighted by sats, not by people.
+
Parity, then breadth
+
+
+
+ +
+
+ On the palette. Every colour inside a mockup is lifted from + ui/theme/Color.kt and Theme.kt — the purple + accent, the green winner state, the amber warning, the surface ramp. The annotation layer around + them uses a separate neutral set, so you can always tell the app from the commentary. Greys stay + hue-free in both, per the decision recorded at Theme.kt:157. +
+
Mockup only — no code in this proposal has been written. Numbers and handles are fictional.
+
+ +
+
+ + From 6053dc4f1d5766b7603fefb3aae2f89f7b28fde9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 14:47:50 +0000 Subject: [PATCH 084/227] docs(commons): reuse the app's user row for poll voters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three revisions to the poll results proposal and its mockup. Voter rows are no longer a bespoke row. A voter is a user, and the app already draws users one way: SlimListItem with UserPicture / UsernameDisplay / AboutDisplay, which is what UserCompose is. The results row wants the first three verbatim and differs only in the trailing slot, where the vote goes instead of the follow buttons — so the proposal adds a trailingContent parameter to UserCompose with a default that leaves every existing call site unchanged, rather than forking the row. The second line is the profile's about text, and the bespoke follows/you chips are gone; ordering already carries that. Drops the privacy call-out entirely. Moves the audience filter, sort control and voter search out of the first version into their own later phase; option chips remain the only filter. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv --- commons/plans/2026-08-03-poll-results-page.md | 112 +++++-- .../assets/2026-08-03-poll-results-page.html | 291 +++++++++--------- 2 files changed, 232 insertions(+), 171 deletions(-) diff --git a/commons/plans/2026-08-03-poll-results-page.md b/commons/plans/2026-08-03-poll-results-page.md index 2a1bd9e142..d2ad3a2cdf 100644 --- a/commons/plans/2026-08-03-poll-results-page.md +++ b/commons/plans/2026-08-03-poll-results-page.md @@ -190,59 +190,110 @@ more than 100 responses truncates silently. │ You voted: nos.lol [Change vote] │ only when signed in + voted ├─────────────────────────────────────────────┤ │ [All] [nos.lol] [damus] [purplepag.es] │ option filter chips -│ [Everyone ▾] [Recent ▾] 🔍 search │ audience + sort + search ├─────────────────────────────────────────────┤ -│ [avatar] you nos.lol 2d ago │ voter rows -│ [avatar] Bob (follows) damus 2d ago │ -│ [avatar] Carol nos.lol 1d ago │ +│ ⬤ Ana Reis nos.lol 2d ago │ voter rows — SlimListItem, +│ Building things on nostr. │ same anatomy as UserCompose +│ ⬤ Bruno Sá damus 2d ago │ (§6.1): picture / name / +│ Relay operator, Lisbon. │ about, vote in the trailing +│ ⬤ npub1q8f…j3xw damus 22h ago│ slot │ … │ ├─────────────────────────────────────────────┤ │ 380 of ~412 loaded from 6 relays · 3 ignored│ completeness footer (§4) └─────────────────────────────────────────────┘ ``` +Audience filter, sort and voter search are **deliberately not in the first +version** — see §5.3. + ### 5.2 Behaviour decisions | Question | Decision | Why | |---|---|---| | Percent basis | single-choice: % of voters; multi-choice: % of voters, bars may exceed 100% in sum, each row captioned "N of M voters" | §3.1 — matches how people read a poll | | Voter list default | flat list of **all** voters, each row showing which option(s) they chose | answers "who voted for what" in one screen without drilling | +| Voter row | `SlimListItem` with the same slots `UserCompose` fills — `UserPicture` / `UsernameDisplay` / `AboutDisplay` — and the vote in `trailingContent` (§6.1) | a voter is a user; it should look like every other user row in the app | | Option chips | filter the voter list to one option; the summary block never filters | keeps totals stable while browsing | -| Audience filter | `Everyone` (default) / `Following` / `Following + WoT` | reuses `account.allFollows.flow`, already the tally's priority set | -| Muted users | excluded by default via `account.isHidden`, with a footer line "2 hidden by your mute list" and a toggle | consistent with every other user list in the app | -| Sort | `Relevance` (you → follows → pubkey — today's `filterTo` comparator), `Newest`, `Oldest` | relevance is already implemented; the other two are one comparator each | -| Ordering of you | your own row always pinned first in Relevance, and your vote echoed in the "You voted" strip | already the `filterTo` behaviour | +| Muted users | excluded by default via `account.isHidden`, with a footer line "2 hidden by your mute list" | consistent with every other user list in the app | +| Ordering | you first, then follows, then pubkey — today's `filterTo` comparator, no sort control | the ordering already exists and already does the useful thing | | Live updates | the page is a live view of `pollState().responses`; new votes animate in | it's a StateFlow already; no refresh button needed except for the relay backfill | | Closed polls | status chip flips to "Ended · 2d ago", "Change vote" disappears, late votes excluded (§3.3) with an "N late votes excluded" footnote | spec compliance, and it explains a number that would otherwise look wrong | | Empty state | "No votes yet" + the option rows at 0 | | -### 5.3 Privacy call-out +### 5.3 Deferred to a later pass -NIP-88 responses are **public, unencrypted events** — this page doesn't disclose -anything new, but it makes a fact legible that many users have not internalised. -Two small additions belong with this work: +Three controls are specified here so the layout leaves room for them, but are +**not** part of the first version: -- an ⓘ affordance on the results header: *"Poll votes are public. Anyone can see - who voted for what."* -- the same one-liner next to the vote controls in `RenderPollCard` **before** the - first vote — the honest place to say it is where the choice is made, not after. +- **audience filter** (`Everyone` / `Following` / `Following + WoT`), +- **sort control** (`Newest` / `Oldest` on top of the default relevance order), +- **voter search**. -There is no "anonymous vote" option to offer; saying so plainly is the whole fix. +Each is cheap on its own — the follow set and the comparator both already exist — +but none of them is needed to answer "how many votes did each answer get, and who +voted for what". They land once the page is real and it's clear which of them +people actually reach for. Until then the list is ordered you → follows → rest, +and the option chips are the only filter. ## 6. Code structure Following `commons/ARCHITECTURE.md` (state + ViewModels + shared Compose in `commons`, screens/nav platform-native): +### 6.1 The voter row is an existing user row + +A voter is a user, and the app already has one way to draw a user in a list. +`UserCompose` (`amethyst/…/ui/note/UserCompose.kt:52`) is `SlimListItem` +(`ui/layouts/listItem/SlimListItemLayout.kt:168`) with four slots filled: + +| Slot | Filled with | Renders | +|---|---|---| +| `leadingContent` | `UserPicture(user, Size55dp, …)` | avatar, with its own loading/blank handling | +| `headlineContent` | `UsernameDisplay(user, accountViewModel)` | the account's petname → `bestName()` → `pubkeyDisplayHex()`, with custom-emoji support via `CreateTextWithEmoji` | +| `supportingContent` | `AboutDisplay(user, accountViewModel)` | the profile's about text, one line, ellipsized, in `placeholderText` | +| `trailingContent` | `UserActionOptions(…)` | follow/unfollow + list buttons | + +The results row wants the first three **verbatim** and only differs in the +fourth, where the vote goes instead of the follow buttons. So don't write a new +row — add a slot to the existing one: + +```kotlin +@Composable +fun UserCompose( + baseUser: User, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: INav, + trailingContent: @Composable () -> Unit = { UserActionOptions(baseUser, accountViewModel, nav) }, +) { … } +``` + +Every existing call site is unchanged by the default; the poll screen passes the +option label + relative timestamp. This also means voter rows inherit, for free, +the things a hand-rolled row would silently lose: petname overrides, emoji in +display names, the npub fallback when metadata hasn't arrived, and the +tap-to-profile navigation from `routeFor(user)`. + +Two consequences worth stating: + +- **The second line is the user's about text, not their npub.** That is what the + rest of the app shows, and the npub already appears in the headline when there + is no metadata to show instead. +- **No bespoke "follows" or "you" chip.** `UserCompose` has never had one, and the + ordering (you → follows → rest) already carries that information. If a follow + marker is wanted later it should come from the existing follow-state renderer + used elsewhere, not a new badge invented for this screen. + +### 6.2 New and edited files + **New in `commons` (shared):** | File | Contents | |---|---| | `model/nip88Polls/PollTallyPolicy.kt` | §3.5 | | `model/nip88Polls/PollResponsesCache.kt` *(edit)* | poll-aware `ResponseTally`, `totalVoters()`, `totalSelections()`, `rejected`, `lateVotes` | -| `viewmodels/nip88Polls/PollResultsViewModel.kt` | holds the poll `Note`; exposes `StateFlow` (header + option rows + filtered/sorted voter rows + completeness meta); owns filter/sort/search `MutableStateFlow`s; drives the backfill. Sibling in style to `viewmodels/LiveStreamTopZappersViewModel.kt`, with the usual nested `Factory` | +| `viewmodels/nip88Polls/PollResultsViewModel.kt` | holds the poll `Note`; exposes `StateFlow` (header + option rows + voter rows + completeness meta); owns the option-chip selection; drives the backfill. Sibling in style to `viewmodels/LiveStreamTopZappersViewModel.kt`, with the usual nested `Factory` | | `relayClient/…/polls/PollResponsesFilterAssembler.kt` | §4 | -| `ui/note/polls/PollResultsHeader.kt`, `PollOptionBreakdown.kt`, `PollVoterRow.kt` | slot-based shared composables — the user avatar/name cell is a `@Composable` slot supplied by each platform (`UserCompose` on Android, desktop's own row), per `compose-slot-api-pattern` | +| `ui/note/polls/PollResultsHeader.kt`, `PollOptionBreakdown.kt` | slot-based shared composables for the header and the option bars, per `compose-slot-api-pattern`. **No shared voter-row composable** — each platform's own user row fills that job (§6.1) | **New in `amethyst` (Android):** @@ -259,10 +310,14 @@ Following `commons/ARCHITECTURE.md` (state + ViewModels + shared Compose in - `DeckColumnType.PollResults(noteId)` in `ui/deck/DeckColumnType.kt` (plus the `title()` and `typeKey()` branches — those `when`s are exhaustive) and a render branch in `DeckColumnContainer.kt`. Keep `VoterListPopup` as the quick peek and - add a **"See all voters"** footer that opens the column. + add a **"See all voters"** footer that opens the column. Its voter rows follow + the same rule as §6.1 against desktop's own user-row composable rather than the + bespoke avatar+name pair in `VoterListPopup`. **Edited (entry points):** +- `UserCompose.kt` — add the `trailingContent` slot (§6.1). Default keeps every + existing call site identical. - `Poll.kt` — make the totals line and the `UserGallery` `+N` chip clickable → `nav.nav(Route.PollResults(noteId))`; add a "N votes" count next to the percentage (currently absent). @@ -295,8 +350,8 @@ very different tally models. the denominator (§3.4); multi-choice `totalVoters` vs `totalSelections` (§3.1); policy arriving *after* responses recomputes the tally (§3.5); duplicate pubkey latest-wins (existing behaviour, lock it in). -- New `PollResultsViewModelTest` with Turbine: filter/sort/search transitions, - mute exclusion, live insertion of a new vote. +- New `PollResultsViewModelTest` with Turbine: option-chip transitions, mute + exclusion, live insertion of a new vote. - Manual: a >100-response poll (paging), a poll whose `relay` tags the viewer isn't connected to (completeness), a multi-choice poll (denominators). @@ -306,9 +361,10 @@ very different tally models. |---|---|---| | 0 | Poll-aware tally (§3) — commons + tests | yes — fixes the inline card's numbers on both platforms | | 1 | Shared subscription + backfill (§4) | yes — Android tallies stop being short | -| 2 | `PollResultsViewModel` + shared composables + Android screen + route + entry points | yes — the feature | -| 3 | Filters, sort, search, completeness footer, privacy call-out (§5.2–5.3) | polish | -| 4 | Desktop column; then zap polls (§7) | parity, then breadth | +| 2 | `PollResultsViewModel` + shared composables + `UserCompose` trailing slot + Android screen + route + entry points | yes — the feature | +| 3 | Desktop column | parity | +| 4 | Audience filter, sort, voter search (§5.3) | once it's clear which are actually reached for | +| 5 | Zap polls (§7) | breadth | Phases 0 and 1 are worth landing even if the page itself is deferred: they are bug fixes wearing a feature's clothes. @@ -323,7 +379,9 @@ bug fixes wearing a feature's clothes. (`Poll.kt:298-336`, `hasViewedPollResults`). Proposal: the page respects the same gate — reaching it counts as opting in and calls `markPollResultsViewed`, exactly like today's "View results" link. -3. **WoT tier in the audience filter** — worth it, or do `Everyone` / `Following` - cover the need? +3. **Trailing slot vs. a second entry point.** §6.1 adds a `trailingContent` + parameter to `UserCompose` with a default that preserves every call site. The + alternative is a separate `UserComposeWithTrailing` — more files, no behaviour + difference. Flagging it because it edits a composable used across the app. 4. **Export/share results** (copy as text, or a rendered image) — desirable, but listed as out of scope until the page exists. diff --git a/commons/plans/assets/2026-08-03-poll-results-page.html b/commons/plans/assets/2026-08-03-poll-results-page.html index 20524de2f1..2697e5eb6f 100644 --- a/commons/plans/assets/2026-08-03-poll-results-page.html +++ b/commons/plans/assets/2026-08-03-poll-results-page.html @@ -40,7 +40,7 @@ --app-accent: #6200ee; --app-good: #339900; --app-warn: #c09b14; - --app-following: #00897b; + --app-placeholder: color-mix(in srgb, #1c1c1c 42%, transparent); --app-shadow: 0 18px 40px rgba(0, 0, 0, .16), 0 2px 6px rgba(0, 0, 0, .08); --font-ui: ui-sans-serif, system-ui, "Segoe UI", Helvetica, Arial, sans-serif; @@ -78,7 +78,7 @@ --app-accent: #bb86fc; --app-good: #99cc33; --app-warn: #e1c419; - --app-following: #03dac5; + --app-placeholder: color-mix(in srgb, #e6e6e6 42%, transparent); --app-shadow: 0 18px 40px rgba(0, 0, 0, .6), 0 0 0 1px #2b2b2b; } } @@ -109,7 +109,7 @@ --app-accent: #bb86fc; --app-good: #99cc33; --app-warn: #e1c419; - --app-following: #03dac5; + --app-placeholder: color-mix(in srgb, #e6e6e6 42%, transparent); --app-shadow: 0 18px 40px rgba(0, 0, 0, .6), 0 0 0 1px #2b2b2b; } @@ -139,7 +139,7 @@ --app-accent: #6200ee; --app-good: #339900; --app-warn: #c09b14; - --app-following: #00897b; + --app-placeholder: color-mix(in srgb, #1c1c1c 42%, transparent); --app-shadow: 0 18px 40px rgba(0, 0, 0, .16), 0 2px 6px rgba(0, 0, 0, .08); } @@ -323,7 +323,8 @@ letter-spacing: .02em; flex: none; } - .avatar-sm { width: 22px; height: 22px; font-size: 9px; } + /* UserPicture at Size55dp, the size every user list in the app uses. */ + .avatar-user { width: 48px; height: 48px; font-size: 15px; } .avatar-xs { width: 21px; height: 21px; font-size: 8.5px; box-shadow: 0 0 0 2px var(--app-bg); @@ -514,74 +515,35 @@ .fchip:focus-visible, .app-btn:focus-visible { outline: 2px solid var(--app-accent); outline-offset: 2px; } - .filter-row { display: flex; gap: 7px; align-items: center; } - .select { - font-size: 11.5px; - padding: 4px 10px; - border-radius: 8px; - border: 1px solid var(--app-outline-2); - color: var(--app-on-2); - display: inline-flex; - gap: 6px; - align-items: center; - white-space: nowrap; - } - .search { - flex: 1; - font-size: 11.5px; - padding: 4px 10px; - border-radius: 8px; - border: 1px solid var(--app-outline-2); - color: var(--app-on-2); - display: flex; - gap: 6px; - align-items: center; - min-width: 0; - } - /* voter list */ + /* Voter list. Row anatomy is UserCompose's, not a new one: SlimListItem with + UserPicture / UsernameDisplay / AboutDisplay, and the vote in the trailing + slot where the follow buttons normally sit. */ .voters { display: flex; flex-direction: column; } .voter { display: flex; align-items: center; - gap: 10px; - padding: 10px 16px; + gap: 12px; + padding: 11px 16px; border-bottom: 1px solid var(--app-outline-2); } .voter:last-child { border-bottom: 0; } .voter-id { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; } .voter-name { - font-size: 13.5px; - font-weight: 600; - display: flex; - align-items: center; - gap: 6px; + font-size: 14.5px; + font-weight: 700; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } - .voter-handle { font-size: 11.5px; color: var(--app-on-2); } - .tag-follows { - font-size: 9.5px; - letter-spacing: .06em; - text-transform: uppercase; - color: var(--app-following); - border: 1px solid color-mix(in srgb, var(--app-following) 45%, transparent); - border-radius: 4px; - padding: 0 4px; - font-weight: 700; - flex: none; - } - .tag-you { - font-size: 9.5px; - letter-spacing: .06em; - text-transform: uppercase; - color: var(--app-accent); - border: 1px solid color-mix(in srgb, var(--app-accent) 45%, transparent); - border-radius: 4px; - padding: 0 4px; - font-weight: 700; - flex: none; + /* AboutDisplay: profile about text, one line, ellipsized, placeholderText + (= onSurface at 42%). */ + .voter-about { + font-size: 12.5px; + color: var(--app-placeholder); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } .voter-vote { text-align: right; @@ -590,8 +552,8 @@ gap: 1px; flex: none; } - .voter-choice { font-size: 12px; font-weight: 600; } - .voter-when { font-size: 11px; color: var(--app-on-2); font-variant-numeric: tabular-nums; } + .voter-choice { font-size: 12.5px; font-weight: 600; } + .voter-when { font-size: 11px; color: var(--app-placeholder); font-variant-numeric: tabular-nums; } .app-foot { padding: 11px 16px 14px; @@ -716,21 +678,7 @@ gap: 9px; } - .privacy-line { - font-size: 11.5px; - color: var(--app-on-2); - display: flex; - gap: 6px; - align-items: baseline; - } - .privacy-line b { color: var(--app-warn); font-weight: 700; } - .vote-opt { - border: 1px solid var(--app-outline-2); - border-radius: 10px; - padding: 10px 13px; - font-size: 14px; - } /* ── Decision A/B ───────────────────────────────────────── */ .ab { @@ -1062,7 +1010,8 @@
BS
LN
RW
-
+130
+
CF
+
+129
35%
@@ -1074,7 +1023,9 @@
19%
@@ -1089,22 +1040,64 @@
-
Proposed — the same card, two additions
+
Proposed — the same card, one addition
Which relay should Amethyst suggest first to brand-new users?
-
nos.lol
-
relay.damus.io
-
purplepag.es
-
ⓘPoll votes are public. Anyone can see who voted for what.
-
-
View results
-
380 votes
+
+
+
+
+
nos.lol
+ +
175
+
46%
+
+
+
+
+
+
relay.damus.io
+ +
133
+
35%
+
+
+
+
+
+
purplepag.es
+ +
72
+
19%
+
+
+
+
+
380 votes ›

- Two changes to the card itself: a vote count beside the percentage, and the privacy - line placed where the choice is made rather than after it. The avatar stack and the - count both become the entry point to the full results screen. + One change to the card itself: the vote count beside each percentage. The count + under the options and the avatar stack both become the entry point to the full results + screen — so the +171 stops being a dead end.

@@ -1165,11 +1158,12 @@
175 of 380 voters
+
MK
+
AR
+
TS
+
JD
+
+171
+
46%
✓
@@ -1182,10 +1176,12 @@
133 of 380 voters
+
BS
+
LN
+
RW
+
CF
+
+129
+
35%
@@ -1197,10 +1193,12 @@
72 of 380 voters
+
HM
+
NF
+
PS
+
EK
+
+68
+
19%
@@ -1219,75 +1217,69 @@ -
- Everyone ▾ - Relevance ▾ - ⚲ Search voters -
-
VP
+
VP
-
you you
-
npub1gcx…wq7d
+
you
+
Android + KMP. Nostr all day.
nos.lol
2d ago
-
BS
+
BS
-
Bruno Sá follows
-
bruno@nostrplebs.com
+
Bruno Sá
+
Relay operator in Lisbon. Runs a small paid relay.
relay.damus.io
2d ago
-
MK
+
MK
-
mira.k follows
-
npub18ht…2m4v
+
mira.k
+
Designer. Making nostr less frightening.
nos.lol
2d ago
-
AR
+
AR
-
Ana Reis follows
-
npub1t4qz…p09h
+
Ana Reis
+
Photographer, mostly film.
nos.lol
1d ago
-
RW
+
RW
relaywrangler
-
npub1kk9m…4zzt
+
Self-hosting everything since 2011.
purplepag.es
1d ago
-
LN
+
npub1q8f…j3xw
-
no profile found
relay.damus.io
22h ago
-
TS
+
TS
tomás
-
npub1w2ns…6vhc
+
café, código, bicicleta
nos.lol
14h ago
-
HM
+
HM
hodlmatrix
-
npub1xr7d…5ktp
+
21M or bust.
purplepag.es
9h ago
@@ -1351,20 +1343,23 @@
Filter the list, never the summary

Option chips scope the voter list only — the bars above stay put, so the totals can't - appear to change while you browse. Audience is Everyone / Following / Following + WoT, - reusing the follow set the tally already sorts by. + appear to change while you browse. An audience filter, a sort control and voter search + are deliberately held back; the list is ordered you → follows → rest, and it's + not yet clear which of the three people would actually reach for.

6
-
One row per person, with their choice on it
+
A voter row is just a user row

- The default is a flat list of everyone, so "who voted for what" is answered without - drilling into an option. You are pinned first, follows next — that ordering already - exists in filterTo. Multiple-choice rows list every option - that person picked. + Not a new row type — SlimListItem with exactly the slots + UserCompose fills: UserPicture, + UsernameDisplay, AboutDisplay. Only the trailing slot differs, + carrying the vote where the follow buttons normally sit. So the second line is the + person's about text, petnames and display-name emoji work, and the npub fallback + (row six) appears on its own when no profile has arrived — all for free.

@@ -1680,23 +1675,26 @@
-
VP
+
VP
-
you you
+
you
+
Android + KMP. Nostr all day.
nos.lol
2d ago
-
BS
+
BS
-
Bruno Sá follows
+
Bruno Sá
+
Relay operator in Lisbon.
relay.damus.io
2d ago
-
MK
+
MK
-
mira.k follows
+
mira.k
+
Designer. Making nostr less frightening.
nos.lol
2d ago
@@ -1789,18 +1787,23 @@
P2
-
The screenViewModel + shared composables + Android route + entry points from the card.
+
The screenViewModel + shared composables + the UserCompose trailing slot + Android route + entry points from the card.
The feature
P3
-
Filters, sort, search, completeness footer, privacy lineEverything in §02 notes 5 and 7.
-
Polish
+
Desktop deck columnSame ViewModel, same option bars, desktop's own user row.
+
Parity
P4
-
Desktop column, then zap pollsKind 6969 shares the shell but not the tally — it is weighted by sats, not by people.
-
Parity, then breadth
+
Audience filter, sort, voter searchHeld back on purpose — added once it's clear which of the three people reach for.
+
Only if wanted
+
+
+
P5
+
Zap pollsKind 6969 shares the shell but not the tally — it is weighted by sats, not by people.
+
Breadth
From 1bd39335b2726dc26daabaf9be959c42c4784fd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 15:05:23 +0000 Subject: [PATCH 085/227] docs(commons): use UserLine for poll voter rows, show NIP-05 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserLine (ShowUserSuggestionList.kt:185) is already the row this needs: a SlimListItem with ClickableUserPicture / UsernameDisplay / WatchAndDisplayNip05Row, and it already exposes a nullable trailingContent parameter. So the results screen writes no row and modifies no existing composable — it passes the option label and timestamp into the slot that is already there. Drops the previously proposed trailingContent addition to UserCompose. The second line is now the NIP-05 identifier rather than the about text, drawn the way the app draws it: local part, verified mark, domain, no @, with the local part ellipsizing and the domain left visible. A root identifier shows the domain alone; the npub appears only as the no-NIP-05 fallback. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv --- commons/plans/2026-08-03-poll-results-page.md | 86 +++++++++--------- .../assets/2026-08-03-poll-results-page.html | 87 +++++++++++++------ 2 files changed, 104 insertions(+), 69 deletions(-) diff --git a/commons/plans/2026-08-03-poll-results-page.md b/commons/plans/2026-08-03-poll-results-page.md index d2ad3a2cdf..f67990c6ec 100644 --- a/commons/plans/2026-08-03-poll-results-page.md +++ b/commons/plans/2026-08-03-poll-results-page.md @@ -191,11 +191,11 @@ more than 100 responses truncates silently. ├─────────────────────────────────────────────┤ │ [All] [nos.lol] [damus] [purplepag.es] │ option filter chips ├─────────────────────────────────────────────┤ -│ ⬤ Ana Reis nos.lol 2d ago │ voter rows — SlimListItem, -│ Building things on nostr. │ same anatomy as UserCompose -│ ⬤ Bruno Sá damus 2d ago │ (§6.1): picture / name / -│ Relay operator, Lisbon. │ about, vote in the trailing -│ ⬤ npub1q8f…j3xw damus 22h ago│ slot +│ ⬤ Ana Reis nos.lol 2d ago │ voter rows — UserLine as-is +│ ana ✓ nostrcheck.me │ (§6.1): picture / name / +│ ⬤ Bruno Sá damus 2d ago │ NIP-05, vote passed in as +│ bruno ✓ nostrplebs.com │ trailingContent +│ ⬤ npub1q8f…j3xw damus 22h ago│ (npub = no-NIP-05 fallback) │ … │ ├─────────────────────────────────────────────┤ │ 380 of ~412 loaded from 6 relays · 3 ignored│ completeness footer (§4) @@ -211,7 +211,7 @@ version** — see §5.3. |---|---|---| | Percent basis | single-choice: % of voters; multi-choice: % of voters, bars may exceed 100% in sum, each row captioned "N of M voters" | §3.1 — matches how people read a poll | | Voter list default | flat list of **all** voters, each row showing which option(s) they chose | answers "who voted for what" in one screen without drilling | -| Voter row | `SlimListItem` with the same slots `UserCompose` fills — `UserPicture` / `UsernameDisplay` / `AboutDisplay` — and the vote in `trailingContent` (§6.1) | a voter is a user; it should look like every other user row in the app | +| Voter row | `UserLine` unmodified, with the vote passed as its existing `trailingContent` (§6.1) | a voter is a user; the app already has this row, and it already has the slot | | Option chips | filter the voter list to one option; the summary block never filters | keeps totals stable while browsing | | Muted users | excluded by default via `account.isHidden`, with a footer line "2 hidden by your mute list" | consistent with every other user list in the app | | Ordering | you first, then follows, then pubkey — today's `filterTo` comparator, no sort control | the ordering already exists and already does the useful thing | @@ -239,49 +239,53 @@ and the option chips are the only filter. Following `commons/ARCHITECTURE.md` (state + ViewModels + shared Compose in `commons`, screens/nav platform-native): -### 6.1 The voter row is an existing user row +### 6.1 The voter row is `UserLine`, unmodified -A voter is a user, and the app already has one way to draw a user in a list. -`UserCompose` (`amethyst/…/ui/note/UserCompose.kt:52`) is `SlimListItem` -(`ui/layouts/listItem/SlimListItemLayout.kt:168`) with four slots filled: +A voter is a user, and the app already has a user row with a trailing slot: +**`UserLine`** (`amethyst/…/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt:185`), +the row the mention autocomplete and the follow-import screens use. It is +`SlimListItem` (`ui/layouts/listItem/SlimListItemLayout.kt:168`) with: | Slot | Filled with | Renders | |---|---|---| -| `leadingContent` | `UserPicture(user, Size55dp, …)` | avatar, with its own loading/blank handling | -| `headlineContent` | `UsernameDisplay(user, accountViewModel)` | the account's petname → `bestName()` → `pubkeyDisplayHex()`, with custom-emoji support via `CreateTextWithEmoji` | -| `supportingContent` | `AboutDisplay(user, accountViewModel)` | the profile's about text, one line, ellipsized, in `placeholderText` | -| `trailingContent` | `UserActionOptions(…)` | follow/unfollow + list buttons | - -The results row wants the first three **verbatim** and only differs in the -fourth, where the vote goes instead of the follow buttons. So don't write a new -row — add a slot to the existing one: +| `leadingContent` | `ClickableUserPicture(user, Size55dp, …)` | avatar | +| `headlineContent` | `UsernameDisplay(user, accountViewModel)` | petname → `bestName()` → `pubkeyDisplayHex()`, with custom-emoji support via `CreateTextWithEmoji` | +| `supportingContent` | `WatchAndDisplayNip05Row(user, accountViewModel)` | the NIP-05 identifier — local part, verified symbol, domain — in `colorScheme.nip05` | +| `trailingContent` | **caller-supplied, already `null`-able** | — | ```kotlin -@Composable -fun UserCompose( +fun UserLine( baseUser: User, - modifier: Modifier = Modifier, accountViewModel: AccountViewModel, - nav: INav, - trailingContent: @Composable () -> Unit = { UserActionOptions(baseUser, accountViewModel, nav) }, -) { … } + trailingContent: (@Composable (User) -> Unit)? = null, + colors: ListItemColors = ListItemDefaults.colors(), + onClick: () -> Unit, +) ``` -Every existing call site is unchanged by the default; the poll screen passes the -option label + relative timestamp. This also means voter rows inherit, for free, -the things a hand-rolled row would silently lose: petname overrides, emoji in -display names, the npub fallback when metadata hasn't arrived, and the -tap-to-profile navigation from `routeFor(user)`. +So the results screen writes **no row at all** and modifies **no existing +composable** — it calls `UserLine` and passes the option label plus the relative +timestamp as `trailingContent`. Voter rows then inherit for free the things a +hand-rolled row silently loses: petname overrides, emoji in display names, live +NIP-05 verification state, and the npub fallback when nothing else has arrived. Two consequences worth stating: -- **The second line is the user's about text, not their npub.** That is what the - rest of the app shows, and the npub already appears in the headline when there - is no metadata to show instead. -- **No bespoke "follows" or "you" chip.** `UserCompose` has never had one, and the +- **The second line is the NIP-05 identifier, not the npub.** `WatchAndDisplayNip05Row` + (`:214`) renders `pubkeyDisplayHex()` only in the `else` branch, when the user + has no NIP-05 — it is a fallback, not the intended content. Note the app draws + it as *name · verified symbol · domain* with no `@` (`:236-258`), and the + `hasLocalPart()` check means a root identifier (`_@domain`) shows the domain + alone. +- **No bespoke "follows" or "you" chip.** No user row in the app has one, and the ordering (you → follows → rest) already carries that information. If a follow - marker is wanted later it should come from the existing follow-state renderer - used elsewhere, not a new badge invented for this screen. + marker is wanted later it should come from the existing follow-state renderer, + not a badge invented for this screen. + +The one loose end: `UserLine` lives under `note/creators/userSuggestions/`, a +mention-autocomplete package, despite being a general-purpose row with three +callers already. Moving it to `ui/note/` is a tidy-up this change makes +worthwhile but does not require. ### 6.2 New and edited files @@ -316,8 +320,6 @@ Two consequences worth stating: **Edited (entry points):** -- `UserCompose.kt` — add the `trailingContent` slot (§6.1). Default keeps every - existing call site identical. - `Poll.kt` — make the totals line and the `UserGallery` `+N` chip clickable → `nav.nav(Route.PollResults(noteId))`; add a "N votes" count next to the percentage (currently absent). @@ -361,7 +363,7 @@ very different tally models. |---|---|---| | 0 | Poll-aware tally (§3) — commons + tests | yes — fixes the inline card's numbers on both platforms | | 1 | Shared subscription + backfill (§4) | yes — Android tallies stop being short | -| 2 | `PollResultsViewModel` + shared composables + `UserCompose` trailing slot + Android screen + route + entry points | yes — the feature | +| 2 | `PollResultsViewModel` + shared composables + Android screen + route + entry points | yes — the feature | | 3 | Desktop column | parity | | 4 | Audience filter, sort, voter search (§5.3) | once it's clear which are actually reached for | | 5 | Zap polls (§7) | breadth | @@ -379,9 +381,9 @@ bug fixes wearing a feature's clothes. (`Poll.kt:298-336`, `hasViewedPollResults`). Proposal: the page respects the same gate — reaching it counts as opting in and calls `markPollResultsViewed`, exactly like today's "View results" link. -3. **Trailing slot vs. a second entry point.** §6.1 adds a `trailingContent` - parameter to `UserCompose` with a default that preserves every call site. The - alternative is a separate `UserComposeWithTrailing` — more files, no behaviour - difference. Flagging it because it edits a composable used across the app. +3. **Move `UserLine` out of `userSuggestions/`?** It is a general-purpose row + sitting in a mention-autocomplete package (§6.1). The poll screen is its + fourth caller. Move it to `ui/note/` with this change, or leave it and + import across packages? 4. **Export/share results** (copy as text, or a rendered image) — desirable, but listed as out of scope until the page exists. diff --git a/commons/plans/assets/2026-08-03-poll-results-page.html b/commons/plans/assets/2026-08-03-poll-results-page.html index 2697e5eb6f..df227501d0 100644 --- a/commons/plans/assets/2026-08-03-poll-results-page.html +++ b/commons/plans/assets/2026-08-03-poll-results-page.html @@ -516,9 +516,9 @@ .app-btn:focus-visible { outline: 2px solid var(--app-accent); outline-offset: 2px; } - /* Voter list. Row anatomy is UserCompose's, not a new one: SlimListItem with - UserPicture / UsernameDisplay / AboutDisplay, and the vote in the trailing - slot where the follow buttons normally sit. */ + /* Voter list. This is UserLine, the app's existing user row: SlimListItem with + ClickableUserPicture / UsernameDisplay / WatchAndDisplayNip05Row, and the + vote passed into the trailingContent slot it already exposes. */ .voters { display: flex; flex-direction: column; } .voter { display: flex; @@ -536,10 +536,42 @@ white-space: nowrap; text-overflow: ellipsis; } - /* AboutDisplay: profile about text, one line, ellipsized, placeholderText - (= onSurface at 42%). */ - .voter-about { - font-size: 12.5px; + /* WatchAndDisplayNip05Row: local part · verified symbol · domain, no "@", + all in colorScheme.nip05 (= the accent). Falls back to the npub only when + the user has no NIP-05 at all. */ + .voter-nip05 { + display: flex; + align-items: center; + gap: 3px; + min-width: 0; + font-size: 13px; + color: var(--app-accent); + } + /* Only the local part ellipsizes; the domain is TextOverflow.Visible in the + app, so it never truncates. */ + .n5-local { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + .n5-domain { flex: none; white-space: nowrap; } + .n5-check { + flex: none; + width: 13px; + height: 13px; + border-radius: 50%; + background: var(--app-accent); + color: var(--app-bg); + font-size: 9px; + font-weight: 700; + display: grid; + place-items: center; + line-height: 1; + } + .voter-npub { + font-size: 13px; color: var(--app-placeholder); overflow: hidden; white-space: nowrap; @@ -1224,7 +1256,7 @@
VP
you
-
Android + KMP. Nostr all day.
+
me✓nostrplebs.com
nos.lol
2d ago
@@ -1232,7 +1264,7 @@
BS
Bruno Sá
-
Relay operator in Lisbon. Runs a small paid relay.
+
bruno✓nostrplebs.com
relay.damus.io
2d ago
@@ -1240,7 +1272,7 @@
MK
mira.k
-
Designer. Making nostr less frightening.
+
mira✓nostrcheck.me
nos.lol
2d ago
@@ -1248,7 +1280,7 @@
AR
Ana Reis
-
Photographer, mostly film.
+
ana✓nostrcheck.me
nos.lol
1d ago
@@ -1256,14 +1288,15 @@
RW
relaywrangler
-
Self-hosting everything since 2011.
+
wrangler✓relay.tools
purplepag.es
1d ago
-
+
KM
-
npub1q8f…j3xw
+
Katya M.
+
npub1q8f…j3xw
relay.damus.io
22h ago
@@ -1271,7 +1304,7 @@
TS
tomás
-
café, código, bicicleta
+
tomas✓nostr.pt
nos.lol
14h ago
@@ -1279,7 +1312,7 @@
HM
hodlmatrix
-
21M or bust.
+
✓21m.io
purplepag.es
9h ago
@@ -1352,14 +1385,14 @@
6
-
A voter row is just a user row
+
This is UserLine, unmodified

- Not a new row type — SlimListItem with exactly the slots - UserCompose fills: UserPicture, - UsernameDisplay, AboutDisplay. Only the trailing slot differs, - carrying the vote where the follow buttons normally sit. So the second line is the - person's about text, petnames and display-name emoji work, and the npub fallback - (row six) appears on its own when no profile has arrived — all for free. + Not a new row, and not an edited one: the results screen calls the app's existing + UserLine and passes the vote as its + trailingContent, a parameter it already has. So the second line is the + NIP-05 identifier the way the app draws it — local part, verified mark, domain, + no @ — with a root identifier showing the domain alone (row eight) and the + npub appearing only where there is no NIP-05 to show (row six).

@@ -1678,7 +1711,7 @@
VP
you
-
Android + KMP. Nostr all day.
+
me✓nostrplebs.com
nos.lol
2d ago
@@ -1686,7 +1719,7 @@
BS
Bruno Sá
-
Relay operator in Lisbon.
+
bruno✓nostrplebs.com
relay.damus.io
2d ago
@@ -1694,7 +1727,7 @@
MK
mira.k
-
Designer. Making nostr less frightening.
+
mira✓nostrcheck.me
nos.lol
2d ago
@@ -1787,7 +1820,7 @@
P2
-
The screenViewModel + shared composables + the UserCompose trailing slot + Android route + entry points from the card.
+
The screenViewModel + shared composables + Android route + entry points from the card. No existing composable is modified.
The feature
From 846eeeb904ebaa58b67cf0c1e8d380bf01e4fee3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 15:52:37 +0000 Subject: [PATCH 086/227] feat(polls): poll results screen with counts and voters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated NIP-88 poll results screen showing how many votes each option got and who voted for what, and fixes the tally bugs and the missing subscription it would otherwise have inherited. Tally is now poll-aware. PollTallyPolicy carries the kind-1068 rules — valid option codes, poll type, and the open window — into ResponseTally, which previously had no access to the poll it was counting. That fixes four things at once: single-choice polls now read only the first response tag instead of every one of them, unknown option codes no longer create phantom buckets that drag real percentages down, responses stamped outside the poll's window are excluded rather than winning on timestamp, and percentages divide by distinct voters instead of total selections. Responses routinely arrive before their poll, so the tally starts permissive and recomputes when updatePolicy lands; both caches set it from either arrival order. Percentages are now share-of-voters. Single choice is unchanged; on multiple choice a bar reads "7 in 10 people" and the bars can sum past 100%. Android now asks the poll's own relay tags for kind 1018. Votes are published there per NIP-88 and EventBroadcaster obeys that on the way out, but the engagement filter only queried the author's inbox relays and where the note was seen, so tallies were systematically short. Desktop had already patched this per-card. The screen itself reads that same tally off the poll Note — no new subscription, no second cache, so the feed card and the results page cannot disagree. Voter rows are UserLine unmodified, with the vote passed into the trailingContent slot it already exposes. Tapping an option scopes the list without moving the summary above it. Muted voters still count toward the totals but are not listed, and the footer accounts for every response excluded and why. Entry points: a vote count beside each percentage on the feed card, and a "N votes" link that opens the screen — so the avatar stack's "+N" is no longer a dead end. Desktop column, audience filter, sort, search and zap polls are not in this change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv --- .../amethyst/model/LocalCache.kt | 27 +- .../FilterRepliesAndReactionsToNotes.kt | 9 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../amethyst/ui/note/types/Poll.kt | 38 + .../polls/results/PollResultsScreen.kt | 650 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 33 + .../model/nip88Polls/PollResponsesCache.kt | 110 ++- .../model/nip88Polls/PollTallyPolicy.kt | 80 +++ .../nip88Polls/PollResultsViewModel.kt | 216 ++++++ .../nip88Polls/PollResponsesCacheTest.kt | 175 ++++- .../desktop/cache/DesktopLocalCache.kt | 5 + .../cache/DesktopLocalCachePollTest.kt | 4 +- 13 files changed, 1320 insertions(+), 33 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollTallyPolicy.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 7ffa080688..322e793196 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollTallyPolicy import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter import com.vitorpamplona.amethyst.commons.model.observables.NewEventMatchingFilter @@ -2946,6 +2947,11 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { val new = consumeRegularEvent(event, relay, wasVerified) if (new) { pollNote.pollState().addResponse(responseNote) + // Responses and their poll race each other. If the poll is already here, hand the + // tally its rules now; if it isn't, consume(PollEvent) does it on arrival. + (pollNote.event as? PollEvent)?.let { + pollNote.pollState().updatePolicy(PollTallyPolicy.from(it)) + } } return new } @@ -2953,6 +2959,21 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { return false } + fun consume( + event: PollEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val new = consumeRegularEvent(event, relay, wasVerified) + attachToRelayGroupIfScoped(event, relay) + + // Not gated on `new`: the tally may have been built from responses that arrived before this + // poll did, and updatePolicy is idempotent for the usual re-delivery from another relay. + getOrCreateNote(event.id).pollState().updatePolicy(PollTallyPolicy.from(event)) + + return new + } + fun consume( event: FileStorageEvent, relay: NormalizedRelayUrl?, @@ -3528,11 +3549,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { } } - is PollEvent -> { - consumeRegularEvent(event, relay, wasVerified).also { - attachToRelayGroupIfScoped(event, relay) - } - } + is PollEvent -> consume(event, relay, wasVerified) is ThreadEvent -> { consumeRegularEvent(event, relay, wasVerified).also { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 64510793f8..dda13a51ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent @@ -89,6 +90,14 @@ fun filterRepliesAndReactionsToNotes( note.relayUrlsForReactions().forEach { relay -> add(relay, note.idHex) } + + // NIP-88 tells respondents to publish their kind-1018 votes to the relays the poll + // itself declares, and EventBroadcaster obeys that on the way out. Those relays are + // usually not the author's inbox nor where we saw the poll, so without this the + // tally silently under-counts — often down to just our own vote. + (note.event as? PollEvent)?.relays()?.forEach { relay -> + add(relay, note.idHex) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 1b10a93069..9e5249d0c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -238,6 +238,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodca import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.PodcastAuthoringScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results.PollResultsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.ProductsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen @@ -616,6 +617,7 @@ fun BuildNavigation( composableFromEndArgs { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ShareNoteAsQrScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ContactListUsersScreen(it.noteId, accountViewModel, nav) } + composableFromEndArgs { PollResultsScreen(it.noteId, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } composableFromEndArgs { UrlScreen(it, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 399db4dc84..5d18946dff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -573,6 +573,10 @@ sealed class Route { val noteId: String, ) : Route() + @Serializable data class PollResults( + val noteId: String, + ) : Route() + @Serializable data class Hashtag( val hashtag: String, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 0bcc0df398..4697b203f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -60,6 +60,7 @@ import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -81,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags @@ -199,12 +201,40 @@ fun InnerRenderPoll( ) } + if (!makeItShort) { + PollResultsLink(note, nav) + } + if (event.hasHashtags()) { DisplayUncitedHashtags(event, event.content, callbackUri, accountViewModel, nav) } } } +/** + * The way out of the card. The avatar stack tops out at four faces and the "+N" chip counts people + * the card has no room to show — this is the door to the rest of them. + */ +@Composable +private fun PollResultsLink( + note: Note, + nav: INav, +) { + val tally by note.pollState().responses.collectAsStateWithLifecycle() + val voters = tally.totalVoters() + if (voters <= 0) return + + Text( + text = pluralStringResource(R.plurals.poll_results_vote_count_link, voters, voters), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .clickable { nav.nav(Route.PollResults(note.idHex)) } + .padding(vertical = 4.dp), + ) +} + @Stable class PollCard( val options: List, @@ -536,6 +566,14 @@ private fun RenderClosedItem( UserGallery(tally, resultContent) } + // The percentage alone never said how many people that was. + Text( + text = tally.users.size.toString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(end = 6.dp), + ) + Text( text = "${(tally.percent * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt new file mode 100644 index 0000000000..02512e05ce --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt @@ -0,0 +1,650 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollOptionResult +import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollResultsUiState +import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollResultsViewModel +import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollVoterRow +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.SmallishBorder +import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.map + +private val VoteColumnWidth = 116.dp + +/** + * The full results of a NIP-88 poll: how many votes each option got, and who voted for what. + * + * Everything here reads the tally already hanging off the poll [Note] — the vote counts on the feed + * card and the numbers on this screen are the same object, so they can never disagree. + */ +@Composable +fun PollResultsScreen( + noteId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadNote(baseNoteHex = noteId, accountViewModel) { note -> + if (note == null) { + PollResultsScaffold(nav) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringRes(R.string.poll_results_loading), + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } else { + PollResults(note, accountViewModel, nav) + } + } +} + +@Composable +private fun PollResults( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + + // Opening this screen is the opt-in, exactly like the card's "View results" link — so the feed + // card stops hiding the tally behind a tap once you have been here. + LaunchedEffect(note.idHex) { + (note.event as? PollEvent)?.let { accountViewModel.markPollResultsViewed(it.id, it.endsAt()) } + } + + val viewModel: PollResultsViewModel = + viewModel( + key = "PollResults-${note.idHex}", + factory = + PollResultsViewModel.Factory( + pollNote = note, + forKey = account.pubKey, + isHidden = { account.isHidden(it) }, + follows = account.allFollows.flow.map { it.authors }, + hiddenChanges = account.hiddenUsers.flow, + ), + ) + + val state by viewModel.uiState.collectAsStateWithLifecycle() + val selected by viewModel.selectedOption.collectAsStateWithLifecycle() + + PollResultsScaffold(nav) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item("header") { + PollHeader(note, state, accountViewModel, nav) + } + + item("options") { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.options.forEach { option -> + key(option.code) { + OptionBar( + option = option, + isSelected = option.code == selected, + isMyPick = option.code in state.myVote, + accountViewModel = accountViewModel, + nav = nav, + onClick = { viewModel.selectOption(option.code) }, + ) + } + } + } + } + + if (state.options.size > 1) { + item("chips") { + OptionFilterRow(state, selected, onSelect = viewModel::selectOption) + } + } + + item("voters-divider") { + HorizontalDivider( + modifier = Modifier.padding(top = 12.dp), + thickness = DividerThickness, + ) + } + + items(state.voters, key = { it.user.pubkeyHex }) { voter -> + VoterRow(voter, accountViewModel, nav) + HorizontalDivider(thickness = DividerThickness) + } + + item("footer") { + ResultsFooter(state) + } + } + } +} + +@Composable +private fun PollResultsScaffold( + nav: INav, + content: @Composable () -> Unit, +) { + Scaffold( + topBar = { TopBarWithBackButton(caption = stringRes(R.string.poll_results_title), nav = nav) }, + ) { padding -> + Box(Modifier.padding(padding)) { content() } + } +} + +// ------------------------------------------------------------------------------------------- +// Header: who asked, what they asked, and the totals in one glance. +// ------------------------------------------------------------------------------------------- + +@Composable +private fun PollHeader( + note: Note, + state: PollResultsUiState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? PollEvent + + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + note.author?.let { author -> + Row(verticalAlignment = Alignment.CenterVertically) { + UserPicture(author, Size25dp, accountViewModel = accountViewModel, nav = nav) + Spacer(Modifier.width(8.dp)) + UsernameDisplay(author, accountViewModel = accountViewModel) + TimeAgo(note.createdAt() ?: 0L, TimeAgoStyle.Dotted) + } + } + + event?.content?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + PollStatusChip(state.endsAt) + PollTypeChip(state) + } + + TotalsLine(state) + } +} + +@Composable +private fun PollStatusChip(endsAt: Long?) { + val hasEnded = endsAt != null && endsAt < TimeUtils.now() + val tint = if (hasEnded) MaterialTheme.colorScheme.grayText else MaterialTheme.colorScheme.allGoodColor + + Chip(tint) { + // A slow breath on the dot: the tally really is live, and this is the only thing on the + // screen that says so without adding a control nobody asked for. + val alpha = + if (hasEnded) { + 1f + } else { + val transition = rememberInfiniteTransition(label = "pollLive") + transition + .animateFloat( + initialValue = 1f, + targetValue = 0.25f, + animationSpec = infiniteRepeatable(tween(1400), RepeatMode.Reverse), + label = "pollLiveDot", + ).value + } + + Box( + Modifier + .size(7.dp) + .alpha(alpha) + .clip(CircleShape) + .background(tint), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = + if (endsAt == null) { + stringRes(R.string.poll_results_open) + } else if (hasEnded) { + stringRes(R.string.poll_results_ended, timeAgoNoDot(endsAt, LocalContext.current)) + } else { + // Ahead, not ago: timeAgoNoDot on a future stamp collapses to "now". + stringRes(R.string.poll_results_closes_in, timeAheadNoDot(endsAt, LocalContext.current)) + }, + style = MaterialTheme.typography.labelMedium, + color = tint, + ) + } +} + +@Composable +private fun PollTypeChip(state: PollResultsUiState) { + val gray = MaterialTheme.colorScheme.grayText + Chip(gray) { + Text( + text = + stringRes( + if (state.type == PollType.MULTI_CHOICE) { + R.string.poll_multiple_choice + } else { + R.string.poll_single_choice + }, + ), + style = MaterialTheme.typography.labelMedium, + color = gray, + ) + } +} + +@Composable +private fun Chip( + tint: Color, + content: @Composable () -> Unit, +) { + Row( + modifier = + Modifier + .clip(CircleShape) + .border(1.dp, tint.copy(alpha = 0.4f), CircleShape) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } +} + +@Composable +private fun TotalsLine(state: PollResultsUiState) { + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = state.totalVoters.toString(), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.width(6.dp)) + Text( + text = pluralStringResource(R.plurals.poll_results_voters, state.totalVoters), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(bottom = 3.dp), + ) + + // Only worth saying when the two numbers can differ — i.e. multiple choice. + if (state.totalSelections != state.totalVoters) { + Text( + text = stringRes(R.string.poll_results_selections, state.totalSelections), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(bottom = 3.dp), + ) + } + } +} + +// ------------------------------------------------------------------------------------------- +// Option bars. +// ------------------------------------------------------------------------------------------- + +@Composable +private fun OptionBar( + option: PollOptionResult, + isSelected: Boolean, + isMyPick: Boolean, + accountViewModel: AccountViewModel, + nav: INav, + onClick: () -> Unit, +) { + val winner = MaterialTheme.colorScheme.allGoodColor + val accent = MaterialTheme.colorScheme.primary + val barColor = if (option.isWinning) winner else accent + + val borderColor by animateColorAsState( + targetValue = + when { + isSelected -> accent + option.isWinning -> winner.copy(alpha = 0.6f) + else -> MaterialTheme.colorScheme.grayText.copy(alpha = 0.4f) + }, + label = "pollOptionBorder", + ) + + // Same 800ms tween the feed card uses, so a vote cast there and read here moves identically. + val progress by animateFloatAsState( + targetValue = option.percent, + animationSpec = tween(durationMillis = 800), + label = "pollOptionBar", + ) + + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(SmallishBorder) + .border(if (isSelected) 2.dp else 1.dp, borderColor, SmallishBorder) + .background( + if (option.isWinning) winner.copy(alpha = 0.12f) else MaterialTheme.colorScheme.subtleBorder, + ).clickable(onClick = onClick), + ) { + Box( + modifier = + Modifier + .matchParentSize() + .alpha(0.32f) + .drawWithContent { + clipRect(right = size.width * progress) { drawRect(barColor) } + drawContent() + }, + ) + + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = option.label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (isMyPick) { + Spacer(Modifier.width(6.dp)) + Text( + text = stringRes(R.string.poll_results_your_pick), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } + Text( + text = pluralStringResource(R.plurals.poll_results_option_count, option.voters, option.voters), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + + Spacer(Modifier.width(10.dp)) + AvatarStack(option, accountViewModel, nav) + Spacer(Modifier.width(10.dp)) + + Text( + text = "${(option.percent * 100).toInt()}%", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.End, + color = if (option.isWinning) winner else Color.Unspecified, + ) + } + } +} + +@Composable +private fun AvatarStack( + option: PollOptionResult, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (option.topVoters.isEmpty()) return + + Row(horizontalArrangement = Arrangement.spacedBy((-8).dp), verticalAlignment = Alignment.CenterVertically) { + option.topVoters.forEach { user -> + key(user.pubkeyHex) { + UserPicture(user, Size25dp, accountViewModel = accountViewModel, nav = nav) + } + } + val rest = option.voters - option.topVoters.size + if (rest > 0) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(Size25dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + ) { + Text( + text = "+$rest", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} + +// ------------------------------------------------------------------------------------------- +// Voter list. +// ------------------------------------------------------------------------------------------- + +@Composable +private fun OptionFilterRow( + state: PollResultsUiState, + selected: String?, + onSelect: (String?) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + FilterChip( + label = stringRes(R.string.poll_results_all_options), + isSelected = selected == null, + onClick = { onSelect(null) }, + ) + state.options.forEach { option -> + key(option.code) { + FilterChip( + label = "${option.label} · ${option.voters}", + isSelected = option.code == selected, + onClick = { onSelect(option.code) }, + ) + } + } + } +} + +@Composable +private fun FilterChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + val accent = MaterialTheme.colorScheme.primary + Row( + modifier = + Modifier + .clip(CircleShape) + .background(if (isSelected) accent.copy(alpha = 0.16f) else Color.Transparent) + .border(1.dp, if (isSelected) accent.copy(alpha = 0.5f) else MaterialTheme.colorScheme.grayText.copy(alpha = 0.4f), CircleShape) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = if (isSelected) accent else MaterialTheme.colorScheme.grayText, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + ) + } +} + +/** + * A voter is a user, so this is [UserLine] — the app's own row — with the vote handed to the + * trailing slot it already exposes, in place of the follow buttons. + */ +@Composable +private fun VoterRow( + voter: PollVoterRow, + accountViewModel: AccountViewModel, + nav: INav, +) { + UserLine( + baseUser = voter.user, + accountViewModel = accountViewModel, + trailingContent = { VoteChoice(voter) }, + onClick = { nav.nav(routeFor(voter.user)) }, + ) +} + +@Composable +private fun VoteChoice(voter: PollVoterRow) { + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier.width(VoteColumnWidth), + ) { + Text( + text = voter.labels.joinToString(", "), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.End, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + TimeAgo(voter.createdAt, TimeAgoStyle.Short) + } +} + +// ------------------------------------------------------------------------------------------- +// Footer: what the numbers above do not include. +// ------------------------------------------------------------------------------------------- + +@Composable +private fun ResultsFooter(state: PollResultsUiState) { + val notes = + remember(state) { + buildList { + if (state.lateVotes > 0) add(R.plurals.poll_results_late_votes to state.lateVotes) + if (state.ignoredVotes > 0) add(R.plurals.poll_results_ignored_votes to state.ignoredVotes) + if (state.hiddenVoters > 0) add(R.plurals.poll_results_hidden_voters to state.hiddenVoters) + } + } + + if (state.totalVoters == 0) { + Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { + Text( + text = stringRes(R.string.poll_results_no_votes), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } + + if (notes.isNotEmpty()) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + notes.forEach { (res, count) -> + Text( + text = pluralStringResource(res, count, count), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1843dbfdd4..54644f7698 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -4256,6 +4256,39 @@ Social proof Submit View results + Poll results + Loading poll… + Open + Closes in %1$s + Ended %1$s ago + All options + Your vote + · %1$d selections + No votes yet + + voter + voters + + + %1$d vote › + %1$d votes › + + + %1$d voter + %1$d voters + + + %1$d vote arrived after the poll closed and is excluded. + %1$d votes arrived after the poll closed and are excluded. + + + %1$d response did not pick a valid option and is excluded. + %1$d responses did not pick a valid option and are excluded. + + + %1$d voter is hidden by your mute list. + %1$d voters are hidden by your mute list. + Restart Accept Decline diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt index 95bfb6a3ad..59d638f321 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt @@ -43,13 +43,72 @@ class PollResponsesCache : UserDependencies { class ResponseTally( val allResponses: List = emptyList(), + /** + * The poll's own rules. Null until the kind-1068 event arrives — responses often show up + * first — in which case the tally stays permissive rather than dropping votes it cannot + * yet validate. + */ + val policy: PollTallyPolicy? = null, ) { - val votes: Map = allResponses.latestByAuthor() - val tally: Map> = votes.votesByOption() + /** Responses stamped outside the poll's window, excluded from [votes]. */ + val lateVotes: Int + + /** Voters whose response cast no valid option code at all, excluded from [tally]. */ + val ignoredVotes: Int + + /** One response per author — the latest one that is actually eligible. */ + val votes: Map + + /** Option code -> the voters who picked it. */ + val tally: Map> + + /** Voters whose response cast at least one valid option code. */ + val countedVoters: Set + + init { + var late = 0 + val eligible = + if (policy == null) { + allResponses + } else { + allResponses.filter { note -> + val event = note.event + val inWindow = event !is PollResponseEvent || policy.isInWindow(event.createdAt) + if (!inWindow) late++ + inWindow + } + } + + lateVotes = late + votes = eligible.latestByAuthor() + + val counted = mutableMapOf>() + val voters = mutableSetOf() + votes.forEach { (user, responseEvent) -> + val codes = responseEvent.responses() + val accepted = policy?.accept(codes) ?: codes.toSet() + if (accepted.isNotEmpty()) { + voters.add(user) + accepted.forEach { code -> counted.getOrPut(code) { mutableSetOf() }.add(user) } + } + } + + ignoredVotes = votes.size - voters.size + countedVoters = voters + tally = counted + } fun winning() = tally.maxByOrNull { it.value.size }?.key - fun totalVotes() = tally.entries.sumOf { it.value.size } + /** + * Distinct people whose vote counts. This is the denominator for every percentage: on a + * multiple-choice poll someone who ticks three boxes is still one voter, so the bars read + * as "share of people" and may sum past 100%. + */ + fun totalVoters() = countedVoters.size + + /** Boxes ticked across all voters. Equal to [totalVoters] on a single-choice poll. */ + fun totalSelections() = tally.entries.sumOf { it.value.size } } val responses = MutableStateFlow(ResponseTally()) @@ -61,6 +120,7 @@ class PollResponsesCache : UserDependencies { responses.update { ResponseTally( it.allResponses + note, + it.policy, ) } } @@ -72,10 +132,23 @@ class PollResponsesCache : UserDependencies { responses.update { ResponseTally( it.allResponses - deleteNote, + it.policy, ) } } + /** + * Hands the tally the poll's rules, recomputing it in place. Idempotent — the common case is + * the same poll event arriving again from another relay. + */ + fun updatePolicy(newPolicy: PollTallyPolicy) { + if (responses.value.policy == newPolicy) return + + responses.update { + ResponseTally(it.allResponses, newPolicy) + } + } + fun ResponseTally.filterTo( code: String, forKey: HexKey, @@ -85,11 +158,14 @@ class PollResponsesCache : UserDependencies { val usersThatVotedForThisOption = tally[code] ?: emptyList() - val votes = totalVotes() + val voters = totalVoters() + // Share of voters, not share of selections: a multiple-choice voter who ticks three boxes + // is one person, and a bar that reads "70%" should mean "7 in 10 people". Identical to the + // selections basis on a single-choice poll. val percent = - if (votes > 0) { - usersThatVotedForThisOption.size.toFloat() / votes.toFloat() + if (voters > 0) { + usersThatVotedForThisOption.size.toFloat() / voters.toFloat() } else { 0f } @@ -114,9 +190,11 @@ class PollResponsesCache : UserDependencies { responses.filterTo(code, forKey, priority) } - fun hasPubKeyVoted(user: User): Boolean = responses.value.votes.containsKey(user) + // Counted, not merely present: a response whose option codes the poll doesn't recognise casts + // no vote, so its author should still be offered the voting controls. + fun hasPubKeyVoted(user: User): Boolean = responses.value.countedVoters.contains(user) - fun hasPubKeyVotedFlow(user: User): Flow = responses.map { hasPubKeyVoted(user) }.distinctUntilChanged() + fun hasPubKeyVotedFlow(user: User): Flow = responses.map { it.countedVoters.contains(user) }.distinctUntilChanged() } @Stable @@ -125,19 +203,3 @@ class TallyResults( val percent: Float = 0.0f, val isWinning: Boolean = false, ) - -fun Map.votesByOption(): Map> { - val tally = mutableMapOf>() - - this.forEach { (user, responseEvent) -> - responseEvent.responses().forEach { code -> - val currentTally = tally[code] - if (currentTally == null) { - tally[code] = mutableSetOf(user) - } else { - currentTally.add(user) - } - } - } - return tally -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollTallyPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollTallyPolicy.kt new file mode 100644 index 0000000000..87851187b5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollTallyPolicy.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip88Polls + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType + +/** + * The rules a [PollResponsesCache] tally must apply, taken from the kind-1068 poll itself. + * + * A tally cannot be computed correctly from the responses alone: whether a response's second + * `response` tag counts, whether an option code exists at all, and whether a vote arrived while + * the poll was open are all questions only the poll event can answer. Responses routinely arrive + * before the poll they reference, so the tally starts permissive (`policy == null`) and is + * recomputed once [PollResponsesCache.updatePolicy] delivers this. + */ +@Immutable +data class PollTallyPolicy( + /** Option codes the poll actually declares. Anything else is not a vote. */ + val validCodes: Set, + val type: PollType, + /** The poll event's own `created_at`; nothing can be voted before the question exists. */ + val createdAt: Long, + /** NIP-88 `endsAt`, or null for a poll that never closes. */ + val endsAt: Long?, +) { + /** + * NIP-88: the retained response is "the event with the latest timestamp within poll + * timeframes" — so a response stamped after the deadline is not a late vote that wins, it is + * not a vote at all. + */ + fun isInWindow(createdAt: Long): Boolean = createdAt >= this.createdAt && (endsAt == null || createdAt <= endsAt) + + /** + * The option codes a response actually casts. + * + * Single choice: NIP-88 says "the first response tag is to be considered the actual response", + * so only the first tag is read — a response that lists every option casts no valid vote rather + * than one vote per option. + * + * Multiple choice: "the first response tag pointing to each unique ID counts", so codes are + * de-duplicated, order-independent. + * + * Codes the poll never declared are dropped in both cases. + */ + fun accept(codes: List): Set = + when (type) { + PollType.SINGLE_CHOICE -> codes.firstOrNull()?.takeIf { it in validCodes }?.let { setOf(it) } ?: emptySet() + PollType.MULTI_CHOICE -> codes.filterTo(mutableSetOf()) { it in validCodes } + } + + companion object { + fun from(event: PollEvent) = + PollTallyPolicy( + validCodes = event.options().mapTo(mutableSetOf()) { it.code }, + type = event.pollType(), + createdAt = event.createdAt, + endsAt = event.endsAt(), + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt new file mode 100644 index 0000000000..4a41adef91 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache.ResponseTally +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlin.reflect.KClass + +/** One option's share of the poll, ready to draw. */ +@Immutable +class PollOptionResult( + val code: String, + val label: String, + val voters: Int, + /** Share of *voters*, so multiple-choice bars can sum past 100%. */ + val percent: Float, + val isWinning: Boolean, + /** The first few voters in relevance order, for the avatar stack. */ + val topVoters: List, +) + +/** One person's vote: who they are, what they picked, and when. */ +@Immutable +class PollVoterRow( + val user: User, + val codes: List, + val labels: List, + val createdAt: Long, +) + +@Immutable +class PollResultsUiState( + val options: List = emptyList(), + val voters: List = emptyList(), + val totalVoters: Int = 0, + val totalSelections: Int = 0, + val ignoredVotes: Int = 0, + val lateVotes: Int = 0, + val hiddenVoters: Int = 0, + val myVote: List = emptyList(), + val type: PollType = PollType.SINGLE_CHOICE, + val endsAt: Long? = null, +) + +/** + * Screen state for the poll results page. + * + * Reads the live tally off the poll [Note] — no new subscription, no second cache — and turns it + * into rows the UI can render without doing set arithmetic in composition. The only interactive + * state is [selectedOption], which scopes the voter list without touching the summary above it. + * + * Muting is applied here rather than in the tally: a muted voter still counts toward the totals + * (they did vote, and hiding them from the maths would misreport the poll), they are just not + * listed, and [PollResultsUiState.hiddenVoters] says how many. + */ +@Stable +class PollResultsViewModel( + private val pollNote: Note, + private val forKey: HexKey, + private val isHidden: (HexKey) -> Boolean, + follows: Flow>, + hiddenChanges: Flow, +) : ViewModel() { + companion object { + /** Faces shown per option before collapsing into a "+N" chip, matching UserGallery. */ + const val AVATAR_STACK = 4 + } + + private val _selectedOption = MutableStateFlow(null) + val selectedOption = _selectedOption.asStateFlow() + + val uiState: StateFlow = + combine( + pollNote.pollState().responses, + follows, + _selectedOption, + hiddenChanges, + ) { tally, followSet, selected, _ -> + build(tally, followSet, selected) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = build(pollNote.pollState().responses.value, emptySet(), null), + ) + + fun selectOption(code: String?) { + _selectedOption.value = if (_selectedOption.value == code) null else code + } + + private fun build( + tally: ResponseTally, + follows: Set, + selected: String?, + ): PollResultsUiState { + val event = pollNote.event as? PollEvent + + // Poll order, not tally order, so the rows never reshuffle as votes arrive. Falls back to + // whatever the responses referenced while the kind-1068 event is still in flight. + val options = event?.options().orEmpty() + val codesInOrder = if (options.isNotEmpty()) options.map { it.code } else tally.tally.keys.sorted() + val labels = options.associate { it.code to it.label } + + val byRelevance = + compareByDescending { it.pubkeyHex == forKey } + .thenByDescending { it.pubkeyHex in follows } + .thenBy { it.pubkeyHex } + + val totalVoters = tally.totalVoters() + + val optionResults = + codesInOrder.map { code -> + val voters = tally.tally[code].orEmpty() + PollOptionResult( + code = code, + label = labels[code] ?: code, + voters = voters.size, + percent = if (totalVoters > 0) voters.size.toFloat() / totalVoters else 0f, + isWinning = voters.isNotEmpty() && code == tally.winning(), + topVoters = voters.sortedWith(byRelevance).take(AVATAR_STACK), + ) + } + + // Invert the tally once: voter -> the codes they picked, in poll order. + val picks = mutableMapOf>() + codesInOrder.forEach { code -> + tally.tally[code]?.forEach { user -> + picks.getOrPut(user) { mutableListOf() }.add(code) + } + } + + var hidden = 0 + val rows = + picks + .mapNotNull { (user, codes) -> + if (isHidden(user.pubkeyHex)) { + hidden++ + return@mapNotNull null + } + if (selected != null && selected !in codes) return@mapNotNull null + + PollVoterRow( + user = user, + codes = codes, + labels = codes.map { labels[it] ?: it }, + createdAt = tally.votes[user]?.createdAt ?: 0L, + ) + }.sortedWith { a, b -> byRelevance.compare(a.user, b.user) } + + return PollResultsUiState( + options = optionResults, + voters = rows, + totalVoters = totalVoters, + totalSelections = tally.totalSelections(), + ignoredVotes = tally.ignoredVotes, + lateVotes = tally.lateVotes, + hiddenVoters = hidden, + myVote = + picks.entries + .firstOrNull { it.key.pubkeyHex == forKey } + ?.value + .orEmpty(), + type = event?.pollType() ?: PollType.SINGLE_CHOICE, + endsAt = event?.endsAt(), + ) + } + + class Factory( + private val pollNote: Note, + private val forKey: HexKey, + private val isHidden: (HexKey) -> Boolean, + private val follows: Flow>, + private val hiddenChanges: Flow, + ) : ViewModelProvider.Factory { + // The multiplatform signature — commonMain has no java.lang.Class. + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: KClass, + extras: CreationExtras, + ): T = PollResultsViewModel(pollNote, forKey, isHidden, follows, hiddenChanges) as T + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt index 9db5dce702..101b2db4f4 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt @@ -23,10 +23,12 @@ package com.vitorpamplona.amethyst.commons.model.nip88Polls import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertSame import kotlin.test.assertTrue class PollResponsesCacheTest { @@ -76,7 +78,7 @@ class PollResponsesCacheTest { val tally = cache.responses.value // Exactly one vote counted for this user. - assertEquals(1, tally.totalVotes()) + assertEquals(1, tally.totalVoters()) // The winning option is the newer one. assertEquals("no", tally.winning()) // Old option carries no voters. @@ -140,6 +142,175 @@ class PollResponsesCacheTest { cache.addResponse(note) cache.addResponse(note) // relay echo of the same note must not double-count - assertEquals(1, cache.responses.value.totalVotes()) + assertEquals(1, cache.responses.value.totalVoters()) + } + + // --------------------------------------------------------------------------------------- + // Poll-aware tally: everything below needs the kind-1068 rules, which ResponseTally only has + // once updatePolicy has run. + // --------------------------------------------------------------------------------------- + + /** Builds a response casting several codes at once, to exercise the per-type accept rules. */ + private fun multiResponseNote( + id: HexKey, + pubKey: HexKey, + options: List, + createdAt: Long, + ): Note { + val event = + PollResponseEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = arrayOf(arrayOf("e", pollId)) + options.map { arrayOf("response", it) }, + content = "", + sig = "0".repeat(128), + ) + val note = Note(id) + note.loadEvent(event, user(pubKey), emptyList()) + return note + } + + private fun policy( + type: PollType, + codes: Set = setOf("yes", "no"), + createdAt: Long = 0, + endsAt: Long? = null, + ) = PollTallyPolicy(validCodes = codes, type = type, createdAt = createdAt, endsAt = endsAt) + + @Test + fun singleChoiceCountsOnlyTheFirstResponseTag() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + + // A client that emits one response tag per option must not land in every bucket. + cache.addResponse(multiResponseNote("1".repeat(64), "b".repeat(64), listOf("yes", "no"), createdAt = 10)) + + val tally = cache.responses.value + assertEquals(1, tally.totalVoters()) + assertEquals(1, tally.totalSelections()) + assertEquals(1, tally.tally["yes"]?.size) + assertTrue(tally.tally["no"].isNullOrEmpty()) + } + + @Test + fun singleChoiceRejectsWhenTheFirstTagIsNotAnOption() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + + // NIP-88 says the first tag *is* the response — so an invalid first tag is an invalid vote, + // not an invitation to look for a valid one further down. + cache.addResponse(multiResponseNote("1".repeat(64), "b".repeat(64), listOf("bogus", "yes"), createdAt = 10)) + + val tally = cache.responses.value + assertEquals(0, tally.totalVoters()) + assertEquals(1, tally.ignoredVotes) + } + + @Test + fun multiChoiceCountsEveryValidCodeOncePerVoter() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.MULTI_CHOICE)) + + cache.addResponse(multiResponseNote("1".repeat(64), "b".repeat(64), listOf("yes", "no", "yes"), createdAt = 10)) + + val tally = cache.responses.value + // One person, two selections: the duplicate "yes" collapses. + assertEquals(1, tally.totalVoters()) + assertEquals(2, tally.totalSelections()) + } + + @Test + fun multiChoicePercentagesAreShareOfVotersNotSelections() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.MULTI_CHOICE)) + + // Two voters; one picks both options, the other picks only "yes". + cache.addResponse(multiResponseNote("1".repeat(64), "b".repeat(64), listOf("yes", "no"), createdAt = 10)) + cache.addResponse(multiResponseNote("2".repeat(64), "c".repeat(64), listOf("yes"), createdAt = 10)) + + val tally = cache.responses.value + assertEquals(2, tally.totalVoters()) + assertEquals(3, tally.totalSelections()) + + val forKey = "0".repeat(64) + // 2 of 2 people want "yes", 1 of 2 want "no" — bars sum past 100% on purpose. + assertEquals(1.0f, cache.currentTally("yes", forKey, emptySet()).percent) + assertEquals(0.5f, cache.currentTally("no", forKey, emptySet()).percent) + } + + @Test + fun votesOutsideThePollWindowAreExcluded() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE, createdAt = 100, endsAt = 200)) + + val voter = "b".repeat(64) + cache.addResponse(responseNote("1".repeat(64), voter, option = "yes", createdAt = 150)) + // Same voter again after the deadline: a later timestamp must not overwrite a valid vote. + cache.addResponse(responseNote("2".repeat(64), voter, option = "no", createdAt = 500)) + // And a vote from before the poll existed is not a vote either. + cache.addResponse(responseNote("3".repeat(64), "c".repeat(64), option = "no", createdAt = 50)) + + val tally = cache.responses.value + assertEquals(1, tally.totalVoters()) + assertEquals("yes", tally.winning()) + assertEquals(2, tally.lateVotes) + } + + @Test + fun unknownOptionCodesDoNotDragDownPercentages() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + + cache.addResponse(responseNote("1".repeat(64), "b".repeat(64), option = "yes", createdAt = 10)) + cache.addResponse(responseNote("2".repeat(64), "c".repeat(64), option = "spam", createdAt = 10)) + + val tally = cache.responses.value + assertEquals(1, tally.totalVoters()) + assertEquals(1, tally.ignoredVotes) + assertTrue(tally.tally["spam"].isNullOrEmpty()) + // The one real vote is 100% of the poll, not 50%. + assertEquals(1.0f, cache.currentTally("yes", "0".repeat(64), emptySet()).percent) + } + + @Test + fun policyArrivingAfterResponsesRecomputesTheTally() { + val cache = PollResponsesCache() + + // Responses regularly beat their poll to the client. Until the poll lands the tally is + // permissive, so the bogus code counts. + cache.addResponse(responseNote("1".repeat(64), "b".repeat(64), option = "yes", createdAt = 10)) + cache.addResponse(responseNote("2".repeat(64), "c".repeat(64), option = "spam", createdAt = 10)) + assertEquals(2, cache.responses.value.totalVoters()) + + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + + assertEquals(1, cache.responses.value.totalVoters()) + } + + @Test + fun updatePolicyIsIdempotent() { + val cache = PollResponsesCache() + cache.addResponse(responseNote("1".repeat(64), "b".repeat(64), option = "yes", createdAt = 10)) + + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + val first = cache.responses.value + // Same poll re-delivered by another relay must not churn the tally identity. + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + + assertSame(first, cache.responses.value) + } + + @Test + fun voterWhoseVoteWasIgnoredCanStillVote() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + + val voter = "b".repeat(64) + cache.addResponse(responseNote("1".repeat(64), voter, option = "spam", createdAt = 10)) + + // They responded, but cast nothing countable — the card should still offer them the + // controls rather than showing results for a vote that does not exist. + assertFalse(cache.hasPubKeyVoted(user(voter))) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index af026c5746..4bee88c1a3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.UserContext import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache +import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollTallyPolicy import com.vitorpamplona.amethyst.commons.service.nwc.NwcPaymentTracker import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -487,6 +488,9 @@ class DesktopLocalCache : ICacheProvider { relay: NormalizedRelayUrl?, ): Boolean { val note = getOrCreateNote(event.id) + // Not gated on the early return below: the tally may already hold responses that arrived + // before this poll did, and updatePolicy is idempotent for re-delivery from another relay. + note.pollState().updatePolicy(PollTallyPolicy.from(event)) if (note.event != null) return false val author = getOrCreateUser(event.pubKey) note.loadEvent(event, author, emptyList()) @@ -510,6 +514,7 @@ class DesktopLocalCache : ICacheProvider { ): Boolean { val pollId = event.poll()?.eventId ?: return false val pollNote = getOrCreateNote(pollId) + (pollNote.event as? PollEvent)?.let { pollNote.pollState().updatePolicy(PollTallyPolicy.from(it)) } val responseNote = getOrCreateNote(event.id) if (responseNote.event != null) return false val author = getOrCreateUser(event.pubKey) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt index 88c32fb660..a5b55291df 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCachePollTest.kt @@ -85,7 +85,7 @@ class DesktopLocalCachePollTest { val pollNote = cache.getNoteIfExists(poll.id) assertTrue(pollNote != null, "poll note must exist") val tally = pollNote.pollState().responses.value - assertEquals(1, tally.totalVotes()) + assertEquals(1, tally.totalVoters()) assertEquals("0", tally.winning()) } @@ -108,6 +108,6 @@ class DesktopLocalCachePollTest { .getNoteIfExists(poll.id)!! .pollState() .responses.value - assertEquals(1, tally.totalVotes()) + assertEquals(1, tally.totalVoters()) } } From b3f5a543e4953f7e15234ea8e81e63a40153e0c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 20:24:27 +0000 Subject: [PATCH 087/227] fix(polls): gate the results link, page past the cap, report completeness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups on the results screen. The results link no longer bypasses the vote-first gate. It was rendered unconditionally under the card, so a reader who had not voted got the voting controls plus a one-tap jump to the full split — and since opening the screen counts as opting in, that choice stuck. RenderPollCard now takes a footerContent slot rendered from RenderResults, which is reached only in the branches where the tally is already visible, so the link inherits the existing gate instead of working around it. Backfill pages past the subscription cap. Kind 1018 rides in the shared engagement filter with a small limit spread across every batched note, so a busy poll arrived truncated with nothing saying so. Opening the results now drains every response per relay via fetchAllPagesFromPool, from the poll's declared relays plus the usual engagement relays. Completeness is reported honestly. Relay COUNT results are never summed — relays mirror each other, so adding them multiplies the poll. When relays supply NIP-45 HyperLogLog registers the registers are merged and the union re-estimated; when they don't, the largest single relay's count is used as the tightest defensible lower bound. Relays without COUNT support simply never answer and only make the figure smaller. The result is then floored by what the local cache already holds, since an HLL estimate can land under the truth. The footer appears only when responses are provably missing, and says "about" whenever the figure is an estimate or some relays stayed silent. Adds PollResultsViewModelTest: poll-order options, relevance ordering, option scoping that leaves the summary alone, muted voters counted but unlisted, multi-choice rows, avatar-stack capping, live vote arrival, and the three completeness paths including a loader that throws. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv --- .../amethyst/ui/note/types/Poll.kt | 25 +- .../polls/results/PollResultsScreen.kt | 71 +++- .../polls/results/RelayPollResponseLoader.kt | 123 +++++++ amethyst/src/main/res/values/strings.xml | 3 + .../nip88Polls/PollResponseLoader.kt | 61 ++++ .../nip88Polls/PollResultsViewModel.kt | 67 +++- .../nip88Polls/PollResultsViewModelTest.kt | 325 ++++++++++++++++++ 7 files changed, 649 insertions(+), 26 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResponseLoader.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModelTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 4697b203f9..b189c487a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -176,6 +176,7 @@ fun InnerRenderPoll( event = event, pollState = note.pollState(), accountViewModel = accountViewModel, + footerContent = { if (!makeItShort) PollResultsLink(note, nav) }, galleryUser = { user -> ClickableUserPicture( user, @@ -201,10 +202,6 @@ fun InnerRenderPoll( ) } - if (!makeItShort) { - PollResultsLink(note, nav) - } - if (event.hasHashtags()) { DisplayUncitedHashtags(event, event.content, callbackUri, accountViewModel, nav) } @@ -214,6 +211,10 @@ fun InnerRenderPoll( /** * The way out of the card. The avatar stack tops out at four faces and the "+N" chip counts people * the card has no room to show — this is the door to the rest of them. + * + * Rendered only from [RenderResults], i.e. only once the card is already showing the tally. Putting + * it beside the voting controls would hand every reader a one-tap bypass of the vote-first gate, + * and — since opening the screen counts as opting in — permanently so. */ @Composable private fun PollResultsLink( @@ -261,6 +262,7 @@ fun RenderPollCard( pollState: PollResponsesCache, accountViewModel: AccountViewModel, galleryUser: @Composable RowScope.(user: User) -> Unit, + footerContent: @Composable () -> Unit = {}, labelContent: @Composable ColumnScope.(code: String, label: String) -> Unit, ) { val card = @@ -308,6 +310,7 @@ fun RenderPollCard( onViewResults = { accountViewModel.markPollResultsViewed(event.id, event.endsAt()) }, hasViewedResults = { accountViewModel.hasViewedPollResults(event.id) }, resultContent = galleryUser, + footerContent = footerContent, labelContent = labelContent, ) } @@ -320,28 +323,29 @@ fun RenderPollCard( onViewResults: () -> Unit = {}, hasViewedResults: () -> Boolean = { false }, resultContent: @Composable RowScope.(user: User) -> Unit, + footerContent: @Composable () -> Unit = {}, labelContent: @Composable ColumnScope.(code: String, label: String) -> Unit, ) { Column( verticalArrangement = SpacedBy5dp, ) { if (card.isMyPoll) { - RenderResults(card, resultContent, labelContent) + RenderResults(card, resultContent, footerContent, labelContent) } else { val haveIVoted = card.haveIVoted() if (haveIVoted) { - RenderResults(card, resultContent, labelContent) + RenderResults(card, resultContent, footerContent, labelContent) } else { // waits for vote val haveIVoted by card.haveIVotedFlow.collectAsStateWithLifecycle(haveIVoted) if (haveIVoted) { - RenderResults(card, resultContent, labelContent) + RenderResults(card, resultContent, footerContent, labelContent) } else if (card.hasEnded() || hasViewedResults()) { - RenderResults(card, resultContent, labelContent) + RenderResults(card, resultContent, footerContent, labelContent) } else { var viewingResults by remember { mutableStateOf(false) } if (viewingResults) { - RenderResults(card, resultContent, labelContent) + RenderResults(card, resultContent, footerContent, labelContent) } else { when (card.type) { PollType.SINGLE_CHOICE -> RenderSingleChoiceOptions(card, labelContent, onRespond) @@ -467,6 +471,7 @@ private fun ColumnScope.RenderMultiChoiceOptions( private fun RenderResults( card: PollCard, resultContent: @Composable RowScope.(user: User) -> Unit, + footerContent: @Composable () -> Unit, labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit), ) { val showGallery = @@ -481,6 +486,8 @@ private fun RenderResults( labelContent(pollItem.code, pollItem.label) } } + + footerContent() } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt index 02512e05ce..a6baf003df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -95,6 +96,7 @@ import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.amethyst.ui.theme.warningColor import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType import com.vitorpamplona.quartz.utils.TimeUtils @@ -154,6 +156,7 @@ private fun PollResults( isHidden = { account.isHidden(it) }, follows = account.allFollows.flow.map { it.authors }, hiddenChanges = account.hiddenUsers.flow, + loader = RelayPollResponseLoader(account.client, account.cache, note), ), ) @@ -633,18 +636,62 @@ private fun ResultsFooter(state: PollResultsUiState) { } } - if (notes.isNotEmpty()) { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - notes.forEach { (res, count) -> - Text( - text = pluralStringResource(res, count, count), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.placeholderText, - ) - } + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Completeness(state) + + notes.forEach { (res, count) -> + Text( + text = pluralStringResource(res, count, count), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) } } } + +/** + * Says how complete the tally is, and only when there is something to say. Silence means the relays + * either agreed with what we hold or could not answer — claiming completeness we cannot prove would + * be the same mistake as presenting a truncated tally as final. + */ +@Composable +private fun Completeness(state: PollResultsUiState) { + if (state.isBackfilling) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.placeholderText, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringRes(R.string.poll_results_loading_votes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + return + } + + val reported = state.reportedResponses ?: return + if (!state.isIncomplete) return + + Text( + text = + stringRes( + if (state.reportIsApproximate) { + R.string.poll_results_partial_approx + } else { + R.string.poll_results_partial + }, + state.loadedResponses, + reported, + state.relaysAnswered, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.warningColor, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt new file mode 100644 index 0000000000..7ac32fcaac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollLoadReport +import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollResponseLoader +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip45Count.HyperLogLog +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent + +/** + * Drains a poll's responses past the live subscription's cap, then asks the relays how many there + * should have been. + * + * Two independent problems, one round trip each: + * + * 1. **Truncation.** Kind 1018 rides in the shared engagement filter with a small `limit` and no + * paging, and that limit is spread across every note batched into it — so a busy poll arrives + * partially and nothing says so. [fetchAllPagesFromPool] walks `until` backwards per relay + * until each is exhausted. + * + * 2. **Knowing what "complete" is.** See [mergeCounts] — a COUNT fan-out cannot simply be summed. + */ +class RelayPollResponseLoader( + private val client: INostrClient, + private val cache: LocalCache, + private val pollNote: Note, +) : PollResponseLoader { + companion object { + /** Per-relay idle window for both the drain and the COUNT. */ + const val TIMEOUT_MS = 20_000L + + /** + * Combines per-relay COUNT results into one figure that is never inflated. + * + * Summing is wrong: relays mirror each other, so the same vote is counted once per relay + * that holds it. NIP-45's optional `hll` field exists precisely for this — HyperLogLog + * registers merge by taking the per-register maximum, and the union's cardinality is + * re-estimated from the merged registers, so shared events collapse instead of stacking. + * + * When no relay ships HLL (most don't), the best available answer is the **largest single + * relay's count**: every relay's count is a lower bound on the union, so the max is the + * tightest lower bound we can justify. Relays that don't implement COUNT never answer and + * are simply absent — they make the figure smaller, never larger, which is the safe + * direction for a number the UI presents as "at least this many". + */ + fun mergeCounts(counts: List>): Pair? { + if (counts.isEmpty()) return null + + val hlls = counts.mapNotNull { it.second } + if (hlls.isNotEmpty()) { + val merged = HyperLogLog.merge(hlls) + return HyperLogLog.estimate(merged).toInt() to true + } + + // No registers to union — fall back to the tightest lower bound, never the sum. + return (counts.maxOf { it.first }) to false + } + } + + override suspend fun load(poll: PollEvent): PollLoadReport { + val relays = responseRelays(poll) + if (relays.isEmpty()) return PollLoadReport(null, approximate = false, relaysAsked = 0, relaysAnswered = 0) + + val filter = + Filter( + kinds = listOf(PollResponseEvent.KIND), + tags = mapOf("e" to listOf(poll.id)), + ) + + client.fetchAllPagesFromPool( + filters = relays.associateWith { listOf(filter) }, + idleTimeoutMs = TIMEOUT_MS, + ) { event, relay -> + // Provenance is best-effort: a client that doesn't expose relay handles still consumes + // the vote, it just doesn't learn where it came from. + cache.justConsume(event, runCatching { client.getOrCreateRelay(relay) }.getOrNull(), false) + } + + val results = client.count(relays.associateWith { listOf(filter) }, idleTimeoutMs = TIMEOUT_MS) + val merged = mergeCounts(results.values.map { it.count to it.hll }) + + return PollLoadReport( + reported = merged?.first, + // An estimate is approximate; so is a figure from only some of the relays we asked. + approximate = (merged?.second ?: false) || results.size < relays.size, + relaysAsked = relays.size, + relaysAnswered = results.size, + ) + } + + /** + * Where a poll's votes live: the relays the poll itself nominates (NIP-88 tells respondents to + * publish there, and [com.vitorpamplona.amethyst.model.EventBroadcaster] obeys it), plus the + * relays we would look at for any other engagement. + */ + private fun responseRelays(poll: PollEvent): Set = (poll.relays() + pollNote.relayUrlsForReactions()).toSet() +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 54644f7698..711459b56e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -4265,6 +4265,9 @@ Your vote · %1$d selections No votes yet + Loading every vote… + %1$d of %2$d responses loaded from %3$d relays. + %1$d of about %2$d responses loaded from %3$d relays. voter voters diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResponseLoader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResponseLoader.kt new file mode 100644 index 0000000000..65a2a3cab5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResponseLoader.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent + +/** + * What the relays said about a poll's responses, after the backfill drained them into the cache. + * + * [reported] is deliberately **not** a sum across relays. The same vote is usually stored on + * several relays, so adding their counts would multiply the poll. NIP-45's optional HyperLogLog + * registers are the only way to combine counts without double-counting; when relays supply them + * the registers are merged and re-estimated, and when they don't the best honest answer is the + * largest single relay's count — a lower bound, never an inflated one. Relays that don't implement + * COUNT at all simply don't answer, and are excluded from both tallies here. + */ +@Immutable +class PollLoadReport( + /** + * Distinct kind-1018 events the relays believe exist, already floored by what is in the local + * cache. Null when no relay could answer at all. + */ + val reported: Int?, + /** + * True when [reported] cannot be trusted as exact — either it is a HyperLogLog estimate, or + * some relays never answered so the figure is only a lower bound. + */ + val approximate: Boolean, + val relaysAsked: Int, + val relaysAnswered: Int, +) + +/** + * Drains every response for a poll into the cache and reports how many the relays believe exist. + * + * The live subscription is capped and never pages, so a poll with more responses than the cap is + * silently truncated — exactly the polls a results screen is opened for. Implementations page past + * the cap once, on demand, rather than widening the always-on subscription. + */ +fun interface PollResponseLoader { + suspend fun load(poll: PollEvent): PollLoadReport +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt index 4a41adef91..723c37e67c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt @@ -32,13 +32,19 @@ import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache.Re import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlin.reflect.KClass /** One option's share of the poll, ready to draw. */ @@ -75,7 +81,17 @@ class PollResultsUiState( val myVote: List = emptyList(), val type: PollType = PollType.SINGLE_CHOICE, val endsAt: Long? = null, -) + /** Raw kind-1018 events the tally has, however they were counted. */ + val loadedResponses: Int = 0, + /** What the relays say exists, floored by [loadedResponses]. Null when nobody could say. */ + val reportedResponses: Int? = null, + val reportIsApproximate: Boolean = false, + val relaysAnswered: Int = 0, + val isBackfilling: Boolean = false, +) { + /** True only when we can actually show that responses are missing. */ + val isIncomplete get() = reportedResponses != null && reportedResponses > loadedResponses +} /** * Screen state for the poll results page. @@ -95,6 +111,7 @@ class PollResultsViewModel( private val isHidden: (HexKey) -> Boolean, follows: Flow>, hiddenChanges: Flow, + private val loader: PollResponseLoader? = null, ) : ViewModel() { companion object { /** Faces shown per option before collapsing into a "+N" chip, matching UserGallery. */ @@ -104,18 +121,49 @@ class PollResultsViewModel( private val _selectedOption = MutableStateFlow(null) val selectedOption = _selectedOption.asStateFlow() + private val backfill = MutableStateFlow(BackfillState()) + + private class BackfillState( + val running: Boolean = false, + val report: PollLoadReport? = null, + ) + + init { + // Page past the subscription cap once, on open. Without this a poll with more responses + // than the live filter's limit shows a confidently wrong tally. + if (loader != null) { + viewModelScope.launch(Dispatchers.IO) { + val poll = awaitPollEvent() + backfill.value = BackfillState(running = true) + val report = runCatching { loader.load(poll) }.getOrNull() + backfill.value = BackfillState(running = false, report = report) + } + } + } + + /** Responses regularly beat their poll to the client; there is nothing to query until it lands. */ + private suspend fun awaitPollEvent(): PollEvent = + pollNote.event as? PollEvent + ?: pollNote + .flow() + .metadata.stateFlow + .map { it.note.event as? PollEvent } + .filterNotNull() + .first() + val uiState: StateFlow = combine( pollNote.pollState().responses, follows, _selectedOption, hiddenChanges, - ) { tally, followSet, selected, _ -> - build(tally, followSet, selected) + backfill, + ) { tally, followSet, selected, _, load -> + build(tally, followSet, selected, load) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = build(pollNote.pollState().responses.value, emptySet(), null), + initialValue = build(pollNote.pollState().responses.value, emptySet(), null, BackfillState()), ) fun selectOption(code: String?) { @@ -126,6 +174,7 @@ class PollResultsViewModel( tally: ResponseTally, follows: Set, selected: String?, + load: BackfillState, ): PollResultsUiState { val event = pollNote.event as? PollEvent @@ -196,6 +245,13 @@ class PollResultsViewModel( .orEmpty(), type = event?.pollType() ?: PollType.SINGLE_CHOICE, endsAt = event?.endsAt(), + loadedResponses = tally.allResponses.size, + // Floor the relays' figure with what we hold: an HLL estimate can land under the true + // value, and a relay that never answered can only make the number too small. + reportedResponses = load.report?.reported?.coerceAtLeast(tally.allResponses.size), + reportIsApproximate = load.report?.approximate ?: false, + relaysAnswered = load.report?.relaysAnswered ?: 0, + isBackfilling = load.running, ) } @@ -205,12 +261,13 @@ class PollResultsViewModel( private val isHidden: (HexKey) -> Boolean, private val follows: Flow>, private val hiddenChanges: Flow, + private val loader: PollResponseLoader? = null, ) : ViewModelProvider.Factory { // The multiplatform signature — commonMain has no java.lang.Class. @Suppress("UNCHECKED_CAST") override fun create( modelClass: KClass, extras: CreationExtras, - ): T = PollResultsViewModel(pollNote, forKey, isHidden, follows, hiddenChanges) as T + ): T = PollResultsViewModel(pollNote, forKey, isHidden, follows, hiddenChanges, loader) as T } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModelTest.kt new file mode 100644 index 0000000000..bf49356049 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModelTest.kt @@ -0,0 +1,325 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollTallyPolicy +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class PollResultsViewModelTest { + private val pollId = "a".repeat(64) + private val me = "f".repeat(64) + private val followed = "e".repeat(64) + private val stranger = "d".repeat(64) + private val muted = "c".repeat(64) + + private val userCache = mutableMapOf() + + // viewModelScope is Dispatchers.Main, and uiState is a WhileSubscribed stateIn — nothing runs + // until something subscribes. + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * Subscribes long enough for the real state to be produced. + * + * Reading `uiState.first()` would return `stateIn`'s seed — computed before any collector + * exists, so with no follow set and no backfill — which is exactly the state no user ever sees. + */ + private fun TestScope.stateOf(vm: PollResultsViewModel): PollResultsUiState { + val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.uiState.collect { } } + advanceUntilIdle() + return vm.uiState.value.also { job.cancel() } + } + + private fun user(pubKey: HexKey): User = userCache.getOrPut(pubKey) { User(pubKey) { addr -> Note(addr.toValue()) } } + + /** A single-choice poll on "red" / "green" / "blue", open forever. */ + private fun pollNote(type: PollType = PollType.SINGLE_CHOICE): Note { + val event = + PollEvent( + id = pollId, + pubKey = "b".repeat(64), + createdAt = 100, + tags = + arrayOf( + arrayOf("polltype", if (type == PollType.MULTI_CHOICE) "multiplechoice" else "singlechoice"), + arrayOf("option", "red", "Red"), + arrayOf("option", "green", "Green"), + arrayOf("option", "blue", "Blue"), + ), + content = "Pick a colour", + sig = "0".repeat(128), + ) + val note = Note(pollId) + note.loadEvent(event, user(event.pubKey), emptyList()) + note.pollState().updatePolicy(PollTallyPolicy.from(event)) + return note + } + + private fun vote( + note: Note, + id: HexKey, + pubKey: HexKey, + options: List, + createdAt: Long = 150, + ) { + val event = + PollResponseEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = arrayOf(arrayOf("e", pollId)) + options.map { arrayOf("response", it) }, + content = "", + sig = "0".repeat(128), + ) + val responseNote = Note(id) + responseNote.loadEvent(event, user(pubKey), emptyList()) + note.pollState().addResponse(responseNote) + } + + private fun viewModel( + note: Note, + hidden: Set = emptySet(), + follows: Set = setOf(followed), + loader: PollResponseLoader? = null, + ) = PollResultsViewModel( + pollNote = note, + forKey = me, + isHidden = { it in hidden }, + follows = MutableStateFlow(follows), + hiddenChanges = MutableStateFlow(Unit), + loader = loader, + ) + + @Test + fun optionsKeepPollOrderAndCarryCounts() = + runTest { + val note = pollNote() + vote(note, "1".repeat(64), me, listOf("blue")) + vote(note, "2".repeat(64), followed, listOf("blue")) + vote(note, "3".repeat(64), stranger, listOf("red")) + + val state = stateOf(viewModel(note)) + + // Declaration order, not "winner first" and not tally-map order. + assertEquals(listOf("red", "green", "blue"), state.options.map { it.code }) + assertEquals(listOf(1, 0, 2), state.options.map { it.voters }) + assertEquals("blue", state.options.first { it.isWinning }.code) + assertEquals(3, state.totalVoters) + } + + @Test + fun votersAreOrderedMeThenFollowsThenRest() = + runTest { + val note = pollNote() + vote(note, "1".repeat(64), stranger, listOf("red")) + vote(note, "2".repeat(64), followed, listOf("red")) + vote(note, "3".repeat(64), me, listOf("red")) + + val state = stateOf(viewModel(note)) + + assertEquals(listOf(me, followed, stranger), state.voters.map { it.user.pubkeyHex }) + } + + @Test + fun selectingAnOptionScopesTheListButNotTheSummary() = + runTest { + val note = pollNote() + vote(note, "1".repeat(64), me, listOf("red")) + vote(note, "2".repeat(64), followed, listOf("blue")) + + val vm = viewModel(note) + vm.selectOption("blue") + val state = stateOf(vm) + + assertEquals(listOf(followed), state.voters.map { it.user.pubkeyHex }) + // The bars above the list must not move while the list is filtered. + assertEquals(2, state.totalVoters) + assertEquals(listOf(1, 0, 1), state.options.map { it.voters }) + } + + @Test + fun selectingTheSameOptionTwiceClearsTheFilter() = + runTest { + val note = pollNote() + vote(note, "1".repeat(64), me, listOf("red")) + vote(note, "2".repeat(64), followed, listOf("blue")) + + val vm = viewModel(note) + vm.selectOption("blue") + vm.selectOption("blue") + + assertNull(vm.selectedOption.value) + assertEquals(2, stateOf(vm).voters.size) + } + + @Test + fun mutedVotersAreHiddenButStillCounted() = + runTest { + val note = pollNote() + vote(note, "1".repeat(64), me, listOf("red")) + vote(note, "2".repeat(64), muted, listOf("red")) + + val state = stateOf(viewModel(note, hidden = setOf(muted))) + + assertEquals(listOf(me), state.voters.map { it.user.pubkeyHex }) + assertEquals(1, state.hiddenVoters) + // Dropping them from the maths would misreport the poll, not protect anyone. + assertEquals(2, state.totalVoters) + assertEquals(1.0f, state.options.first { it.code == "red" }.percent) + } + + @Test + fun multiChoiceRowsListEveryOptionThatPersonPicked() = + runTest { + val note = pollNote(PollType.MULTI_CHOICE) + vote(note, "1".repeat(64), me, listOf("blue", "red")) + + val state = stateOf(viewModel(note)) + val row = state.voters.single() + + // Poll order, so two voters who picked the same pair read identically. + assertEquals(listOf("red", "blue"), row.codes) + assertEquals(listOf("Red", "Blue"), row.labels) + assertEquals(1, state.totalVoters) + assertEquals(2, state.totalSelections) + } + + @Test + fun avatarStackIsCappedAndOrderedByRelevance() = + runTest { + val note = pollNote() + // Six voters on one option: the stack shows AVATAR_STACK of them. + vote(note, "1".repeat(64), me, listOf("red")) + vote(note, "2".repeat(64), followed, listOf("red")) + listOf("3", "4", "5", "6").forEach { seed -> + vote(note, seed.repeat(64), "${seed}a".repeat(32), listOf("red")) + } + + val red = stateOf(viewModel(note)).options.first { it.code == "red" } + + assertEquals(PollResultsViewModel.AVATAR_STACK, red.topVoters.size) + assertEquals(me, red.topVoters[0].pubkeyHex) + assertEquals(followed, red.topVoters[1].pubkeyHex) + } + + @Test + fun myVoteIsReportedForHighlightingTheOption() = + runTest { + val note = pollNote() + vote(note, "1".repeat(64), me, listOf("green")) + + assertEquals(listOf("green"), stateOf(viewModel(note)).myVote) + } + + @Test + fun aNewVoteFlowsThroughToTheState() = + runTest { + val note = pollNote() + val vm = viewModel(note) + assertEquals(0, stateOf(vm).totalVoters) + + vote(note, "1".repeat(64), stranger, listOf("red")) + + assertEquals(1, stateOf(vm).totalVoters) + } + + @Test + fun relayReportBelowWhatWeHoldNeverClaimsIncompleteness() = + runTest { + val note = pollNote() + vote(note, "1".repeat(64), me, listOf("red")) + vote(note, "2".repeat(64), followed, listOf("red")) + + // An HLL estimate can land under the truth; the floor keeps the footer honest. + val vm = + viewModel( + note, + loader = { PollLoadReport(reported = 1, approximate = true, relaysAsked = 3, relaysAnswered = 1) }, + ) + + val state = stateOf(vm) + assertEquals(2, state.reportedResponses) + assertTrue(!state.isIncomplete) + } + + @Test + fun relayReportAboveWhatWeHoldSurfacesTheShortfall() = + runTest { + val note = pollNote() + vote(note, "1".repeat(64), me, listOf("red")) + + val vm = + viewModel( + note, + loader = { PollLoadReport(reported = 412, approximate = true, relaysAsked = 6, relaysAnswered = 4) }, + ) + + val state = stateOf(vm) + assertEquals(412, state.reportedResponses) + assertEquals(1, state.loadedResponses) + assertTrue(state.isIncomplete) + assertTrue(state.reportIsApproximate) + assertEquals(4, state.relaysAnswered) + } + + @Test + fun aLoaderThatThrowsLeavesTheTallyUsable() = + runTest { + val note = pollNote() + vote(note, "1".repeat(64), me, listOf("red")) + + val vm = viewModel(note, loader = { error("relay exploded") }) + + val state = stateOf(vm) + assertNull(state.reportedResponses) + assertEquals(1, state.totalVoters) + } +} From 5de8ccd626386f5c9deba9d4f700e79ed0baef28 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 21:15:48 +0000 Subject: [PATCH 088/227] fix(nip45): keep plain counts when merging with HLL; keep polls live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mergeCountResults moves into quartz's nip45Count package, next to the HyperLogLog code that defines what a COUNT fan-out means, and countMerged now delegates to it so every caller shares one policy. The policy had a hole in the mixed case. Whenever any relay shipped HLL registers, relays that answered with a plain count were dropped entirely — so a relay outside the register set holding 5000 events was discarded in favour of a ~50 estimate from the relays that did ship registers. Both figures are lower bounds on the union, so the answer is the larger of the two. Summing is still never an option: relays mirror each other and the same event would be counted once per relay holding it. Tests cover empty, single, plain-only (largest, not the sum), overlapping and disjoint HLL unions, both mixed directions, register equality, and the at-least-every-answer property. Two of them assert the merge property — that merging registers equals counting the union — rather than a band around the true cardinality, because HLL at m=256 has no bias correction and reads high near n≈m; asserting accuracy there would be testing the estimator, not this function. Also fixes two things the results screen got wrong. It never held a subscription, so the only votes it could show were the ones the one-shot backfill caught: a vote cast while you were reading it never arrived, because the feed card that used to carry the subscription is disposed behind the screen. It now keeps the standard note subscription open, which also loads the kind-1068 event when arriving by deep link. And it rebuilt its state on the UI thread. stateIn(viewModelScope) put the combine's transform on Dispatchers.Main, so every new vote re-sorted every voter there — during a backfill that is one full re-sort per consumed event. The transform now runs on Default via flowOn, with the dispatchers injected so tests keep driving them on the test scheduler. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv --- .../polls/results/PollResultsScreen.kt | 7 + .../polls/results/RelayPollResponseLoader.kt | 39 +---- .../nip88Polls/PollResultsViewModel.kt | 30 +++- .../nip88Polls/PollResultsViewModelTest.kt | 6 +- .../client/accessories/NostrClientCountExt.kt | 29 +--- .../quartz/nip45Count/CountMerge.kt | 67 ++++++++ .../quartz/nip45Count/CountMergeTest.kt | 156 ++++++++++++++++++ 7 files changed, 267 insertions(+), 67 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/CountMerge.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountMergeTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt index a6baf003df..f491491f77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt @@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollOptionResult import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollResultsUiState import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollResultsViewModel import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollVoterRow +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -140,6 +141,12 @@ private fun PollResults( ) { val account = accountViewModel.account + // Keeps a REQ open for this poll while the screen is on top. Without it the only votes ever + // shown are the ones the one-shot backfill happened to catch: a vote cast while you are reading + // would never arrive, because the feed card that used to hold this subscription is disposed + // behind us. It also loads the kind-1068 event itself when we arrived by deep link. + EventFinderFilterAssemblerSubscription(note, accountViewModel) + // Opening this screen is the opt-in, exactly like the card's "View results" link — so the feed // card stops hiding the tally behind a tap once you have been here. LaunchedEffect(note.idHex) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt index 7ac32fcaac..d24599e1d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip45Count.HyperLogLog +import com.vitorpamplona.quartz.nip45Count.mergeCountResults import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent @@ -44,7 +44,8 @@ import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent * partially and nothing says so. [fetchAllPagesFromPool] walks `until` backwards per relay * until each is exhausted. * - * 2. **Knowing what "complete" is.** See [mergeCounts] — a COUNT fan-out cannot simply be summed. + * 2. **Knowing what "complete" is.** See [mergeCountResults] — a COUNT fan-out cannot be summed, + * because relays mirror each other and the same vote would be counted once per relay. */ class RelayPollResponseLoader( private val client: INostrClient, @@ -54,33 +55,6 @@ class RelayPollResponseLoader( companion object { /** Per-relay idle window for both the drain and the COUNT. */ const val TIMEOUT_MS = 20_000L - - /** - * Combines per-relay COUNT results into one figure that is never inflated. - * - * Summing is wrong: relays mirror each other, so the same vote is counted once per relay - * that holds it. NIP-45's optional `hll` field exists precisely for this — HyperLogLog - * registers merge by taking the per-register maximum, and the union's cardinality is - * re-estimated from the merged registers, so shared events collapse instead of stacking. - * - * When no relay ships HLL (most don't), the best available answer is the **largest single - * relay's count**: every relay's count is a lower bound on the union, so the max is the - * tightest lower bound we can justify. Relays that don't implement COUNT never answer and - * are simply absent — they make the figure smaller, never larger, which is the safe - * direction for a number the UI presents as "at least this many". - */ - fun mergeCounts(counts: List>): Pair? { - if (counts.isEmpty()) return null - - val hlls = counts.mapNotNull { it.second } - if (hlls.isNotEmpty()) { - val merged = HyperLogLog.merge(hlls) - return HyperLogLog.estimate(merged).toInt() to true - } - - // No registers to union — fall back to the tightest lower bound, never the sum. - return (counts.maxOf { it.first }) to false - } } override suspend fun load(poll: PollEvent): PollLoadReport { @@ -103,12 +77,13 @@ class RelayPollResponseLoader( } val results = client.count(relays.associateWith { listOf(filter) }, idleTimeoutMs = TIMEOUT_MS) - val merged = mergeCounts(results.values.map { it.count to it.hll }) + // Combination rules (never a sum) live in quartz — see mergeCountResults. + val merged = mergeCountResults(results.values) return PollLoadReport( - reported = merged?.first, + reported = merged?.count, // An estimate is approximate; so is a figure from only some of the relays we asked. - approximate = (merged?.second ?: false) || results.size < relays.size, + approximate = (merged?.approximate ?: false) || results.size < relays.size, relaysAsked = relays.size, relaysAnswered = results.size, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt index 723c37e67c..0cd76de132 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt @@ -42,9 +42,11 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext import kotlin.reflect.KClass /** One option's share of the poll, ready to draw. */ @@ -96,9 +98,11 @@ class PollResultsUiState( /** * Screen state for the poll results page. * - * Reads the live tally off the poll [Note] — no new subscription, no second cache — and turns it - * into rows the UI can render without doing set arithmetic in composition. The only interactive - * state is [selectedOption], which scopes the voter list without touching the summary above it. + * Reads the live tally off the poll [Note] — no second cache — and turns it into rows the UI can + * render without doing set arithmetic in composition. Every rebuild after the first runs on + * [computeContext] (Default): a backfill emits one tally per consumed vote, and re-sorting thousands of + * voters that many times on the UI thread would drop frames for the whole drain. The only + * interactive state is [selectedOption], which scopes the voter list without touching the summary. * * Muting is applied here rather than in the tally: a muted voter still counts toward the totals * (they did vote, and hiding them from the maths would misreport the poll), they are just not @@ -112,6 +116,9 @@ class PollResultsViewModel( follows: Flow>, hiddenChanges: Flow, private val loader: PollResponseLoader? = null, + // Injected so tests can drive both on the test scheduler; production never passes them. + private val computeContext: CoroutineContext = Dispatchers.Default, + private val loadContext: CoroutineContext = Dispatchers.IO, ) : ViewModel() { companion object { /** Faces shown per option before collapsing into a "+N" chip, matching UserGallery. */ @@ -132,7 +139,7 @@ class PollResultsViewModel( // Page past the subscription cap once, on open. Without this a poll with more responses // than the live filter's limit shows a confidently wrong tally. if (loader != null) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(loadContext) { val poll = awaitPollEvent() backfill.value = BackfillState(running = true) val report = runCatching { loader.load(poll) }.getOrNull() @@ -160,11 +167,16 @@ class PollResultsViewModel( backfill, ) { tally, followSet, selected, _, load -> build(tally, followSet, selected, load) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = build(pollNote.pollState().responses.value, emptySet(), null, BackfillState()), - ) + }.flowOn(computeContext) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + // Built eagerly so the first frame already has the tally instead of flashing an + // empty poll. Only this one pass is on the caller's thread; every recomputation + // after it — including the flood of them during a backfill, one per consumed vote — + // runs on Default via the flowOn above. + initialValue = build(pollNote.pollState().responses.value, emptySet(), null, BackfillState()), + ) fun selectOption(code: String?) { _selectedOption.value = if (_selectedOption.value == code) null else code diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModelTest.kt index bf49356049..3292ee828d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModelTest.kt @@ -124,7 +124,7 @@ class PollResultsViewModelTest { note.pollState().addResponse(responseNote) } - private fun viewModel( + private fun TestScope.viewModel( note: Note, hidden: Set = emptySet(), follows: Set = setOf(followed), @@ -136,6 +136,10 @@ class PollResultsViewModelTest { follows = MutableStateFlow(follows), hiddenChanges = MutableStateFlow(Unit), loader = loader, + // Keep the state build and the backfill on the test scheduler; in production these are + // Default and IO so a big poll's re-sorts never land on the UI thread. + computeContext = UnconfinedTestDispatcher(testScheduler), + loadContext = UnconfinedTestDispatcher(testScheduler), ) @Test diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt index 0f19d9d75c..08d7db6b67 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip45Count.HyperLogLog +import com.vitorpamplona.quartz.nip45Count.mergeCountResults import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.ensureActive @@ -179,15 +179,9 @@ suspend fun INostrClient.count( } /** - * Queries multiple relays for a COUNT and merges the HyperLogLog - * registers from all responses to produce a single merged estimate. + * Queries multiple relays for a COUNT and combines the answers into one figure. * - * If any relay returns HLL data, the results are merged by taking - * the maximum register value across all relays, and the cardinality - * is re-estimated from the merged registers. - * - * If no relay returns HLL data, falls back to the maximum count - * reported by any relay. + * The combination rules — and why summing is never one of them — live in [mergeCountResults]. * * @param relays List of relays to query. * @param filter The filter to count against. @@ -207,20 +201,5 @@ suspend fun INostrClient.countMerged( idleTimeoutMs = idleTimeoutMs, ) - if (results.isEmpty()) return null - - val hlls = results.values.mapNotNull { it.hll } - - return if (hlls.isNotEmpty()) { - val merged = HyperLogLog.merge(hlls) - val estimate = HyperLogLog.estimate(merged) - CountResult( - count = estimate.toInt(), - approximate = true, - hll = merged, - ) - } else { - // No HLL data - use the maximum count from any relay - results.values.maxByOrNull { it.count } - } + return mergeCountResults(results.values) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/CountMerge.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/CountMerge.kt new file mode 100644 index 0000000000..076f893589 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/CountMerge.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip45Count + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult + +/** + * Combines NIP-45 COUNT answers from several relays into one figure. + * + * **Never sum.** Relays mirror each other, so the same event is normally held by several of them + * and adding their counts multiplies the result by an unknown factor. Every honest combination is + * therefore either a set union or a lower bound: + * + * - **HyperLogLog union.** Relays that ship the optional `hll` field give 256 registers that merge + * by per-register maximum; the union's cardinality is re-estimated from the merged registers, so + * events held by several relays collapse into one instead of stacking. + * - **Largest plain count.** A relay's own count is a lower bound on the union, so the biggest one + * is the tightest bound available without registers to union. + * + * When both kinds arrive, neither wins by default: the HLL union covers only the relays that + * supplied registers, and a plain count from a relay outside that set can easily exceed it — so the + * answer is the larger of the two. Taking the estimate alone would silently discard the better + * bound. + * + * Relays that don't implement COUNT simply never answer and are absent here. That can only make the + * result too small, which is the safe direction for a number presented as "at least this many". + * + * @return the merged result, or null when nothing to merge. + */ +fun mergeCountResults(results: Collection): CountResult? { + if (results.isEmpty()) return null + + val hlls = results.mapNotNull { it.hll } + if (hlls.isEmpty()) { + return results.maxByOrNull { it.count } + } + + val merged = HyperLogLog.merge(hlls) + val union = HyperLogLog.estimate(merged).toInt() + + // Relays that answered without registers are not represented in the union above. + val bestPlain = results.filter { it.hll == null }.maxOfOrNull { it.count } ?: 0 + + return CountResult( + count = maxOf(union, bestPlain), + approximate = true, + hll = merged, + ) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountMergeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountMergeTest.kt new file mode 100644 index 0000000000..1b673e5bfc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountMergeTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip45Count + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CountMergeTest { + /** Register-index offset into the key, as a relay counting by `#e` would use. */ + private val offset = 8 + + /** A relay that answered with a plain count and no registers. */ + private fun plain(count: Int) = CountResult(count = count, approximate = false, hll = null) + + /** + * A relay that answered with HLL registers built from [ids] distinct 32-byte keys. + * + * Real registers rather than hand-written bytes, so the union assertions exercise the same + * add/merge/estimate path a relay's answer would. + */ + private fun hll(ids: Iterable): CountResult { + val builder = HllBuilder(offset = offset) + ids.forEach { builder.add(key(it)) } + return builder.toCountResult() + } + + /** + * Deterministic distinct 32-byte key. + * + * Pseudo-random bytes rather than a counter written into the array: HyperLogLog reads a + * register index and a leading-zero run straight out of the key and assumes those bits are + * uniformly distributed, which is true of real event ids and false of counters — structured + * keys pile identical values into the registers and the estimator goes wild. + */ + private fun key(n: Int): ByteArray = Random(n).nextBytes(32) + + @Test + fun noAnswersMergeToNull() { + assertNull(mergeCountResults(emptyList())) + } + + @Test + fun plainCountsTakeTheLargestNeverTheSum() { + val merged = mergeCountResults(listOf(plain(10), plain(500), plain(120))) + + assertNotNull(merged) + // 630 would be the sum. Relays mirror each other, so the union is at least the biggest + // single relay and at most the sum — only the lower bound is defensible. + assertEquals(500, merged.count) + assertEquals(null, merged.hll) + } + + @Test + fun aSingleRelayPassesItsOwnAnswerThrough() { + val merged = mergeCountResults(listOf(plain(42))) + + assertNotNull(merged) + assertEquals(42, merged.count) + } + + @Test + fun overlappingHllRegistersCollapseInsteadOfStacking() { + // Two relays holding heavily overlapping sets: 0..199 and 100..299. + val a = hll(0 until 200) + val b = hll(100 until 300) + val merged = mergeCountResults(listOf(a, b)) + + assertNotNull(merged) + assertTrue(merged.approximate) + assertNotNull(merged.hll) + + // The claim under test is the merge property, not the estimator's accuracy: merging two + // relays' registers must give exactly what one relay holding the union would have reported. + // (HLL at m=256 has no bias correction and reads high around n≈m, so asserting a band + // around the true 300 would be testing the estimator, not this function.) + assertEquals(hll(0 until 300).count, merged.count) + + // And it must land nowhere near the 400 a naive sum would claim. + assertTrue(merged.count < a.count + b.count, "merging must collapse the overlap, not stack it") + } + + @Test + fun disjointHllRegistersMergeToTheUnionToo() { + val merged = mergeCountResults(listOf(hll(0 until 150), hll(500 until 650))) + + assertNotNull(merged) + assertEquals(hll((0 until 150) + (500 until 650)).count, merged.count) + } + + @Test + fun aPlainCountLargerThanTheHllUnionIsNotDiscarded() { + // The regression this test exists for: a relay outside the register set can hold far more + // than the relays that supplied registers. Estimating from the registers alone threw that + // relay's answer away and reported ~50 instead of 5000. + val merged = mergeCountResults(listOf(hll(0 until 50), plain(5000))) + + assertNotNull(merged) + assertEquals(5000, merged.count) + // Still approximate, and the registers are still carried so a caller can merge again. + assertTrue(merged.approximate) + assertNotNull(merged.hll) + } + + @Test + fun aPlainCountSmallerThanTheHllUnionDoesNotDragItDown() { + val union = hll(0 until 300) + val merged = mergeCountResults(listOf(union, plain(5))) + + assertNotNull(merged) + assertEquals(union.count, merged.count) + } + + @Test + fun mergedRegistersAreTheUnionOfTheInputs() { + val a = hll(0 until 100) + val b = hll(100 until 200) + val merged = mergeCountResults(listOf(a, b)) + + assertNotNull(merged) + val expected = HyperLogLog.merge(listOf(a.hll!!, b.hll!!)) + assertEquals(expected.toList(), merged.hll!!.toList()) + } + + @Test + fun theResultIsAlwaysAtLeastEveryIndividualAnswer() { + // The property that makes this safe to show as "at least this many". + val answers = listOf(plain(7), plain(31), hll(0 until 20), plain(12)) + val merged = mergeCountResults(answers) + + assertNotNull(merged) + assertTrue(merged.count >= answers.filter { it.hll == null }.maxOf { it.count }) + } +} From aae563c927ce0522c410bccdab49a837abf4be58 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 21:50:35 +0000 Subject: [PATCH 089/227] perf(polls): fold responses into the tally instead of rebuilding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResponseTally rebuilt itself from every response on each arrival — copying the list, redoing latestByAuthor, re-inverting the option map — so a poll cost O(n²) across its lifetime. The containment guard in addResponse was a second O(n) scan per arrival. Draining a few thousand votes through the new backfill meant millions of map operations and one full copy per event. The tally now sits on persistent collections and folds one response in at a time, sharing structure with the snapshot the UI is still reading, so an arrival costs O(log n) and allocates almost nothing. Insertion-ordered persistent collections are used deliberately: replaying them has to keep the same latest-per-author tie behaviour a list walk had. Folding is only sound if every add retracts what it supersedes, which a rebuild never had to think about. A re-vote now explicitly removes the voter from the options they previously held, and drops an option key that empties out rather than leaving a zero-size set for winning() to find. A later response that casts nothing valid still supersedes an earlier valid one, per NIP-88's latest-per-author rule. Removal and policy changes keep the full rebuild. Retracting a deleted vote means finding that author's next-latest, which would need a per-author index that is not worth carrying for a delete — and neither path is on the hot path. New tests: re-vote retraction and empty-key removal, a later invalid vote superseding a valid one, deletion restoring the previous vote, an echoed response leaving the flow untouched, and an equivalence check that folds a messy stream — re-votes, a late vote, an unknown code, an out-of-order arrival — one at a time and asserts it matches a rebuild exactly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv --- .../model/nip88Polls/PollResponsesCache.kt | 210 +++++++++++------- .../nip88Polls/PollResponsesCacheTest.kt | 110 +++++++++ 2 files changed, 242 insertions(+), 78 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt index 59d638f321..c36ee8840b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt @@ -24,9 +24,12 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.UserDependencies -import com.vitorpamplona.amethyst.commons.model.latestByAuthor import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -41,100 +44,153 @@ class PollResponsesCache : UserDependencies { compareByDescending { it.createdAt }.thenBy { it.id } } - class ResponseTally( - val allResponses: List = emptyList(), - /** - * The poll's own rules. Null until the kind-1068 event arrives — responses often show up - * first — in which case the tally stays permissive rather than dropping votes it cannot - * yet validate. - */ - val policy: PollTallyPolicy? = null, - ) { - /** Responses stamped outside the poll's window, excluded from [votes]. */ - val lateVotes: Int + /** + * An immutable snapshot of the tally. + * + * Built on persistent collections so [add] can fold one response in while sharing structure + * with the snapshot the UI is still reading. Rebuilding from scratch per arrival — the obvious + * implementation — is O(n) each time and therefore O(n²) across a poll's lifetime; a backfill + * draining a few thousand votes turns that into millions of map operations and one full copy + * per event. + */ + class ResponseTally + private constructor( + val allResponses: PersistentSet, + /** + * The poll's own rules. Null until the kind-1068 event arrives — responses often show + * up first — in which case the tally stays permissive rather than dropping votes it + * cannot yet validate. + */ + val policy: PollTallyPolicy?, + /** One response per author — the latest one that is actually eligible. */ + val votes: PersistentMap, + /** Option code -> the voters who picked it. */ + val tally: PersistentMap>, + /** Voters whose response cast at least one valid option code. */ + val countedVoters: PersistentSet, + /** Responses stamped outside the poll's window, excluded from [votes]. */ + val lateVotes: Int, + ) { + constructor() : this( + persistentSetOf(), + null, + persistentMapOf(), + persistentMapOf(), + persistentSetOf(), + 0, + ) - /** Voters whose response cast no valid option code at all, excluded from [tally]. */ - val ignoredVotes: Int + /** Voters whose response cast no valid option code at all, excluded from [tally]. */ + val ignoredVotes: Int get() = votes.size - countedVoters.size - /** One response per author — the latest one that is actually eligible. */ - val votes: Map + fun winning() = tally.maxByOrNull { it.value.size }?.key - /** Option code -> the voters who picked it. */ - val tally: Map> + /** + * Distinct people whose vote counts. This is the denominator for every percentage: on a + * multiple-choice poll someone who ticks three boxes is still one voter, so the bars + * read as "share of people" and may sum past 100%. + */ + fun totalVoters() = countedVoters.size - /** Voters whose response cast at least one valid option code. */ - val countedVoters: Set + /** Boxes ticked across all voters. Equal to [totalVoters] on a single-choice poll. */ + fun totalSelections() = tally.entries.sumOf { it.value.size } - init { - var late = 0 - val eligible = - if (policy == null) { - allResponses - } else { - allResponses.filter { note -> - val event = note.event - val inWindow = event !is PollResponseEvent || policy.isInWindow(event.createdAt) - if (!inWindow) late++ - inWindow + /** The option codes this response actually casts under the current rules. */ + private fun accepted(event: PollResponseEvent): Set { + val codes = event.responses() + return policy?.accept(codes) ?: codes.toSet() + } + + private fun withVoteRemoved( + user: User, + event: PollResponseEvent, + ): Pair>, PersistentSet> { + var newTally = tally + accepted(event).forEach { code -> + val remaining = newTally[code]?.remove(user) + newTally = + when { + remaining == null -> newTally + // Drop the key rather than leave an empty set: winning() and + // totalSelections() both read every entry. + remaining.isEmpty() -> newTally.remove(code) + else -> newTally.put(code, remaining) + } + } + return newTally to countedVoters.remove(user) + } + + /** + * Folds one response in, returning `this` unchanged when it adds nothing — which lets + * the caller skip an emission rather than churn every collector. + */ + fun add(note: Note): ResponseTally { + if (note in allResponses) return this + + val withNote = allResponses.add(note) + val event = + note.event as? PollResponseEvent + ?: return ResponseTally(withNote, policy, votes, tally, countedVoters, lateVotes) + + if (policy != null && !policy.isInWindow(event.createdAt)) { + return ResponseTally(withNote, policy, votes, tally, countedVoters, lateVotes + 1) + } + + val author = note.author ?: return ResponseTally(withNote, policy, votes, tally, countedVoters, lateVotes) + + // Latest-per-author, ties keeping the one already held — same rule as latestByAuthor. + val current = votes[author] + if (current != null && current.createdAt >= event.createdAt) { + return ResponseTally(withNote, policy, votes, tally, countedVoters, lateVotes) + } + + // A re-vote has to retract the old one first, or the voter lingers in the options + // they used to hold. + var newTally = tally + var newVoters = countedVoters + if (current != null) { + val (t, v) = withVoteRemoved(author, current) + newTally = t + newVoters = v + } + + val codes = accepted(event) + if (codes.isNotEmpty()) { + newVoters = newVoters.add(author) + codes.forEach { code -> + newTally = newTally.put(code, (newTally[code] ?: persistentSetOf()).add(author)) } } - lateVotes = late - votes = eligible.latestByAuthor() - - val counted = mutableMapOf>() - val voters = mutableSetOf() - votes.forEach { (user, responseEvent) -> - val codes = responseEvent.responses() - val accepted = policy?.accept(codes) ?: codes.toSet() - if (accepted.isNotEmpty()) { - voters.add(user) - accepted.forEach { code -> counted.getOrPut(code) { mutableSetOf() }.add(user) } - } + return ResponseTally(withNote, policy, votes.put(author, event), newTally, newVoters, lateVotes) } - ignoredVotes = votes.size - voters.size - countedVoters = voters - tally = counted + /** + * Rebuilds from a response set — the O(n) path, used when a response disappears or the + * rules change under us. Both are rare; incremental removal would need a per-author + * index to find the next-latest vote, which is not worth carrying for a delete. + */ + fun rebuild( + responses: PersistentSet = allResponses, + newPolicy: PollTallyPolicy? = policy, + ): ResponseTally { + var rebuilt = ResponseTally(persistentSetOf(), newPolicy, persistentMapOf(), persistentMapOf(), persistentSetOf(), 0) + responses.forEach { rebuilt = rebuilt.add(it) } + return rebuilt + } } - fun winning() = tally.maxByOrNull { it.value.size }?.key - - /** - * Distinct people whose vote counts. This is the denominator for every percentage: on a - * multiple-choice poll someone who ticks three boxes is still one voter, so the bars read - * as "share of people" and may sum past 100%. - */ - fun totalVoters() = countedVoters.size - - /** Boxes ticked across all voters. Equal to [totalVoters] on a single-choice poll. */ - fun totalSelections() = tally.entries.sumOf { it.value.size } - } - val responses = MutableStateFlow(ResponseTally()) fun addResponse(note: Note) { - // if it's already there, quick exit - if (responses.value.allResponses.contains(note)) return - - responses.update { - ResponseTally( - it.allResponses + note, - it.policy, - ) - } + responses.update { it.add(note) } } fun removeResponse(deleteNote: Note) { // if it's not already there, quick exit - if (!responses.value.allResponses.contains(deleteNote)) return + if (deleteNote !in responses.value.allResponses) return - responses.update { - ResponseTally( - it.allResponses - deleteNote, - it.policy, - ) - } + responses.update { it.rebuild(responses = it.allResponses.remove(deleteNote)) } } /** @@ -144,9 +200,7 @@ class PollResponsesCache : UserDependencies { fun updatePolicy(newPolicy: PollTallyPolicy) { if (responses.value.policy == newPolicy) return - responses.update { - ResponseTally(it.allResponses, newPolicy) - } + responses.update { it.rebuild(newPolicy = newPolicy) } } fun ResponseTally.filterTo( diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt index 101b2db4f4..248e088fb3 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt @@ -301,6 +301,116 @@ class PollResponsesCacheTest { assertSame(first, cache.responses.value) } + // --------------------------------------------------------------------------------------- + // Incremental folding: the tally accumulates one response at a time instead of rebuilding, so + // anything it forgets to retract stays wrong forever. A rebuild could not get these wrong. + // --------------------------------------------------------------------------------------- + + @Test + fun reVotingRetractsTheOldOptionEntirely() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.MULTI_CHOICE, codes = setOf("a", "b", "c"))) + val voter = "b".repeat(64) + + cache.addResponse(multiResponseNote("1".repeat(64), voter, listOf("a", "b"), createdAt = 10)) + cache.addResponse(multiResponseNote("2".repeat(64), voter, listOf("b", "c"), createdAt = 20)) + + val tally = cache.responses.value + // "a" was only ever held by this voter, so the key must be gone rather than left empty — + // winning() reads every entry and would happily return an option with nobody in it. + assertFalse(tally.tally.containsKey("a")) + assertEquals(setOf(voter), tally.tally["b"]?.map { it.pubkeyHex }?.toSet()) + assertEquals(setOf(voter), tally.tally["c"]?.map { it.pubkeyHex }?.toSet()) + assertEquals(1, tally.totalVoters()) + assertEquals(2, tally.totalSelections()) + } + + @Test + fun aLaterButInvalidVoteDoesNotSilentlyDropTheVoter() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + val voter = "b".repeat(64) + + cache.addResponse(responseNote("1".repeat(64), voter, option = "yes", createdAt = 10)) + // NIP-88 keeps the latest response per author — even when that response casts nothing + // countable, it still supersedes the earlier one. + cache.addResponse(responseNote("2".repeat(64), voter, option = "bogus", createdAt = 20)) + + val tally = cache.responses.value + assertEquals(0, tally.totalVoters()) + assertEquals(1, tally.ignoredVotes) + assertFalse(tally.tally.containsKey("yes")) + } + + @Test + fun deletingTheLatestVoteRestoresThePreviousOne() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + val voter = "b".repeat(64) + + val first = responseNote("1".repeat(64), voter, option = "yes", createdAt = 10) + val second = responseNote("2".repeat(64), voter, option = "no", createdAt = 20) + cache.addResponse(first) + cache.addResponse(second) + assertEquals("no", cache.responses.value.winning()) + + cache.removeResponse(second) + + // Removal cannot be folded in — the earlier vote has to be found again — so it rebuilds. + assertEquals("yes", cache.responses.value.winning()) + assertEquals(1, cache.responses.value.totalVoters()) + } + + @Test + fun foldingOneAtATimeMatchesARebuildFromScratch() { + val incremental = PollResponsesCache() + val pollPolicy = policy(PollType.MULTI_CHOICE, codes = setOf("a", "b", "c"), createdAt = 100, endsAt = 500) + incremental.updatePolicy(pollPolicy) + + // A messy but realistic stream: re-votes, a late vote, an invalid code, and duplicates. + val notes = + listOf( + multiResponseNote("1".repeat(64), "b".repeat(64), listOf("a"), createdAt = 150), + multiResponseNote("2".repeat(64), "c".repeat(64), listOf("a", "b"), createdAt = 160), + multiResponseNote("3".repeat(64), "b".repeat(64), listOf("b", "c"), createdAt = 200), + multiResponseNote("4".repeat(64), "d".repeat(64), listOf("zzz"), createdAt = 210), + multiResponseNote("5".repeat(64), "e".repeat(64), listOf("c"), createdAt = 900), + multiResponseNote("6".repeat(64), "c".repeat(64), listOf("a"), createdAt = 120), + ) + notes.forEach { incremental.addResponse(it) } + + val folded = incremental.responses.value + val rebuilt = folded.rebuild() + + assertEquals(rebuilt.totalVoters(), folded.totalVoters()) + assertEquals(rebuilt.totalSelections(), folded.totalSelections()) + assertEquals(rebuilt.lateVotes, folded.lateVotes) + assertEquals(rebuilt.ignoredVotes, folded.ignoredVotes) + assertEquals(rebuilt.winning(), folded.winning()) + assertEquals( + rebuilt.tally.mapValues { entry -> entry.value.map { it.pubkeyHex }.toSet() }, + folded.tally.mapValues { entry -> entry.value.map { it.pubkeyHex }.toSet() }, + ) + assertEquals( + rebuilt.votes.mapKeys { it.key.pubkeyHex }.mapValues { it.value.id }, + folded.votes.mapKeys { it.key.pubkeyHex }.mapValues { it.value.id }, + ) + } + + @Test + fun aResponseThatChangesNothingDoesNotChurnTheFlow() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + val note = responseNote("1".repeat(64), "b".repeat(64), option = "yes", createdAt = 10) + cache.addResponse(note) + + val before = cache.responses.value + cache.addResponse(note) // the same relay echo again + + // Same instance, so every collector stays put instead of recomposing for nothing. + assertSame(before, cache.responses.value) + } + @Test fun voterWhoseVoteWasIgnoredCanStillVote() { val cache = PollResponsesCache() From aafa96224555fcc01c24245a028b5e10381bfd8a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 22:49:24 +0000 Subject: [PATCH 090/227] fix(polls): audit fixes across the tally, the card and the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A draw now has no winner. winning() used maxByOrNull, which handed the green highlight and the check to whichever tied option happened to come first — on a 1-1 poll, an outright lie about the result. The feed card no longer sorts every voter on the UI thread to draw four avatars. filterTo ran during composition and again on every tally change, sorting an option's whole voter list so UserGallery could take(4). TallyResults now keeps its voters unsorted, materialises the ordered list lazily for the screens that list everyone, and offers topUsers(n) via a bounded insertion. Desktop's own gallery and its is-this-my-vote check go through the same paths instead of forcing the sort. Voter rows are keyed by pubkey rather than by User instance. The cache normally hands out one User per pubkey, but an eviction and re-create would leave two live instances for one person — and two rows sharing a LazyColumn key is a crash, not a cosmetic duplicate. The tally still counts them separately; this stops the crash. An empty poll that is still loading no longer says "No votes yet" and "Loading every vote..." at the same time. The results opt-in fires on a deep link. It read note.event inside an id-keyed LaunchedEffect, so when the poll event arrived after first composition the effect never ran again and the visit went unrecorded. Keyed on the observed note state now, via observeNote, which also carries the subscription the screen already needed. The backfill remembers. The ViewModel is rebuilt on every visit, so bouncing in and out of a poll re-walked every relay's whole history; a completed drain now stands in for the next five minutes, which the live subscription makes safe. Smaller: the ViewModel factory, loader and mapped Flow are remembered instead of rebuilt each recomposition; winning() is computed once per build rather than once per option; myVote falls out of the loop already running instead of a second scan; option bars grow from zero on first show; the vote column has a max width instead of a fixed one; and a no-HLL merge reports approximate when any relay said so, not just when the highest did. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv --- .../amethyst/ui/note/types/Poll.kt | 14 ++-- .../polls/results/PollResultsScreen.kt | 62 ++++++++------ .../polls/results/RelayPollResponseLoader.kt | 40 +++++++-- .../model/nip88Polls/PollResponsesCache.kt | 76 +++++++++++++++-- .../nip88Polls/PollResultsViewModel.kt | 51 ++++++++--- .../nip88Polls/PollResponsesCacheTest.kt | 84 +++++++++++++++++++ .../nip88Polls/PollResultsViewModelTest.kt | 43 ++++++++++ .../desktop/ui/note/DesktopPollCard.kt | 4 +- .../quartz/nip45Count/CountMerge.kt | 9 +- 9 files changed, 324 insertions(+), 59 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index b189c487a4..260bbe24ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -575,7 +575,7 @@ private fun RenderClosedItem( // The percentage alone never said how many people that was. Text( - text = tally.users.size.toString(), + text = tally.size.toString(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(end = 6.dp), @@ -612,23 +612,27 @@ fun measure100PercentWidthModifier(textStyle: TextStyle): Modifier { } } +/** Faces drawn before the rest collapse into a "+N" chip. */ +private const val GALLERY_FACES = 4 + @Composable fun UserGallery( tally: TallyResults, galleryUser: @Composable RowScope.(user: User) -> Unit, ) { - if (tally.users.isNotEmpty()) { + if (tally.size > 0) { + val shown = tally.topUsers(GALLERY_FACES) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-10).dp), ) { - tally.users.take(4).forEach { + shown.forEach { key(it.pubkeyHex) { galleryUser(it) } } - if (tally.users.size > 4) { + if (tally.size > shown.size) { Box( contentAlignment = Alignment.Center, modifier = @@ -638,7 +642,7 @@ fun UserGallery( .background(MaterialTheme.colorScheme.secondaryContainer), ) { Text( - text = "+" + showCount(tally.users.size - 4), + text = "+" + showCount(tally.size - shown.size), fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt index f491491f77..1f30052485 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/PollResultsScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -54,7 +55,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -76,7 +79,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollOptionResult import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollResultsUiState import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollResultsViewModel import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollVoterRow -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -103,7 +106,7 @@ import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.map -private val VoteColumnWidth = 116.dp +private val VoteColumnMaxWidth = 140.dp /** * The full results of a NIP-88 poll: how many votes each option got, and who voted for what. @@ -141,31 +144,34 @@ private fun PollResults( ) { val account = accountViewModel.account - // Keeps a REQ open for this poll while the screen is on top. Without it the only votes ever - // shown are the ones the one-shot backfill happened to catch: a vote cast while you are reading - // would never arrive, because the feed card that used to hold this subscription is disposed - // behind us. It also loads the kind-1068 event itself when we arrived by deep link. - EventFinderFilterAssemblerSubscription(note, accountViewModel) + // Subscribes for this poll while the screen is on top *and* observes what arrives. Without the + // subscription the only votes ever shown are the ones the one-shot backfill happened to catch — + // a vote cast while you are reading would never appear, because the feed card that used to + // carry it is disposed behind us. It also loads the kind-1068 event on a deep link. + val noteState by observeNote(note, accountViewModel) - // Opening this screen is the opt-in, exactly like the card's "View results" link — so the feed - // card stops hiding the tally behind a tap once you have been here. - LaunchedEffect(note.idHex) { + // Opening this screen is the opt-in, exactly like the card's "View results" link. Keyed on the + // note state rather than the id: arriving by deep link, the poll event lands *after* the first + // composition, and an id-keyed effect would never run again to record it. + LaunchedEffect(noteState) { (note.event as? PollEvent)?.let { accountViewModel.markPollResultsViewed(it.id, it.endsAt()) } } - val viewModel: PollResultsViewModel = - viewModel( - key = "PollResults-${note.idHex}", - factory = - PollResultsViewModel.Factory( - pollNote = note, - forKey = account.pubKey, - isHidden = { account.isHidden(it) }, - follows = account.allFollows.flow.map { it.authors }, - hiddenChanges = account.hiddenUsers.flow, - loader = RelayPollResponseLoader(account.client, account.cache, note), - ), - ) + // Remembered: viewModel() only consults the factory once, but building it inline allocated a + // loader, two lambdas and a mapped Flow on every recomposition. + val factory = + remember(note, account) { + PollResultsViewModel.Factory( + pollNote = note, + forKey = account.pubKey, + isHidden = { account.isHidden(it) }, + follows = account.allFollows.flow.map { it.authors }, + hiddenChanges = account.hiddenUsers.flow, + loader = RelayPollResponseLoader(account.client, account.cache, note), + ) + } + + val viewModel: PollResultsViewModel = viewModel(key = "PollResults-${note.idHex}", factory = factory) val state by viewModel.uiState.collectAsStateWithLifecycle() val selected by viewModel.selectedOption.collectAsStateWithLifecycle() @@ -414,8 +420,12 @@ private fun OptionBar( ) // Same 800ms tween the feed card uses, so a vote cast there and read here moves identically. + // animateFloatAsState starts *at* its target, so without stepping off zero the first frame the + // bars would simply appear at full length and only ever animate on later changes. + var target by remember(option.code) { mutableFloatStateOf(0f) } + LaunchedEffect(option.code, option.percent) { target = option.percent } val progress by animateFloatAsState( - targetValue = option.percent, + targetValue = target, animationSpec = tween(durationMillis = 800), label = "pollOptionBar", ) @@ -604,7 +614,7 @@ private fun VoterRow( private fun VoteChoice(voter: PollVoterRow) { Column( horizontalAlignment = Alignment.End, - modifier = Modifier.width(VoteColumnWidth), + modifier = Modifier.widthIn(max = VoteColumnMaxWidth), ) { Text( text = voter.labels.joinToString(", "), @@ -633,7 +643,7 @@ private fun ResultsFooter(state: PollResultsUiState) { } } - if (state.totalVoters == 0) { + if (state.totalVoters == 0 && !state.isBackfilling) { Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { Text( text = stringRes(R.string.poll_results_no_votes), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt index d24599e1d7..96d2a545e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/polls/results/RelayPollResponseLoader.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollLoadReport import com.vitorpamplona.amethyst.commons.viewmodels.nip88Polls.PollResponseLoader import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool @@ -32,6 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip45Count.mergeCountResults import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import java.util.concurrent.ConcurrentHashMap /** * Drains a poll's responses past the live subscription's cap, then asks the relays how many there @@ -55,9 +58,28 @@ class RelayPollResponseLoader( companion object { /** Per-relay idle window for both the drain and the COUNT. */ const val TIMEOUT_MS = 20_000L + + /** + * How long a completed drain stands in for the next one. + * + * The ViewModel — and therefore this loader — is rebuilt on every visit, so bouncing in and + * out of a poll would otherwise re-walk every relay's whole history each time. The live + * subscription keeps the tally current in between, which is what makes skipping safe. + */ + const val REDRAIN_AFTER_SECONDS = 5 * 60L + + // Process-wide, because the point is to outlive the screen. Small and self-limiting: one + // entry per poll actually opened. + private val recentDrains = ConcurrentHashMap>() + + fun forgetDrains() = recentDrains.clear() } override suspend fun load(poll: PollEvent): PollLoadReport { + recentDrains[poll.id]?.let { (drainedAt, report) -> + if (TimeUtils.now() - drainedAt < REDRAIN_AFTER_SECONDS) return report + } + val relays = responseRelays(poll) if (relays.isEmpty()) return PollLoadReport(null, approximate = false, relaysAsked = 0, relaysAnswered = 0) @@ -80,13 +102,17 @@ class RelayPollResponseLoader( // Combination rules (never a sum) live in quartz — see mergeCountResults. val merged = mergeCountResults(results.values) - return PollLoadReport( - reported = merged?.count, - // An estimate is approximate; so is a figure from only some of the relays we asked. - approximate = (merged?.approximate ?: false) || results.size < relays.size, - relaysAsked = relays.size, - relaysAnswered = results.size, - ) + val report = + PollLoadReport( + reported = merged?.count, + // An estimate is approximate; so is a figure from only some of the relays we asked. + approximate = (merged?.approximate ?: false) || results.size < relays.size, + relaysAsked = relays.size, + relaysAnswered = results.size, + ) + + recentDrains[poll.id] = TimeUtils.now() to report + return report } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt index c36ee8840b..cc7bd071ef 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt @@ -83,7 +83,32 @@ class PollResponsesCache : UserDependencies { /** Voters whose response cast no valid option code at all, excluded from [tally]. */ val ignoredVotes: Int get() = votes.size - countedVoters.size - fun winning() = tally.maxByOrNull { it.value.size }?.key + /** + * The single option with the most voters, or null when nothing leads. + * + * A draw has no winner. `maxByOrNull` would hand the crown — the green highlight and + * the check — to whichever tied option happened to come first, which on a 1-1 poll is + * an outright lie about the result. + */ + fun winning(): String? { + var best: String? = null + var bestCount = 0 + var drawn = false + + tally.forEach { (code, voters) -> + when { + voters.size > bestCount -> { + best = code + bestCount = voters.size + drawn = false + } + + voters.size == bestCount -> drawn = true + } + } + + return if (drawn || bestCount == 0) null else best + } /** * Distinct people whose vote counts. This is the denominator for every percentage: on a @@ -210,7 +235,7 @@ class PollResponsesCache : UserDependencies { ): TallyResults { val comparator = compareByDescending { it.pubkeyHex == forKey }.thenByDescending { it.pubkeyHex in priority }.thenBy { it.pubkeyHex } - val usersThatVotedForThisOption = tally[code] ?: emptyList() + val usersThatVotedForThisOption = tally[code] ?: emptySet() val voters = totalVoters() @@ -224,9 +249,9 @@ class PollResponsesCache : UserDependencies { 0f } - val sortedUsers = usersThatVotedForThisOption.sortedWith(comparator) - - return TallyResults(sortedUsers, percent, code == winning()) + // Deliberately not sorted here: this runs on the UI thread for every visible poll card, and + // the feed only ever draws four avatars. TallyResults sorts on demand. + return TallyResults(usersThatVotedForThisOption, comparator, percent, code == winning()) } fun currentTally( @@ -251,9 +276,46 @@ class PollResponsesCache : UserDependencies { fun hasPubKeyVotedFlow(user: User): Flow = responses.map { it.countedVoters.contains(user) }.distinctUntilChanged() } +/** + * One option's share of a poll, with its voters kept unsorted until somebody needs them ordered. + * + * The feed card builds one of these per option, on the UI thread, every time the tally changes — + * and then draws four avatars. Sorting every voter of a busy poll to pick four is the kind of work + * that only shows up as dropped frames while scrolling, so [users] is lazy and [topUsers] never + * sorts the tail at all. + */ @Stable class TallyResults( - val users: List = emptyList(), + private val voters: Collection = emptyList(), + private val order: Comparator = compareBy { it.pubkeyHex }, val percent: Float = 0.0f, val isWinning: Boolean = false, -) +) { + val size get() = voters.size + + /** Whether this option holds a vote from [pubkeyHex], without materialising the ordered list. */ + fun contains(pubkeyHex: HexKey) = voters.any { it.pubkeyHex == pubkeyHex } + + /** Every voter, in order. Materialised once, and only for screens that list them all. */ + val users: List by lazy(LazyThreadSafetyMode.PUBLICATION) { voters.sortedWith(order) } + + /** + * The first [n] voters in order, chosen by a bounded insertion rather than a full sort — O(m·n) + * with n fixed at a handful, against O(m log m) to then throw all but [n] away. + */ + fun topUsers(n: Int): List { + if (n <= 0 || voters.isEmpty()) return emptyList() + if (voters.size <= n) return users + + val top = ArrayList(n + 1) + voters.forEach { user -> + val found = top.binarySearch(user, order) + val at = if (found < 0) -found - 1 else found + if (at < n) { + top.add(at, user) + if (top.size > n) top.removeAt(n) + } + } + return top + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt index 0cd76de132..a9da139939 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModel.kt @@ -202,6 +202,8 @@ class PollResultsViewModel( .thenBy { it.pubkeyHex } val totalVoters = tally.totalVoters() + // Once, not once per option: winning() walks every bucket. + val winner = tally.winning() val optionResults = codesInOrder.map { code -> @@ -211,24 +213,32 @@ class PollResultsViewModel( label = labels[code] ?: code, voters = voters.size, percent = if (totalVoters > 0) voters.size.toFloat() / totalVoters else 0f, - isWinning = voters.isNotEmpty() && code == tally.winning(), - topVoters = voters.sortedWith(byRelevance).take(AVATAR_STACK), + isWinning = code == winner, + topVoters = topByRelevance(voters, byRelevance, AVATAR_STACK), ) } // Invert the tally once: voter -> the codes they picked, in poll order. - val picks = mutableMapOf>() + // + // Keyed by pubkey rather than by User instance. The cache normally hands out one User per + // pubkey, but an eviction and re-create would leave two live instances for one person — + // and two rows sharing a key is a hard crash in LazyColumn, not a cosmetic duplicate. + val picks = LinkedHashMap>>() codesInOrder.forEach { code -> tally.tally[code]?.forEach { user -> - picks.getOrPut(user) { mutableListOf() }.add(code) + picks.getOrPut(user.pubkeyHex) { user to mutableListOf() }.second.add(code) } } var hidden = 0 + var myPicks: List = emptyList() val rows = picks - .mapNotNull { (user, codes) -> - if (isHidden(user.pubkeyHex)) { + .mapNotNull { (pubkey, entry) -> + val (user, codes) = entry + if (pubkey == forKey) myPicks = codes + + if (isHidden(pubkey)) { hidden++ return@mapNotNull null } @@ -250,11 +260,7 @@ class PollResultsViewModel( ignoredVotes = tally.ignoredVotes, lateVotes = tally.lateVotes, hiddenVoters = hidden, - myVote = - picks.entries - .firstOrNull { it.key.pubkeyHex == forKey } - ?.value - .orEmpty(), + myVote = myPicks, type = event?.pollType() ?: PollType.SINGLE_CHOICE, endsAt = event?.endsAt(), loadedResponses = tally.allResponses.size, @@ -267,6 +273,29 @@ class PollResultsViewModel( ) } + /** + * The [n] highest-ranked voters without sorting the rest — the stack shows a handful and a + * "+N" chip, so ordering the tail is pure waste on a poll with thousands of them. + */ + private fun topByRelevance( + voters: Collection, + order: Comparator, + n: Int, + ): List { + if (voters.size <= n) return voters.sortedWith(order) + + val top = ArrayList(n + 1) + voters.forEach { user -> + val found = top.binarySearch(user, order) + val at = if (found < 0) -found - 1 else found + if (at < n) { + top.add(at, user) + if (top.size > n) top.removeAt(n) + } + } + return top + } + class Factory( private val pollNote: Note, private val forKey: HexKey, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt index 248e088fb3..15e35f335e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCacheTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue @@ -411,6 +412,89 @@ class PollResponsesCacheTest { assertSame(before, cache.responses.value) } + @Test + fun aDrawHasNoWinner() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + + cache.addResponse(responseNote("1".repeat(64), "b".repeat(64), option = "yes", createdAt = 10)) + cache.addResponse(responseNote("2".repeat(64), "c".repeat(64), option = "no", createdAt = 10)) + + // Crowning whichever option came first would be an outright lie about a 1-1 result. + assertNull(cache.responses.value.winning()) + assertFalse(cache.currentTally("yes", "0".repeat(64), emptySet()).isWinning) + assertFalse(cache.currentTally("no", "0".repeat(64), emptySet()).isWinning) + } + + @Test + fun aClearLeadStillWins() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + + cache.addResponse(responseNote("1".repeat(64), "b".repeat(64), option = "yes", createdAt = 10)) + cache.addResponse(responseNote("2".repeat(64), "c".repeat(64), option = "yes", createdAt = 10)) + cache.addResponse(responseNote("3".repeat(64), "d".repeat(64), option = "no", createdAt = 10)) + + assertEquals("yes", cache.responses.value.winning()) + } + + @Test + fun anEmptyPollHasNoWinner() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + + assertNull(cache.responses.value.winning()) + } + + @Test + fun topUsersPicksTheHighestRankedWithoutOrderingTheRest() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + val me = "f".repeat(64) + val followed = "e".repeat(64) + + // Ten voters, two of whom should outrank the rest. + cache.addResponse(responseNote("1".repeat(64), me, option = "yes", createdAt = 10)) + cache.addResponse(responseNote("2".repeat(64), followed, option = "yes", createdAt = 10)) + (0 until 8).forEach { i -> + cache.addResponse(responseNote("$i".repeat(64).take(64).padEnd(64, 'a'), "${i}b".repeat(32), option = "yes", createdAt = 10)) + } + + val tally = cache.currentTally("yes", me, priorityAccounts = setOf(followed)) + + assertEquals(10, tally.size) + val top = tally.topUsers(4) + assertEquals(4, top.size) + assertEquals(me, top[0].pubkeyHex) + assertEquals(followed, top[1].pubkeyHex) + // Same answer the full ordering gives, just without paying for the tail. + assertEquals(tally.users.take(4).map { it.pubkeyHex }, top.map { it.pubkeyHex }) + } + + @Test + fun topUsersHandlesFewerVotersThanAsked() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + cache.addResponse(responseNote("1".repeat(64), "b".repeat(64), option = "yes", createdAt = 10)) + + val tally = cache.currentTally("yes", "0".repeat(64), emptySet()) + assertEquals(1, tally.topUsers(4).size) + assertEquals(0, tally.topUsers(0).size) + assertEquals(0, cache.currentTally("no", "0".repeat(64), emptySet()).topUsers(4).size) + } + + @Test + fun containsFindsAVoterWithoutOrdering() { + val cache = PollResponsesCache() + cache.updatePolicy(policy(PollType.SINGLE_CHOICE)) + val voter = "b".repeat(64) + cache.addResponse(responseNote("1".repeat(64), voter, option = "yes", createdAt = 10)) + + val tally = cache.currentTally("yes", "0".repeat(64), emptySet()) + assertTrue(tally.contains(voter)) + assertFalse(tally.contains("c".repeat(64))) + } + @Test fun voterWhoseVoteWasIgnoredCanStillVote() { val cache = PollResponsesCache() diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModelTest.kt index 3292ee828d..92f9835a2f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/nip88Polls/PollResultsViewModelTest.kt @@ -254,6 +254,49 @@ class PollResultsViewModelTest { assertEquals(followed, red.topVoters[1].pubkeyHex) } + @Test + fun twoUserInstancesForOnePersonYieldOneRow() = + runTest { + val note = pollNote() + val pubkey = "9".repeat(64) + + // The cache normally hands out one User per pubkey, but an eviction and re-create can + // leave two live instances. Two rows sharing a LazyColumn key is a crash, not a + // cosmetic duplicate — so the row list has to collapse them. + listOf("1", "2").forEachIndexed { i, id -> + val event = + PollResponseEvent( + id = id.repeat(64), + pubKey = pubkey, + createdAt = 150L + i, + tags = arrayOf(arrayOf("e", pollId), arrayOf("response", "red")), + content = "", + sig = "0".repeat(128), + ) + val responseNote = Note(event.id) + // A fresh User object each time, deliberately bypassing the test's user cache. + responseNote.loadEvent(event, User(pubkey) { addr -> Note(addr.toValue()) }, emptyList()) + note.pollState().addResponse(responseNote) + } + + val state = stateOf(viewModel(note)) + + assertEquals(1, state.voters.count { it.user.pubkeyHex == pubkey }) + assertEquals(state.voters.size, state.voters.distinctBy { it.user.pubkeyHex }.size) + } + + @Test + fun aDrawLeavesEveryOptionUncrowned() = + runTest { + val note = pollNote() + vote(note, "1".repeat(64), me, listOf("red")) + vote(note, "2".repeat(64), followed, listOf("blue")) + + val state = stateOf(viewModel(note)) + + assertTrue(state.options.none { it.isWinning }, "a 1-1 poll has no winner to highlight") + } + @Test fun myVoteIsReportedForHighlightingTheOption() = runTest { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopPollCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopPollCard.kt index 6fa5219d06..7a9dc76585 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopPollCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopPollCard.kt @@ -448,7 +448,7 @@ private fun PollResultRow( animated.animateTo(tally.percent) } - val isMyVote = forKey.isNotEmpty() && tally.users.any { it.pubkeyHex == forKey } + val isMyVote = forKey.isNotEmpty() && tally.contains(forKey) val winning = tally.isWinning val barColor = if (winning) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary // Border marks YOUR choice (primary); the winner is conveyed by the bar fill color. @@ -508,7 +508,7 @@ private fun PollResultRow( } Spacer(Modifier.width(12.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - VoterGallery(tally.users, forKey) + VoterGallery(tally.topUsers(4), forKey) Spacer(Modifier.width(8.dp)) Text( text = "${(tally.percent * 100).toInt()}%", diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/CountMerge.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/CountMerge.kt index 076f893589..e2e78a31df 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/CountMerge.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/CountMerge.kt @@ -50,7 +50,14 @@ fun mergeCountResults(results: Collection): CountResult? { val hlls = results.mapNotNull { it.hll } if (hlls.isEmpty()) { - return results.maxByOrNull { it.count } + val best = results.maxByOrNull { it.count } ?: return null + // Approximation is a property of the batch, not of the winner: one relay admitting its + // count is an estimate makes the combined figure an estimate too, whoever reported highest. + return if (best.approximate || results.none { it.approximate }) { + best + } else { + CountResult(count = best.count, approximate = true, hll = null) + } } val merged = HyperLogLog.merge(hlls) From 5896ae5eff843e3ebd9b9c31bc55d37845d58c5a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 3 Aug 2026 19:19:56 -0400 Subject: [PATCH 091/227] fix(relay): stripe the subscription-state lock per relay to end an ANR convoy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A production ANR on a Pixel 8 (Amethyst 1.13.1, anr_2026-08-03-12-55-26-256) showed the app burning 596% CPU — 6 of 9 cores — with the main thread stuck in WaitingForGcToComplete. 37 of 52 runnable DefaultDispatcher workers sat at ONE program point inside PoolRequests.onIncomingMessage and 12 more at one point in syncState, all state=R, while the single thread actually holding the lock was itself parked in GC. Root cause: RequestSubscriptionState.withLock was a raw busy-wait (`while (lock.exchange(true)) { while (lock.load()) {} }`) with no yield or backoff, and being `inline` it disappeared into its callers' frames. The lock is per subId, but one subId spans every relay it runs on — 191 live sockets on that device — so dozens of relay-dispatch threads piled onto a single AtomicBoolean. Spinning is only correct when the holder cannot be descheduled; on Android it always can. The fix stripes the lock per (subId, relay) rather than making waiting cheaper. All 11 withLock bodies in PoolRequests are already scoped to exactly one relay, and every field of RequestSubscriptionState is keyed by relay, so the sharing was purely an artifact of mutableMapOf not being thread-safe. State moves into a ConcurrentMap; locks live in a fixed 32-entry stripe array that is never mutated, so lock identity stays stable — if locks lived inside the map values, a thread holding one while another dropped and re-created that entry would leave both inside the critical section excluding nothing. A suspending Mutex was measured and rejected: it needs 262 method overrides and 110 call sites to become suspend, and ran at 0.35-0.63x the current throughput. Measured (LockDesignComparisonBenchmark, 191 relays / 64 dispatcher threads): striped vs per-sub lock 1.5-2.8x throughput, bystander p50 halved On device (SM-T220, playBenchmark, same account, n=3 per design): DefaultDispatcher CPU -35% mean / -30% median vs the spin lock, with non-overlapping ranges; GC -18% Plus 10 min of driven UI stress (feed, profiles, chat, notifications, communities): no ANRs, no crashes, thread pools stable. Also here: - PlatformLock: new expect/actual parking lock (ReentrantLock on jvmAndroid, NSRecursiveLock on Apple, spin only on linuxX64 which is a CI target). quartz cannot use commons' equivalent KmpLock because commons depends on quartz. - LiveNegentropyIndex had the identical busy-wait with a full list SORT inside the critical section; switched to PlatformLock. - ConcurrentMap.remove (+ tests), with a caution that a removable value must not own a lock callers acquire. - SpinLockConvoyBenchmark: regression guard asserting contended waiters PARK rather than spin (fails-before / passes-after). Pure benchmarks are gated behind -PprodRelayBench=1, so CI cost is 0.3s rather than 51.5s. Analysis and measurements: quartz/plans/2026-08-03-poolrequests-lock-contention.md Co-Authored-By: Claude Opus 5 (1M context) --- ...2026-08-03-poolrequests-lock-contention.md | 221 ++++++++++++ .../utils/concurrent/PlatformLock.apple.kt | 36 ++ .../relay/client/pool/PoolRequests.kt | 55 +-- .../client/reqs/RequestSubscriptionState.kt | 228 ++++++++----- .../nip77Negentropy/LiveNegentropyIndex.kt | 23 +- .../quartz/utils/concurrent/ConcurrentMap.kt | 11 + .../quartz/utils/concurrent/PlatformLock.kt | 75 +++++ .../concurrent/ConcurrentCollectionsTest.kt | 34 ++ .../concurrent/ConcurrentMap.jvmAndroid.kt | 2 + .../concurrent/PlatformLock.jvmAndroid.kt | 35 ++ .../LockDesignComparisonBenchmark.kt | 316 ++++++++++++++++++ .../prodbench/SpinLockConvoyBenchmark.kt | 242 ++++++++++++++ .../utils/concurrent/PlatformLock.linux.kt | 44 +++ .../utils/concurrent/ConcurrentMap.native.kt | 10 + 14 files changed, 1205 insertions(+), 127 deletions(-) create mode 100644 quartz/plans/2026-08-03-poolrequests-lock-contention.md create mode 100644 quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.apple.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.jvmAndroid.kt create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/LockDesignComparisonBenchmark.kt create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/SpinLockConvoyBenchmark.kt create mode 100644 quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.linux.kt diff --git a/quartz/plans/2026-08-03-poolrequests-lock-contention.md b/quartz/plans/2026-08-03-poolrequests-lock-contention.md new file mode 100644 index 0000000000..5c14313e09 --- /dev/null +++ b/quartz/plans/2026-08-03-poolrequests-lock-contention.md @@ -0,0 +1,221 @@ +# PoolRequests subscription-state lock: what a suspending Mutex would cost + +**Date:** 2026-08-03 +**Status:** analysis + measurements; recommendation is NOT to use `Mutex` +**Harness:** `quartz/src/jvmTest/.../prodbench/LockDesignComparisonBenchmark.kt` +(`./gradlew :quartz:jvmTest --tests "*.LockDesignComparisonBenchmark"`) +**Context:** follows the Pixel 8 ANR (`anr_2026-08-03-12-55-26-256`) that replaced +`RequestSubscriptionState`'s busy-wait with a parking `PlatformLock`. + +## The question + +The parking lock removed the CPU burn, but it still **blocks** rather than suspends. +Relay consumers run as coroutines on `Dispatchers.IO` (limitedParallelism 64) and +production has ~191 live relays, so a blocked waiter occupies one of 64 dispatcher +threads. kotlinx's scheduler treats IO tasks as blocking and grows the pool when they +block — which is why the on-device fix moved CPU but left thread count flat (+7%). + +Would `kotlinx.coroutines.sync.Mutex` (a waiter *suspends*, freeing its thread) be +better? + +## What Mutex actually costs + +The lock sits at the bottom of a call chain that is **entirely non-suspend**: + +``` +OkHttpWebSocket: scope.launch { for (m in incomingMessages) out.onMessage(m) } <- coroutine + WebSocketListener.onMessage (non-suspend) + BasicRelayClient.MyWebsocketListener.onMessage + RelayPool.onIncomingMessage + NostrClient.onIncomingMessage (RelayConnectionListener) + PoolRequests.onIncomingMessage + RequestSubscriptionState.withLock <- the lock + SubscriptionListener.onEvent (non-suspend, fans out to the app) +``` + +`Mutex.lock()` is `suspend`, so every frame above it must become `suspend`. Measured +blast radius: + +| interface / API | count | +|---|---| +| `override fun onEvent(` | 59 | +| `override fun onEose(` | 52 | +| `override fun onIncomingMessage(` | 35 | +| `override fun onCannotConnect(` | 32 | +| `override fun onClosed(` | 25 | +| `override fun onDisconnected(` | 17 | +| `override fun onConnected(` | 16 | +| `override fun onSent(` | 14 | +| `override fun onConnecting(` | 11 | +| others (`onSubscriptionStarted`, …) | 1 | +| **total overrides to convert** | **262** | +| `.subscribe(` / `.unsubscribe(` call sites | **110** | + +Worse than the count: the *entry points* are not all coroutines. `INostrClient` is +non-suspend by design — `subscribe`, `unsubscribe`, `publish`, `syncFilters`, +`connect` — and is called from ViewModels, filter assemblers and Compose effects. +`PoolRequests.addOrUpdate` / `remove` / `sendToRelayIfChanged` reach the lock from +those paths. Making them suspend pushes coroutine scoping into every call site that +today just calls `subscribe(...)` synchronously. + +## The cheaper alternative: stripe the lock per relay + +**Enabling invariant (verified by reading all 11 `withLock` bodies):** *every* +critical section in `PoolRequests` is scoped to exactly one relay. Each one takes +`url` / `relay` / `relay.url` as its key: + +| line | body | key | +|---|---|---| +| 204 | `state.connecting(url)` | url | +| 218 | `state.onOpenReq(relay, cmd.filters)` | relay | +| 228 | `state.onSubscriptionClosed(relay)` | relay | +| 249 | `onNewEvent` / `currentState` / `lastKnownFilterStates` | relay.url | +| 267 | `onEose` + `decideCommandLocked` | relay.url | +| 296 | `onClosed` + `recordRefusalIfStructural` + `decideCommandLocked` | relay.url | +| 325 | `state.disconnected(url)` | url | +| 354 | `isStructurallyRefused` + `onOpenReq` | relay | +| 382 | `lastKnownFilterStates(url)` | url | +| 403 | `decideCommandLocked(state, subId, relay)` | relay | + +Every field in `RequestSubscriptionState` is a `Map` keyed by relay +(`subStates`, `filterStates`, `lastKnownFilterStates`, `refusedFilters`, +`refusalCounts`). The single cross-relay accessor, `currentFilters()` (no-arg, +returns the whole map), has **zero usages** — dead code, delete it. + +So the lock is only shared across relays as an artifact of `mutableMapOf` not being +thread-safe. One lock per `(subId, relay)` is semantically equivalent and drops +contention from ~191 threads to ~1–2 (that relay's consumer, plus the occasional app +thread in `sendToRelayIfChanged`). + +Blast radius: `RequestSubscriptionState` + `PoolRequests` only. Used by 4 production +files (`PoolRequests`, `RelayActiveRequestStates`, `RelayReqRefusals`) and 2 tests. +**No public API change.** + +## Measurements + +191 relay coroutines on a 64-thread limited-parallelism dispatcher, 3s windows. Each +iteration yields, modelling the real per-message suspension point of +`for (message in incomingMessages)` — without it the tight loops monopolise the +dispatcher and the harness measures itself, not the lock. + +`bystander` = a task that never touches the lock, on the same dispatcher. Its latency +answers "is the lock stealing dispatcher threads from unrelated work?" + +| subs | design | ops/s | bystander p50 | p99 | +|---|---|---|---|---| +| 1 | PER_SUB_BLOCKING (today) | 548,608 | 196.5µs | 770.9µs | +| 1 | PER_SUB_MUTEX | 190,672 | **1.9µs** | 30.4µs | +| 1 | **STRIPED** | **1,543,037** | 88.8µs | 237.6µs | +| 4 | PER_SUB_BLOCKING | 833,090 | 136.1µs | 507.6µs | +| 4 | PER_SUB_MUTEX | 523,440 | **3.1µs** | 26.0µs | +| 4 | **STRIPED** | **1,499,757** | 92.3µs | 337.5µs | +| 16 | PER_SUB_BLOCKING | 1,198,581 | 94.3µs | 444.3µs | +| 16 | PER_SUB_MUTEX | 749,487 | **5.0µs** | 16.5µs | +| 16 | **STRIPED** | **1,789,509** | 85.7µs | 219.8µs | + +Reading: + +- **STRIPED gives the most throughput** — 2.8× / 1.8× / 1.5× over today — and roughly + halves bystander p50. It removes the contention rather than tolerating it. +- **MUTEX is the slowest of the three** (0.35× / 0.63× / 0.63× of today): per-acquisition + suspend/resume is not free at these rates. +- **MUTEX does win bystander latency decisively** (1.9–5.0µs vs 85–196µs, and it collected + 626k vs 24k samples). But read that honestly — part of the win is that its coroutines + spend their time *suspended waiting for the mutex instead of doing work*. Better + thread-yielding is partly a symptom of lower throughput, not purely a win. + +## On-device result (SM-T220, playBenchmark, same account, n=3 per design) + +Cold-start burst, 75s window, exact per-thread CPU accounting from `/proc//stat`. +Warmups matched (~220-255 established sockets, ~200 relay reader threads each time). + +| design | DefaultDispatcher CPU (ms/75s) mean / median / range | GC ms | threads | +|---|---|---|---| +| spin lock (pre-fix) | 136,743 / 117,750 / 112,230–180,250 | 33,777 | 475 | +| parking, one lock per sub | 103,780 / 99,160 / 86,120–130,680 | 30,235 | 510 | +| **striped, per (sub, relay)** | **88,953 / 82,160 / 72,860–111,840** | **27,687** | 489 | + +- **Striped vs spin: −35% mean, −30% median CPU, −18% GC — and the ranges do not + overlap** (striped max 111,840 < spin min 112,230). That separation is what the + parking-only change could not show; its range overlapped the baseline heavily. +- **Striped vs parking: −14% mean / −17% median**, ranges still overlap — directional, + not conclusive at n=3. +- **Thread count is unchanged across all three** (475 / 510 / 489). Expected: the lock + is blocking, and kotlinx marks IO tasks blocking and grows the pool. Striping reduces + how *often* threads block, not the fact that they can. +- Not a false win from broken subscriptions: the feed rendered live notes 3-15 min old + with avatars and reaction counts, on 223 sockets / 200 relay reader threads. + +## Recommendation + +**Do the striping; do not convert to `Mutex`.** + +Striping attacks the cause — the lock is contended only because it is shared across +relays that touch disjoint keys. Once contention is ~0, the blocking-vs-suspending +question is moot: *a lock that is never contended never blocks a thread*. It also +happens to be the fastest option and is contained to two files, versus 262 overrides +and 110 call sites for a design that measures slower. + +`Mutex` only becomes the right answer if a future critical section must genuinely span +relays (or do I/O), which none does today. + +## The lock must not live inside a removable entry + +The obvious shape — `ConcurrentMap` where `PerRelayState` owns both +the fields *and* its lock — is **wrong as soon as entries can be removed**. +`connecting()` / `disconnected()` genuinely mean "forget this relay's wire state", so +they want removal, and then: + +``` +T1: getOrPut(R) -> stateA ; stateA.lock.lock() // in a critical section +T2: disconnected(R) -> remove(R) // stateA is now orphaned +T3: getOrPut(R) -> stateB (NEW lock) ; stateB.lock.lock() // acquires immediately + -> T1 and T3 are both "in" relay R's critical section, excluding nothing. +``` + +Two ways out: + +- **(A) never remove; null the fields.** Lock identity is stable, but entries + accumulate for every relay a sub has *ever* seen. Bounded but monotonic — and slow + monotonic growth in a long-lived process is precisely the class of bug this whole + investigation was about. +- **(B) stable stripe locks + removable state.** A fixed `Array(N) { PlatformLock() }` + indexed by `relay.hashCode()`, never mutated, so lock identity can't change; the + `ConcurrentMap` entries are then free to be added and removed with + exact `connecting`/`disconnected` semantics and no growth. + +**(B) is the recommendation.** With N = 32 and ~191 relays, ~6 relays share a stripe — +still a ~32x contention reduction versus today's single lock per sub, with none of the +lock-lifetime hazard. `ConcurrentMap.remove` (added 2026-08-03, with tests in +`ConcurrentCollectionsTest`) is what makes (B) possible. + +## Implementation sketch + +1. Delete the dead `currentFilters()` (no-arg). +2. In `RequestSubscriptionState`, replace the five relay-keyed maps with a single + `ConcurrentMap` holding the five fields — **no lock inside**. +3. Add a fixed `private val stripes = Array(32) { PlatformLock() }` and + `withLock(reference)` = `stripes[reference.hashCode().absoluteValue % 32].withLock { }`. + The array is never mutated, so lock identity is stable for the object's life. +4. `connecting()` / `disconnected()` become `map.remove(reference)` — exact current + semantics, no residue. +5. Update the 11 `withLock` call sites in `PoolRequests` to pass the relay. + `decideCommandLocked`'s "MUST hold the lock" contract becomes "MUST hold *that + relay's stripe*" — tighten the kdoc. +6. Extend `PoolRequestsRefusalTest` with concurrent multi-relay access on one subId, + and add a striped variant to `LockDesignComparisonBenchmark` to confirm the + measured win survives stripe collisions. + +## Risks + +- **Stripe collisions serialize unrelated relays.** With 32 stripes and 191 relays this + is ~6-way sharing; it is a contention *reduction*, not elimination. If a future + profile shows it mattering, raise N — it is a one-line change with no semantic effect. +- **Two relays on one stripe must never be locked simultaneously by one thread** — that + would self-deadlock (`PlatformLock` is reentrant on JVM/Apple, so same-thread + re-entry is survivable, but the invariant should be stated). `PoolRequests` already + locks one relay at a time. +- The state machine's atomicity comments are load-bearing; the per-relay scoping must + be re-verified against any new `withLock` body added between now and the change. +- Striping is NOT a fix for a critical section that does I/O or spans relays. If one is + ever added, this analysis must be redone. diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.apple.kt new file mode 100644 index 0000000000..c59d0ccecc --- /dev/null +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.apple.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.concurrent + +import platform.Foundation.NSRecursiveLock + +// NSRecursiveLock parks contended waiters in the kernel, matching the +// ReentrantLock semantics of the jvmAndroid actual (and the same choice +// commons' KmpLock made for iOS). This must NOT be a spin lock: quartz's +// relay client runs here too, and spinning is what produced the Android +// ANR documented on the expect declaration. +actual class PlatformLock { + private val delegate = NSRecursiveLock() + + actual fun lock() = delegate.lock() + + actual fun unlock() = delegate.unlock() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index eb8c220c7d..7d4b08315b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -84,25 +84,26 @@ class PoolRequests( /* * Locking model: every compound access to a subscription's state machine * ([RequestSubscriptionState]) — including the check-then-send decision in - * [decideCommandLocked] — runs inside THAT subscription's own lock - * ([RequestSubscriptionState.withLock]). + * [decideCommandLocked] — runs inside the stripe for THAT (subscription, relay) + * pair ([RequestSubscriptionState.withLock], which takes the relay). * - * A single subscription can span many relays, and each relay's - * socket-reader thread delivers messages into this class concurrently - * while the app thread adds/removes subscriptions — so the plain maps - * inside [RequestSubscriptionState] are written from several threads at - * once. That is both a memory hazard (concurrent map mutation) and a - * logic hazard: two threads must never both observe "no REQ in flight" - * and both send a REQ for the same sub id. + * A single subscription can span many relays, and each relay's socket-reader + * thread delivers messages into this class concurrently while the app thread + * adds/removes subscriptions. Two threads must never both observe "no REQ in + * flight" and both send a REQ for the same (sub, relay). * - * The lock is per subscription, not global, because different subIds - * share no wire state: EVENT frames for different subs coming from - * different relay consumer threads must not serialize on each other (a - * global lock here measured negative scaling under 4 concurrent relay - * feeders). Listener callbacks and the actual socket sends are ALWAYS - * performed outside the lock — they re-enter this class through - * [onSent], so holding the lock across them would self-deadlock, and the - * lock is non-reentrant. Never hold two subscriptions' locks at once. + * The lock is striped PER RELAY rather than per subscription: every critical + * section below touches only one relay's slice of the state, so EVENT frames + * arriving for the same subId from different relays no longer serialize on each + * other. With ~191 relays that previously made a single per-sub lock contended + * ~191 threads deep — the production ANR in + * `quartz/plans/2026-08-03-poolrequests-lock-contention.md`. + * + * Two rules this file must keep: + * - Never hold two relays' stripes (or two subscriptions') at once. Every loop + * below locks exactly one relay at a time. + * - Listener callbacks and socket sends are ALWAYS performed outside the lock — + * they re-enter this class through [onSent]. */ /** @@ -201,7 +202,7 @@ class PoolRequests( fun onConnecting(url: NormalizedRelayUrl) { // Change states to connecting. One sub's lock at a time. relayState.forEach { subId, state -> - state.withLock { state.connecting(url) } + state.withLock(url) { state.connecting(url) } } } @@ -215,7 +216,7 @@ class PoolRequests( when (cmd) { is ReqCmd -> { subState(cmd.subId).let { state -> - state.withLock { state.onOpenReq(relay, cmd.filters) } + state.withLock(relay) { state.onOpenReq(relay, cmd.filters) } } desiredSubListeners.get(cmd.subId)?.onSubscriptionStarted( relay = relay.url, @@ -225,7 +226,7 @@ class PoolRequests( is CloseCmd -> { subState(cmd.subId).let { state -> - state.withLock { state.onSubscriptionClosed(relay) } + state.withLock(relay) { state.onSubscriptionClosed(relay) } } desiredSubListeners.get(cmd.subId)?.onSubscriptionClosed( relay = relay.url, @@ -246,7 +247,7 @@ class PoolRequests( var isLive = false var forFilters: List? = null relayState.get(msg.subId)?.let { state -> - state.withLock { + state.withLock(relay.url) { state.onNewEvent(relay.url) isLive = state.currentState(relay.url) == ReqSubStatus.LIVE forFilters = state.lastKnownFilterStates(relay.url) @@ -264,7 +265,7 @@ class PoolRequests( var forFilters: List? = null val cmd = relayState.get(msg.subId)?.let { state -> - state.withLock { + state.withLock(relay.url) { state.onEose(relay.url) forFilters = state.lastKnownFilterStates(relay.url) // Decide (and pre-mark) the resend while still holding the @@ -293,7 +294,7 @@ class PoolRequests( var forFilters: List? = null val cmd = relayState.get(msg.subId)?.let { state -> - state.withLock { + state.withLock(relay.url) { state.onClosed(relay.url) forFilters = state.lastKnownFilterStates(relay.url) recordRefusalIfStructural(state, relay.url, msg.message, forFilters) @@ -322,7 +323,7 @@ class PoolRequests( */ fun onDisconnected(url: NormalizedRelayUrl) { relayState.forEach { subId, state -> - state.withLock { state.disconnected(url) } + state.withLock(url) { state.disconnected(url) } } } @@ -351,7 +352,7 @@ class PoolRequests( if (!filters.isNullOrEmpty()) { val send = subState(subId).let { state -> - state.withLock { + state.withLock(relay) { if (isStructurallyRefused(state, relay, filters)) { false } else { @@ -379,7 +380,7 @@ class PoolRequests( // These are all my subs.. need to figure out which relays have them val subs = desiredSubs.get(subId) if (subs != null && url in subs.keys) { - toNotify.add(subId to state.withLock { state.lastKnownFilterStates(url) }) + toNotify.add(subId to state.withLock(url) { state.lastKnownFilterStates(url) }) } } @@ -400,7 +401,7 @@ class PoolRequests( val state = subState(subId) relaysToUpdate.forEach { relay -> // Decide + pre-mark atomically under the sub's lock, then send outside it. - val cmd = state.withLock { decideCommandLocked(state, subId, relay) } + val cmd = state.withLock(relay) { decideCommandLocked(state, subId, relay) } if (cmd != null) { sync(relay, cmd) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt index 4bfdd6aa2a..47b417241a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt @@ -21,84 +21,116 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.reqs import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import kotlin.concurrent.atomics.AtomicBoolean -import kotlin.concurrent.atomics.ExperimentalAtomicApi +import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap +import com.vitorpamplona.quartz.utils.concurrent.PlatformLock /** * Manages the State of Subscriptions by logging states as the * subscription progresses. * - * Thread-safety: the plain maps below are only touched inside [withLock]. - * The lock lives HERE — one per subscription — instead of a single global - * lock in PoolRequests, because every mutation is scoped to one subId: - * relays delivering EVENTs for *different* subscriptions have no shared - * state and must not serialize on each other. (A single global spin lock - * measured *negative* scaling: 4 relay consumer threads pushed less - * aggregate throughput through it than 1 — see - * quartz/plans/2026-07-02-nostrclient-receiver-perf.md.) + * **Thread-safety: the lock is striped per reference (per relay), not per + * subscription.** Every operation below is scoped to a single [reference] — all state + * lives in [RelayState] objects held in a [ConcurrentMap] keyed by it — so two relays + * delivering EVENTs for the SAME subscription touch disjoint state and no longer + * serialize on each other. + * + * That mattered in production: one subId spans every relay it is subscribed on, so a + * single per-subscription lock was contended ~191 threads deep on the Pixel 8 that + * produced `anr_2026-08-03-12-55-26-256` (it was a *spin* lock then, which burned 6 of + * 9 cores — see [PlatformLock]). Striping removes the contention instead of making + * waiting cheaper: measured 1.5-2.8x the throughput of one lock per sub in + * `quartz/src/jvmTest/.../prodbench/LockDesignComparisonBenchmark.kt`. Full analysis, + * including why a suspending `Mutex` was rejected, is in + * `quartz/plans/2026-08-03-poolrequests-lock-contention.md`. + * + * The stripe array is allocated once and NEVER mutated, so a stripe's identity is + * stable for this object's whole life. That is load-bearing: if locks lived inside the + * per-relay values, a thread holding one while another thread dropped and re-created + * that entry would leave both "inside" the critical section excluding nothing. + * + * Callers MUST NOT hold two references' stripes at once ([PoolRequests] locks one relay + * at a time, including inside its all-subs iterations), and MUST keep socket sends and + * listener callbacks outside the critical section — they re-enter this class through + * `onSent`, and the point of a lock is to be held briefly. */ -@OptIn(ExperimentalAtomicApi::class) -class RequestSubscriptionState { +class RequestSubscriptionState { /** - * Tiny non-reentrant spin lock (same primitive as BasicRelayClient's - * connecting mutex). Critical sections are a handful of map operations, - * never I/O — callers MUST NOT re-enter and MUST NOT hold two - * subscriptions' locks at once (PoolRequests locks one sub at a time, - * including inside its all-subs iterations). + * One reference's (relay's) slice of this subscription's state. Plain `var`s: every + * field is written and read under that reference's stripe by [PoolRequests]. * - * `@PublishedApi internal` only because [withLock] is inline (this sits - * on the per-EVENT hot path; inlining avoids a closure allocation per - * message) — treat it as private. + * Note `RelayActiveRequestStates` uses this class WITHOUT locking. There the fields + * may be read stale — but the backing [ConcurrentMap] can no longer be structurally + * corrupted the way the plain `HashMap`s this replaced could. + */ + private class RelayState { + /** Null == no REQ state on this relay (fresh, or wiped by connecting/disconnected). */ + var status: ReqSubStatus? = null + + /** Filters of the REQ currently believed to be in flight. */ + var filters: List? = null + + /** + * Survives connect/disconnect so that if new events still arrive we can link + * them with the filters the relay was processing. + */ + var lastKnownFilters: List? = null + + /** + * Refused-filter memory. Unlike [status]/[filters] — per-connection wire state + * wiped by [connecting]/[disconnected] — this SURVIVES reconnects on purpose: a + * relay that structurally refuses a filter (a search-only relay CLOSING a plain + * kinds REQ, a relay that "does not accept REQs", "too many filters", …) refuses + * it again on every new socket, so [PoolRequests.syncState] replaying it each + * reconnect is pure waste. [refusalCount] accumulates repeated refusals of the + * same shape so a one-off (transient) close isn't mistaken for a structural one. + * Cleared on a successful REQ ([onEose]/[onNewEvent]) or when the caller observes + * the desired filter meaningfully changed. + */ + var refusedFilters: List? = null + + var refusalCount: Int = 0 + } + + private val states = ConcurrentMap() + + /** + * Fixed stripe array — allocated once, never mutated, so lock identity is stable. + * [STRIPE_COUNT] stripes over ~191 relays is roughly 6-way sharing: a ~32x + * contention reduction versus one lock per subscription. References colliding on a + * stripe merely serialize; correctness never depends on N. */ @PublishedApi - internal val lock = AtomicBoolean(false) + internal val stripes = Array(STRIPE_COUNT) { PlatformLock() } - inline fun withLock(block: () -> R): R { - while (lock.exchange(true)) { - // Test-and-test-and-set: spin-read until it looks free (cheaper - // on the cache line than hammering exchange), then retry above. - while (lock.load()) { } - } + @PublishedApi + internal fun stripeFor(reference: T): PlatformLock = stripes[(reference.hashCode() and 0x7FFFFFFF) % STRIPE_COUNT] + + /** + * Runs [block] holding [reference]'s stripe. Inline so the per-EVENT hot path + * allocates no closure. + */ + inline fun withLock( + reference: T, + block: () -> R, + ): R { + val lock = stripeFor(reference) + lock.lock() try { return block() } finally { - lock.store(false) + lock.unlock() } } - // Logs the state of each channel to: - // 1. inform when an event is received as live - // 2. to block REQs being sent before finished (receiving an EOSE or Closed) - // - // If 2 happens, the relay might send multiple EOSEs in sequence - // for the same sub and we won't know which REQ was it for. - private val subStates = mutableMapOf() - private val filterStates = mutableMapOf>() + /** Read-only lookup — never creates an entry. */ + private fun peek(reference: T): RelayState? = states[reference] - /** - * This cache is used to make sure we know what the relay was processing - * before a close or disconnect so that if new events still arrive - * we can link them with the appropriate filters. - */ - private val lastKnownFilterStates = mutableMapOf>() + /** Write lookup — creates the entry on first use. */ + private fun mutable(reference: T): RelayState = states.getOrPut(reference) { RelayState() } - /** - * Refused-filter memory. Unlike [subStates]/[filterStates] above — per-connection - * wire state wiped by [connecting]/[disconnected] — this SURVIVES reconnects on - * purpose: a relay that structurally refuses a filter (a search-only relay CLOSING - * a plain kinds REQ, a relay that "does not accept REQs", "too many filters", …) - * refuses it again on every new socket, so [PoolRequests.syncState] replaying it - * each reconnect is pure waste. [refusalCounts] accumulates repeated refusals of - * the same shape so a one-off (transient) close isn't mistaken for a structural - * one. Cleared on a successful REQ ([onEose]/[onNewEvent]) or when the caller - * observes the desired filter meaningfully changed. - */ - private val refusedFilters = mutableMapOf>() - private val refusalCounts = mutableMapOf() + fun refusedFilters(reference: T) = peek(reference)?.refusedFilters - fun refusedFilters(reference: T) = refusedFilters[reference] - - fun refusalCount(reference: T) = refusalCounts[reference] ?: 0 + fun refusalCount(reference: T) = peek(reference)?.refusalCount ?: 0 /** * Records that [reference] refused [filters]. [sameAsLastRefusal] must be true when @@ -111,73 +143,91 @@ class RequestSubscriptionState { filters: List, sameAsLastRefusal: Boolean, ) { + val state = mutable(reference) if (sameAsLastRefusal) { - refusalCounts[reference] = refusalCount(reference) + 1 + state.refusalCount += 1 } else { - refusedFilters[reference] = filters - refusalCounts[reference] = 1 + state.refusedFilters = filters + state.refusalCount = 1 } } fun clearRefusal(reference: T) { - refusedFilters.remove(reference) - refusalCounts.remove(reference) + peek(reference)?.let { + it.refusedFilters = null + it.refusalCount = 0 + } } - fun currentFilters() = filterStates + fun currentFilters(reference: T) = peek(reference)?.filters - fun currentFilters(reference: T) = filterStates[reference] + fun lastKnownFilterStates(reference: T) = peek(reference)?.lastKnownFilters - fun lastKnownFilterStates(reference: T) = lastKnownFilterStates[reference] - - fun currentState(reference: T) = subStates[reference] + fun currentState(reference: T) = peek(reference)?.status fun onNewEvent(reference: T) { + val state = mutable(reference) // The relay is serving this REQ (it matched an event), so any past refusal // no longer applies — let it be tried freely again. - clearRefusal(reference) - if (subStates[reference] == ReqSubStatus.SENT) { - subStates[reference] = ReqSubStatus.QUERYING_PAST + state.refusedFilters = null + state.refusalCount = 0 + if (state.status == ReqSubStatus.SENT) { + state.status = ReqSubStatus.QUERYING_PAST } } fun onEose(reference: T) { + val state = mutable(reference) // Reaching EOSE means the relay accepted and finished the REQ; clear any refusal. - clearRefusal(reference) - subStates[reference] = ReqSubStatus.LIVE + state.refusedFilters = null + state.refusalCount = 0 + state.status = ReqSubStatus.LIVE } fun onClosed(reference: T) { - subStates[reference] = ReqSubStatus.CLOSED - // Closed messages are usually relays refusing to process a REQ - // This message keeps the state of filterStates intact to - // avoid sending the same filter, and getting immediately closed, - // over and over again. - - // filterStates.remove(reference) + // Closed messages are usually relays refusing to process a REQ. This keeps + // [RelayState.filters] intact to avoid sending the same filter, and getting + // immediately closed, over and over again. + mutable(reference).status = ReqSubStatus.CLOSED } fun onOpenReq( reference: T, filters: List, ) { - subStates[reference] = ReqSubStatus.SENT - filterStates[reference] = filters - lastKnownFilterStates[reference] = filters + val state = mutable(reference) + state.status = ReqSubStatus.SENT + state.filters = filters + state.lastKnownFilters = filters } fun onSubscriptionClosed(reference: T) { - subStates[reference] = ReqSubStatus.CLOSED - filterStates.remove(reference) + val state = mutable(reference) + state.status = ReqSubStatus.CLOSED + state.filters = null } fun connecting(reference: T) { - subStates.remove(reference) - filterStates.remove(reference) + // Wipes per-connection wire state only; lastKnownFilters and the refusal memory + // deliberately survive (see [RelayState]). + peek(reference)?.let { + it.status = null + it.filters = null + } } fun disconnected(reference: T) { - subStates.remove(reference) - filterStates.remove(reference) + peek(reference)?.let { + it.status = null + it.filters = null + } + } + + companion object { + /** + * Comfortably above the IO dispatcher's thread count (64 / 2) so collisions stay + * rare even when many workers are inside this class at once. + */ + const val STRIPE_COUNT = 32 } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/LiveNegentropyIndex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/LiveNegentropyIndex.kt index 0e0b134d63..9b39c70776 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/LiveNegentropyIndex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/LiveNegentropyIndex.kt @@ -22,8 +22,7 @@ package com.vitorpamplona.quartz.nip77Negentropy import com.vitorpamplona.negentropy.storage.IStorage import com.vitorpamplona.quartz.nip01Core.store.IdAndTime -import kotlin.concurrent.atomics.AtomicBoolean -import kotlin.concurrent.atomics.ExperimentalAtomicApi +import com.vitorpamplona.quartz.utils.concurrent.PlatformLock /** * Always-current `(created_at, id)` index for NIP-77 negentropy — the @@ -54,13 +53,12 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi * sealed storage, so one snapshot backs any number of concurrent * sessions and stays valid even if the live index mutates after. * - * Thread-safety: all operations take a short spin lock (same pattern as - * `LiveEventStore`'s replay dedup). Mutations arrive from the store's - * single writer; snapshots from any REQ coroutine. + * Thread-safety: all operations take a short parking lock ([PlatformLock]). + * Mutations arrive from the store's single writer; snapshots from any REQ + * coroutine. */ -@OptIn(ExperimentalAtomicApi::class) class LiveNegentropyIndex { - private val lock = AtomicBoolean(false) + private val lock = PlatformLock() /** Sorted by (createdAt, id). Only touched under [locked]. */ private var entries = ArrayList() @@ -74,14 +72,17 @@ class LiveNegentropyIndex { private var cachedSnapshot: IStorage? = null private var cachedGeneration = -1L + // Parks rather than busy-waits. This lock's critical sections are FAR longer than a + // handful of map ops — [rebuild] sorts the whole entry list and snapshotting builds a + // storage — so a spinning waiter would burn a core for the duration of a sort while + // concurrent ingest threads pile up. Same defect that produced the client-side ANR + // documented on PlatformLock; see quartz/.../prodbench/SpinLockConvoyBenchmark.kt. private inline fun locked(block: () -> R): R { - while (lock.exchange(true)) { - while (lock.load()) { } - } + lock.lock() try { return block() } finally { - lock.store(false) + lock.unlock() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.kt index 47d3233c9b..f06d914583 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.kt @@ -61,6 +61,17 @@ expect class ConcurrentMap() { remap: (old: V, new: V) -> V, ): V + /** + * Removes [key] and returns the value it held, or null when absent. + * + * CAUTION: if the removed value owns a lock that callers acquire, removal + * breaks mutual exclusion — a thread holding the old value's lock and a + * thread that re-created the entry are no longer excluding each other. Keep + * such locks in a structure whose identity is stable (see + * `quartz/plans/2026-08-03-poolrequests-lock-contention.md`). + */ + fun remove(key: K): V? + fun size(): Int /** A point-in-time copy of the entries — safe to iterate without holding a lock. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.kt new file mode 100644 index 0000000000..b43f69e5ec --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.concurrent + +/** + * A blocking mutual-exclusion lock whose waiters **park** (yield the core to the + * scheduler) instead of busy-waiting. + * + * Why this exists: `commonMain` has no `java.util.concurrent.locks.Lock`, so the + * relay client previously hand-rolled a spin lock over an `AtomicBoolean`. A + * busy-wait is only ever correct when the holder cannot be descheduled while + * holding the lock — and on Android that assumption is false. A production ANR on + * a Pixel 8 (`anr_2026-08-03-12-55-26-256`, Amethyst 1.13.1) caught the exact + * failure: the thread holding the lock was parked in `WaitingForGcToComplete` + * while **51 of 52** runnable relay-dispatch threads sat in the inlined spin loop, + * burning 596% CPU (6 of the phone's 9 cores) waiting for a holder that could not + * be scheduled to release it. The UI thread, needing to allocate, then waited on + * the same GC for over 5s and Android killed the frame with + * "Input dispatching timed out". + * + * Measured on `SpinLockConvoyBenchmark` (12-core dev machine — a phone is worse): + * the spin lock delivered 138M critical sections/s uncontended but only 1.2M/s + * with 52 contenders (0.86%, a 116x collapse), and an *unrelated* allocating + * thread's p90 latency went from 22µs to 10ms. Parking removes the CPU burn: a + * waiter costs one context switch instead of a whole core. + * + * Splits the same way [ConcurrentMap] does: + * - JVM / Android → `ReentrantLock` (real parking via `AbstractQueuedSynchronizer`). + * - Apple → `NSRecursiveLock`, which also parks. The relay client genuinely runs + * on iOS, so this must not spin — same choice commons' `KmpLock` already made. + * - Linux → a spin, since Kotlin/Native ships no parking lock and there is no + * Foundation; linuxX64 is a build/CI target, not a host for the many-relay + * workload. Swap in a pthread mutex if that changes. + * + * (`commons` has an equivalent `KmpLock`, but `commons` depends on `quartz` and not + * the reverse, so quartz cannot use it — keep the two in sync by hand.) + * + * Unlike the primitive it replaces, the JVM/Android actual is **reentrant**, so an + * accidental re-entry degrades into a no-op rather than a self-deadlock. Callers + * should still keep I/O and listener callbacks outside the critical section — that + * discipline is about holding the lock briefly, not about avoiding a hang. + */ +expect class PlatformLock() { + fun lock() + + fun unlock() +} + +/** Runs [block] holding [this]. Inline so hot paths allocate no closure. */ +inline fun PlatformLock.withLock(block: () -> R): R { + lock() + try { + return block() + } finally { + unlock() + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentCollectionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentCollectionsTest.kt index 208054f818..5f78d107f1 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentCollectionsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentCollectionsTest.kt @@ -71,6 +71,40 @@ class ConcurrentCollectionsTest { assertEquals(4, m["k"]) } + @Test + fun mapRemoveReturnsOldValueAndDeletes() { + val map = ConcurrentMap() + map["a"] = 1 + map["b"] = 2 + + assertEquals(1, map.remove("a")) + assertNull(map["a"]) + assertEquals(1, map.size()) + assertEquals(2, map["b"]) + } + + @Test + fun mapRemoveAbsentKeyIsNullAndNoOp() { + val map = ConcurrentMap() + map["b"] = 2 + + assertNull(map.remove("missing")) + assertEquals(1, map.size()) + assertEquals(2, map["b"]) + } + + @Test + fun mapRemoveThenGetOrPutRecreates() { + // The lifecycle PoolRequests needs: connecting()/disconnected() drop a relay's + // wire state, and the next REQ re-creates it. A stale value must never survive. + val map = ConcurrentMap() + map.getOrPut("relay") { 1 } + map.remove("relay") + + assertEquals(9, map.getOrPut("relay") { 9 }) + assertEquals(9, map["relay"]) + } + @Test fun mapSnapshotIsDetached() { val m = ConcurrentMap() diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.jvmAndroid.kt index aaf40fdcb7..c68eb8da8a 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.jvmAndroid.kt @@ -49,6 +49,8 @@ actual class ConcurrentMap { remap: (old: V, new: V) -> V, ): V = map.merge(key, value) { old, new -> remap(old, new) }!! + actual fun remove(key: K): V? = map.remove(key) + actual fun size(): Int = map.size actual fun snapshot(): Map = HashMap(map) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.jvmAndroid.kt new file mode 100644 index 0000000000..58488489b6 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.jvmAndroid.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.concurrent + +import java.util.concurrent.locks.ReentrantLock + +// ReentrantLock parks contended waiters through AbstractQueuedSynchronizer, so a +// thread waiting on a holder that lost its core (GC, preemption) costs one context +// switch rather than a spinning core. Non-fair on purpose: fairness would add a +// handoff per acquisition and the critical sections here are microseconds long. +actual class PlatformLock { + private val lock = ReentrantLock() + + actual fun lock() = lock.lock() + + actual fun unlock() = lock.unlock() +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/LockDesignComparisonBenchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/LockDesignComparisonBenchmark.kt new file mode 100644 index 0000000000..48e75ec97d --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/LockDesignComparisonBenchmark.kt @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.prodbench + +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.RequestSubscriptionState +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.junit.Test +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.locks.ReentrantLock + +/** + * Compares the three candidate designs for `PoolRequests`' subscription-state lock, + * under the real production topology: R relay-consumer coroutines on a + * limited-parallelism dispatcher (`Dispatchers.IO` = 64) all delivering EVENTs for a + * small number of subscription ids. + * + * - **PER_SUB_BLOCKING** — today: one blocking lock per subId, shared by every relay + * that sub runs on. Waiters park, which fixed the CPU burn, but a parked waiter + * still OCCUPIES its dispatcher thread. + * - **PER_SUB_MUTEX** — kotlinx `Mutex`: a waiter *suspends* and releases its thread. + * Requires making the whole listener chain `suspend` (262 overrides, 110 call sites). + * - **STRIPED** — one lock per (subId, relay). Every critical section in PoolRequests + * is already scoped to a single relay, so this removes the contention outright and + * needs no API change. + * + * The metric that matters is NOT lock throughput — it is whether unrelated work can + * still get a thread while the lock is contended. `bystanderLatency` models that: a + * task that never touches the lock, submitted to the same dispatcher. + */ +class LockDesignComparisonBenchmark { + private val relays = 191 + private val dispatcherThreads = 64 + private val durationMs = 3000L + + /** One relay's slice of a subscription's state — all real fields are relay-keyed. */ + private class PerRelay { + val lock = ReentrantLock() + var status: Int = 0 + var filters: List? = null + var lastKnown: List? = null + } + + private class Striped { + val perRelay = ConcurrentHashMap() + + inline fun withLock( + relay: Int, + block: (PerRelay) -> R, + ): R { + val s = perRelay.computeIfAbsent(relay) { PerRelay() } + s.lock.lock() + try { + return block(s) + } finally { + s.lock.unlock() + } + } + } + + private class PerSubBlocking { + val lock = ReentrantLock() + val status = HashMap() + val filters = HashMap>() + } + + private class PerSubMutex { + val mutex = Mutex() + val status = HashMap() + val filters = HashMap>() + } + + private fun report( + name: String, + subs: Int, + ops: Long, + bystanderSamples: List, + ) { + val sorted = bystanderSamples.sorted() + val p50 = sorted[sorted.size / 2] / 1000.0 + val p99 = sorted[(sorted.size * 99) / 100] / 1000.0 + val max = sorted.last() / 1000.0 + println( + "%-18s subs=%-3d ops/s=%,10d bystander p50=%8.1fus p99=%9.1fus max=%9.1fus (n=%d)".format( + name, + subs, + ops * 1000 / durationMs, + p50, + p99, + max, + sorted.size, + ), + ) + } + + @Test + fun compareDesigns() { + if (System.getenv("PROD_RELAY_BENCH") == null && System.getProperty("prodRelayBench") == null) { + println("compareDesigns skipped. Run with -PprodRelayBench=1 to enable.") + return + } + println("relays=$relays dispatcherThreads=$dispatcherThreads window=${durationMs}ms") + println("bystander = a task that NEVER touches the lock, on the same dispatcher.") + println("Lower bystander latency = the lock is not stealing dispatcher threads.\n") + for (subs in listOf(1, 4, 16)) { + runPerSubBlocking(subs) + runPerSubMutex(subs) + runStriped(subs) + runRealStriped(subs) + println() + } + } + + private fun runPerSubBlocking(subs: Int) = + runBlocking { + @Suppress("DEPRECATION") + val dispatcher = Dispatchers.IO.limitedParallelism(dispatcherThreads) + val states = Array(subs) { PerSubBlocking() } + val stop = AtomicBoolean(false) + val ops = AtomicLong(0) + val bystander = ArrayList() + val jobs = + (0 until relays).map { relay -> + launch(dispatcher) { + var n = 0L + while (!stop.get()) { + val s = states[relay % subs] + s.lock.lock() + try { + s.status[relay] = 1 + s.filters[relay] = SAMPLE + s.status[relay] + } finally { + s.lock.unlock() + } + n++ + // Models `for (message in incomingMessages)`: every real + // iteration suspends, letting the dispatcher multiplex. + kotlinx.coroutines.yield() + } + ops.addAndGet(n) + } + } + val by = + launch(dispatcher) { + while (!stop.get()) { + val t = System.nanoTime() + kotlinx.coroutines.yield() + bystander.add(System.nanoTime() - t) + } + } + Thread.sleep(durationMs) + stop.set(true) + jobs.forEach { it.join() } + by.join() + report("PER_SUB_BLOCKING", subs, ops.get(), bystander.ifEmpty { listOf(0L) }) + } + + private fun runPerSubMutex(subs: Int) = + runBlocking { + @Suppress("DEPRECATION") + val dispatcher = Dispatchers.IO.limitedParallelism(dispatcherThreads) + val states = Array(subs) { PerSubMutex() } + val stop = AtomicBoolean(false) + val ops = AtomicLong(0) + val bystander = ArrayList() + val jobs = + (0 until relays).map { relay -> + launch(dispatcher) { + var n = 0L + while (!stop.get()) { + val s = states[relay % subs] + s.mutex.withLock { + s.status[relay] = 1 + s.filters[relay] = SAMPLE + s.status[relay] + } + n++ + // Models `for (message in incomingMessages)`: every real + // iteration suspends, letting the dispatcher multiplex. + kotlinx.coroutines.yield() + } + ops.addAndGet(n) + } + } + val by = + launch(dispatcher) { + while (!stop.get()) { + val t = System.nanoTime() + kotlinx.coroutines.yield() + bystander.add(System.nanoTime() - t) + } + } + Thread.sleep(durationMs) + stop.set(true) + jobs.forEach { it.join() } + by.join() + report("PER_SUB_MUTEX", subs, ops.get(), bystander.ifEmpty { listOf(0L) }) + } + + private fun runStriped(subs: Int) = + runBlocking { + @Suppress("DEPRECATION") + val dispatcher = Dispatchers.IO.limitedParallelism(dispatcherThreads) + val states = Array(subs) { Striped() } + val stop = AtomicBoolean(false) + val ops = AtomicLong(0) + val bystander = ArrayList() + val jobs = + (0 until relays).map { relay -> + launch(dispatcher) { + var n = 0L + while (!stop.get()) { + states[relay % subs].withLock(relay) { s -> + s.status = 1 + s.filters = SAMPLE + s.lastKnown = SAMPLE + } + n++ + // Models `for (message in incomingMessages)`: every real + // iteration suspends, letting the dispatcher multiplex. + kotlinx.coroutines.yield() + } + ops.addAndGet(n) + } + } + val by = + launch(dispatcher) { + while (!stop.get()) { + val t = System.nanoTime() + kotlinx.coroutines.yield() + bystander.add(System.nanoTime() - t) + } + } + Thread.sleep(durationMs) + stop.set(true) + jobs.forEach { it.join() } + by.join() + report("STRIPED", subs, ops.get(), bystander.ifEmpty { listOf(0L) }) + } + + /** + * Same topology, but driving the REAL shipped [RequestSubscriptionState] (striped per + * relay) instead of a prototype — so the measured win is a property of the code that + * ships, not of this file. + */ + private fun runRealStriped(subs: Int) = + runBlocking { + @Suppress("DEPRECATION") + val dispatcher = Dispatchers.IO.limitedParallelism(dispatcherThreads) + val states = Array(subs) { RequestSubscriptionState() } + val stop = AtomicBoolean(false) + val ops = AtomicLong(0) + val bystander = ArrayList() + val filters = listOf(Filter(kinds = listOf(1))) + val jobs = + (0 until relays).map { relay -> + launch(dispatcher) { + var n = 0L + val state = states[relay % subs] + while (!stop.get()) { + state.withLock(relay) { + state.onNewEvent(relay) + state.currentState(relay) + state.onOpenReq(relay, filters) + state.lastKnownFilterStates(relay) + } + n++ + kotlinx.coroutines.yield() + } + ops.addAndGet(n) + } + } + val by = + launch(dispatcher) { + while (!stop.get()) { + val t = System.nanoTime() + kotlinx.coroutines.yield() + bystander.add(System.nanoTime() - t) + } + } + Thread.sleep(durationMs) + stop.set(true) + jobs.forEach { it.join() } + by.join() + report("REAL_STRIPED", subs, ops.get(), bystander.ifEmpty { listOf(0L) }) + } + + companion object { + private val SAMPLE = listOf("kinds:1", "authors:abc") + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/SpinLockConvoyBenchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/SpinLockConvoyBenchmark.kt new file mode 100644 index 0000000000..c8b17e6d85 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/SpinLockConvoyBenchmark.kt @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.prodbench + +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.RequestSubscriptionState +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import kotlin.concurrent.thread + +/** + * Reproduces the production ANR seen on a Pixel 8 (2026-08-03, anr_2026-08-03-12-55-26-256): + * 191 live relay sockets feed EVENT frames for the same handful of subscription ids, so + * dozens of DefaultDispatcher workers pile onto ONE [RequestSubscriptionState] busy-wait + * lock. In that trace 51 of 52 runnable workers sat in the inlined spin loop while the + * single lock holder was parked in `WaitingForGcToComplete`. + * + * Two things are measured: + * - [contendedThroughput]: aggregate critical sections/s as the contender count grows past + * the core count (the "negative scaling" the 2026-07-02 plan measured with 4 feeders, + * re-run at production fan-out). + * - [victimLatencyUnderSpin]: what an UNRELATED thread (stand-in for the UI thread) sees + * while the spinners run — this is the ANR mechanism, not the lock throughput. + */ +class SpinLockConvoyBenchmark { + private val cores = Runtime.getRuntime().availableProcessors() + + /** + * Every waiter targets ONE reference on purpose. The lock is striped per relay, so + * spreading threads over distinct relays would put them on different stripes and + * this benchmark would measure nothing — the point here is the primitive's behaviour + * when contention DOES land on a single stripe. + */ + private val hotRelay = 0 + + /** Mirrors the real critical section: a handful of map reads/writes, no I/O. */ + private fun criticalSection( + state: RequestSubscriptionState, + relay: Int, + ) { + state.onNewEvent(relay) + state.currentState(relay) + state.lastKnownFilterStates(relay) + } + + @Test + fun contendedThroughput() { + if (System.getenv("PROD_RELAY_BENCH") == null && System.getProperty("prodRelayBench") == null) { + println("contendedThroughput skipped. Run with -PprodRelayBench=1 to enable.") + return + } + println("cores = $cores") + println("threads | ops/s | vs 1 thread") + var baseline = 0.0 + for (threads in listOf(1, 2, 4, 8, 16, 32, 52)) { + val state = RequestSubscriptionState() + val stop = AtomicBoolean(false) + val ops = AtomicLong(0) + val start = CountDownLatch(1) + val workers = + (0 until threads).map { id -> + thread { + start.await() + var local = 0L + while (!stop.get()) { + state.withLock(hotRelay) { criticalSection(state, hotRelay) } + local++ + } + ops.addAndGet(local) + } + } + val t0 = System.nanoTime() + start.countDown() + Thread.sleep(2000) + stop.set(true) + workers.forEach { it.join() } + val secs = (System.nanoTime() - t0) / 1e9 + val rate = ops.get() / secs + if (threads == 1) baseline = rate + println("%7d | %9.0f | %.2fx".format(threads, rate, rate / baseline)) + } + } + + /** + * REGRESSION GUARD: contended waiters on [RequestSubscriptionState.withLock] must PARK, + * never busy-wait. Fails if the lock is ever turned back into a spin lock. + * + * This is the signature that identified the production ANR: in + * `anr_2026-08-03-12-55-26-256`, 37 of 52 runnable workers sat at one obfuscated line of + * `PoolRequests.onIncomingMessage` and 12 more at one line of `syncState$lambda$0` — all + * `state=R`, `sCount=0`, burning 596% CPU while the lock holder was stuck in + * `WaitingForGcToComplete`. With a spin lock this test observes ~40/40 waiters RUNNABLE; + * with a parking lock it observes 0. + */ + @Test + fun contendedWaitersParkInsteadOfSpinning() { + val spinners = 40 + val state = RequestSubscriptionState() + val stop = AtomicBoolean(false) + val start = CountDownLatch(1) + + val holder = + thread(name = "holder") { + start.await() + while (!stop.get()) { + state.withLock(hotRelay) { + val t = System.nanoTime() + while (System.nanoTime() - t < 5_000_000) { /* hold 5ms */ } + } + } + } + val threads = + (0 until spinners).map { id -> + thread(name = "spinner-$id") { + start.await() + while (!stop.get()) { + state.withLock(hotRelay) { criticalSection(state, hotRelay) } + } + } + } + start.countDown() + Thread.sleep(300) + + // Sample every spinner's stack, exactly like an ANR dump would. + val points = HashMap() + var runnable = 0 + threads.forEach { t -> + if (t.state == Thread.State.RUNNABLE) runnable++ + val top = t.stackTrace.firstOrNull { it.className.contains("SpinLockConvoyBenchmark") || it.className.contains("RequestSubscriptionState") } + if (top != null) { + val key = "${top.className.substringAfterLast('.')}.${top.methodName}:${top.lineNumber}" + points[key] = (points[key] ?: 0) + 1 + } + } + stop.set(true) + threads.forEach { it.join() } + holder.join() + + println("sampled $spinners waiters: RUNNABLE=$runnable, distinct program points=${points.size}") + points.entries.sortedByDescending { it.value }.forEach { println(" ${it.value}x ${it.key}") } + + // A parked waiter reports WAITING, so the parking lock measures ~0 here while a + // spin lock measures ~100%. The line sits at 50% deliberately: it separates the + // two cases by a mile and leaves headroom on a loaded CI box, where a few waiters + // can legitimately be mid-acquire when we sample. + assertTrue( + "Expected contended waiters to park, but $runnable/$spinners were RUNNABLE — " + + "RequestSubscriptionState.withLock looks like it is busy-waiting again. " + + "See PlatformLock's kdoc: this is what caused anr_2026-08-03-12-55-26-256.", + runnable <= spinners / 2, + ) + } + + @Test + fun victimLatencyUnderSpin() { + if (System.getenv("PROD_RELAY_BENCH") == null && System.getProperty("prodRelayBench") == null) { + println("victimLatencyUnderSpin skipped. Run with -PprodRelayBench=1 to enable.") + return + } + // One holder that briefly stalls inside the critical section (in production: a GC + // pause, or simply being descheduled). Everyone else spins. + val spinners = 52 + val state = RequestSubscriptionState() + val stop = AtomicBoolean(false) + + fun measureVictim(label: String) { + // The "UI thread": allocates and measures its own scheduling latency. + val samples = ArrayList() + repeat(200) { + val t = System.nanoTime() + // trivial allocation work, like a Compose semantics traversal step + val junk = ArrayList(64) + repeat(64) { i -> junk.add("node$i") } + Thread.yield() + samples.add(System.nanoTime() - t) + } + samples.sort() + println( + "%-22s p50=%6.0fus p90=%7.0fus p99=%8.0fus max=%8.0fus".format( + label, + samples[samples.size / 2] / 1000.0, + samples[(samples.size * 90) / 100] / 1000.0, + samples[(samples.size * 99) / 100] / 1000.0, + samples.last() / 1000.0, + ), + ) + } + + measureVictim("idle (no spinners)") + + val start = CountDownLatch(1) + val holder = + thread { + start.await() + while (!stop.get()) { + state.withLock(hotRelay) { + // Simulate the holder losing its core / waiting on GC mid-section. + val t = System.nanoTime() + while (System.nanoTime() - t < 2_000_000) { /* 2ms stall */ } + } + Thread.sleep(1) + } + } + val threads = + (0 until spinners).map { id -> + thread { + start.await() + while (!stop.get()) { + state.withLock(hotRelay) { criticalSection(state, hotRelay) } + } + } + } + start.countDown() + Thread.sleep(500) + measureVictim("$spinners spinners") + stop.set(true) + threads.forEach { it.join() } + holder.join() + + measureVictim("after (no spinners)") + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.linux.kt new file mode 100644 index 0000000000..5f3c4fdf46 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/PlatformLock.linux.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.concurrent + +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +// Linux/JVM-less target: Kotlin/Native's stdlib ships no parking lock and there is +// no Foundation here, so this keeps a test-and-test-and-set spin. Correct but not +// scalable. Acceptable ONLY because linuxX64 is a build/CI target for quartz, not a +// host for the many-relay client workload whose contention motivated the parking +// actuals on jvmAndroid and Apple. If that ever changes, swap in a pthread mutex. +@OptIn(ExperimentalAtomicApi::class) +actual class PlatformLock { + private val held = AtomicBoolean(false) + + actual fun lock() { + while (held.exchange(true)) { + while (held.load()) { } + } + } + + actual fun unlock() { + held.store(false) + } +} diff --git a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.native.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.native.kt index de4ee540d8..8805e8d5a7 100644 --- a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.native.kt +++ b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.native.kt @@ -74,6 +74,16 @@ actual class ConcurrentMap { } } + actual fun remove(key: K): V? { + while (true) { + val cur = ref.load() + val old = cur[key] ?: return null + val copy = HashMap(cur) + copy.remove(key) + if (ref.compareAndSet(cur, copy)) return old + } + } + actual fun size(): Int = ref.load().size actual fun snapshot(): Map = HashMap(ref.load()) From a110ce0a30e797145fb100de2ff81c630ff4e708 Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Tue, 4 Aug 2026 10:51:16 +1000 Subject: [PATCH 092/227] ci: publish linux-arm64 desktop, amy, and geode release assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amethyst v1.13.1 (and every prior release) shipped only linux-x64 desktop binaries — .deb, .rpm, .AppImage, .flatpak, .tar.gz. Same for the amy CLI and geode relay. Users on aarch64 hardware (Pinebook, Ampere Altra, Raspberry Pi 4/5, AWS Graviton, arm64 servers, arm64 Chromebooks running crostini, etc.) can't install any of them. This teaches the release matrix about arm64: - Add `ubuntu-24.04-arm` legs to build-desktop, build-cli, and build-geode. This is a standard, free public-repo GitHub-hosted runner (4 CPU / 16 GB / 14 GB SSD / arm64) since early 2025. No cross-compilation: jpackage / jlink / Compose Multiplatform 1.11 all produce host-native artifacts. - Fetch the matching `appimagetool-.AppImage` from the same 1.9.0 release with an arch-specific SHA256 pin. `APPIMAGETOOL_URL` becomes `APPIMAGETOOL_VERSION` + per-arch SHA256 env vars. - Parametrize the portable tarball/zip filename by `matrix.arch` (`amethyst-desktop--linux-arm64.tar.gz` is now produced). - Parametrize the Flatpak bundle filename and rewrite the manifest's `GST_PLUGIN_SYSTEM_PATH` from `x86_64-linux-gnu` to `aarch64-linux-gnu` on the arm64 leg. The Flathub-submission manifest (`desktopApp/packaging/flatpak/flathub/`) still gates on `only-arches: x86_64` — flipping that to include aarch64 is a follow-up once a Flathub aarch64 build has been validated end-to-end. - Make the `createReleaseAppImage` gradle task pick its host arch from `System.getProperty("os.arch")` (amd64/x86_64 → `x86_64`, aarch64/ arm64 → `aarch64`). Same task, same command, drives both legs. - Fix `desktopApp/packaging/appimage/AppRun` to compute the multiarch library path from `uname -m` at launch time instead of hard-coding `x86_64-linux-gnu`. One script works in both AppImages on the target machine. - Extend the desktop smoke test to run the release .deb build + launch probe on `ubuntu-24.04-arm` too, so arch-specific ProGuard/jlink breakage (missing native lib, arch-specific reflection root) is caught at PR time. - Update BUILDING.md and scripts/asset-name.sh docs with the new arm64 asset names. Follow-up assets published for the next tag push (v1.13.2+): - amethyst-desktop--linux-arm64.{deb,rpm,AppImage,flatpak,tar.gz} - amy--linux-arm64.{deb,rpm,tar.gz} - geode--linux-arm64.{deb,rpm,tar.gz} Verification (local, before submitting): - `python3 -c 'import yaml; yaml.safe_load(open(".github/workflows/create-release.yml"))'` — parses clean - `bash -n scripts/asset-name.sh desktopApp/packaging/appimage/AppRun` — parses clean - `actionlint` — reports only pre-existing shellcheck style hints; no new errors - Confirmed `linuxdeploy-aarch64.AppImage` and `appimagetool-aarch64.AppImage` exist under the same pinned release tags used for x86_64; SHA256 recorded from a fresh download. Not addressed (out of scope for this PR): - Homebrew / winget bump workflows (`bump-homebrew*.yml`, `bump-winget.yml`) — those consume the assets by name; the new arm64 filenames don't change any x86_64 name they already reference. - Android arm64 continues to ship as before (already had it). --- .github/workflows/create-release.yml | 68 +++++++++++++++++------- .github/workflows/smoke-test-desktop.yml | 13 ++++- BUILDING.md | 13 +++-- desktopApp/build.gradle.kts | 20 +++++-- desktopApp/packaging/appimage/AppRun | 5 +- desktopApp/packaging/flatpak/README.md | 8 ++- scripts/asset-name.sh | 11 ++++ 7 files changed, 106 insertions(+), 32 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index ce5d002955..f87c0556f4 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -26,8 +26,12 @@ env: # bundle deps — that fights jpackage's self-contained JRE (libjvm.so has # $ORIGIN RPATH so ldd can't resolve it standalone). appimagetool only # embeds the AppDir as-is, which is what we actually want. - APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage - APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1 + # + # Both arch binaries come from the same appimagetool release so their SHA256 + # values move in lockstep on version bumps. + APPIMAGETOOL_VERSION: '1.9.0' + APPIMAGETOOL_SHA256_X86_64: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1 + APPIMAGETOOL_SHA256_AARCH64: 04f45ea45b5aa07bb2b071aed9dbf7a5185d3953b11b47358c1311f11ea94a96 jobs: # --------------------------------------------------------------------------- @@ -38,11 +42,17 @@ jobs: strategy: fail-fast: false matrix: + # Linux legs run on x64 and arm64 GitHub-hosted runners (the + # ubuntu-24.04-arm label is a standard free public-repo runner as of + # early 2025). jpackage / jlink / Compose Multiplatform 1.11 all + # produce host-native artifacts — no cross-compilation needed. include: - - { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" } - - { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" } - - { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } - - { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" } + - { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" } + - { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } + - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } + - { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" } + - { os: ubuntu-24.04-arm, arch: arm64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" } runs-on: ${{ matrix.os }} timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle defaults: @@ -93,13 +103,21 @@ jobs: set -euo pipefail # appimagetool 1.9.0 validates the .desktop file via desktop-file-validate. sudo apt-get update && sudo apt-get install -y desktop-file-utils - curl -fsSL --retry 3 "$APPIMAGETOOL_URL" -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage - actual=$(sha256sum desktopApp/packaging/appimage/appimagetool-x86_64.AppImage | awk '{print $1}') - if [[ "$actual" != "$APPIMAGETOOL_SHA256" ]]; then - echo "::error::appimagetool SHA256 mismatch. Expected $APPIMAGETOOL_SHA256, got $actual" + # Map runner arch → upstream AppImage suffix (x86_64 / aarch64). + case "${{ matrix.arch }}" in + x64) TOOL_ARCH=x86_64 ; EXPECTED_SHA="$APPIMAGETOOL_SHA256_X86_64" ;; + arm64) TOOL_ARCH=aarch64; EXPECTED_SHA="$APPIMAGETOOL_SHA256_AARCH64" ;; + *) echo "::error::unsupported arch for AppImage: ${{ matrix.arch }}"; exit 1 ;; + esac + URL="https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-${TOOL_ARCH}.AppImage" + DEST="desktopApp/packaging/appimage/appimagetool-${TOOL_ARCH}.AppImage" + curl -fsSL --retry 3 "$URL" -o "$DEST" + actual=$(sha256sum "$DEST" | awk '{print $1}') + if [[ "$actual" != "$EXPECTED_SHA" ]]; then + echo "::error::appimagetool SHA256 mismatch for $TOOL_ARCH. Expected $EXPECTED_SHA, got $actual" exit 1 fi - chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage + chmod +x "$DEST" # Flatpak tooling + the freedesktop runtime/sdk the manifest pins # (runtime-version is greped from the manifest so this never drifts). @@ -208,12 +226,13 @@ jobs: run: | set -euo pipefail VER="${{ steps.ver.outputs.version }}" + ARCH="${{ matrix.arch }}" APP="desktopApp/build/compose/binaries/main-release/app" mkdir -p desktopApp/build/portable if [[ "${{ matrix.family }}" == "windows" ]]; then - ( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-x64.zip" Amethyst/ ) + ( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-${ARCH}.zip" Amethyst/ ) else - ( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ ) + ( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-${ARCH}.tar.gz" Amethyst/ ) fi # Flatpak bundle: wraps the same createReleaseDistributable tree the @@ -230,6 +249,17 @@ jobs: PKG="desktopApp/packaging/flatpak" APP_ID="com.vitorpamplona.amethyst.Desktop" OUT="desktopApp/build/flatpak" + # AppImage-style arch names for the bundle filename. + case "${{ matrix.arch }}" in + x64) BUNDLE_ARCH=x86_64 ; GST_TRIPLET=x86_64-linux-gnu ;; + arm64) BUNDLE_ARCH=aarch64 ; GST_TRIPLET=aarch64-linux-gnu ;; + *) echo "::error::unsupported arch for Flatpak: ${{ matrix.arch }}"; exit 1 ;; + esac + # Rewrite the arch-specific GStreamer plugin path in the manifest + # (checked-in default is x86_64-linux-gnu). Idempotent — the sed only + # matches the original triplet. + sed -i "s|/usr/lib/x86_64-linux-gnu/gstreamer-1.0|/usr/lib/${GST_TRIPLET}/gstreamer-1.0|g" \ + "${PKG}/${APP_ID}.yml" # Inject the AppStream entry for this build (the checked-in # metainfo deliberately carries none — CI is the source of truth). sed -i "s||\n |" \ @@ -241,7 +271,7 @@ jobs: "${OUT}/build-dir" \ "${PKG}/${APP_ID}.yml" flatpak build-bundle "${OUT}/repo" \ - "${OUT}/Amethyst-${VER}-x86_64.flatpak" \ + "${OUT}/Amethyst-${VER}-${BUNDLE_ARCH}.flatpak" \ "$APP_ID" \ --runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo ls -la "$OUT" @@ -325,8 +355,9 @@ jobs: fail-fast: false matrix: include: - - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } - - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } runs-on: ${{ matrix.os }} timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image defaults: @@ -574,8 +605,9 @@ jobs: fail-fast: false matrix: include: - - { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" } - - { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } + - { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } + - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } runs-on: ${{ matrix.os }} timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image defaults: diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index 3dafc575dd..6d8159ab0e 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -49,9 +49,17 @@ jobs: # package, installs it, and verifies the process stays alive for 10s. # Catches ProGuard stripping (JNI, reflection), missing jlink modules # (java.management, java.prefs), and native lib bundling issues. + # + # Runs on both x64 and arm64 hosted runners so release-time arm64 breakage + # (e.g. ProGuard rules missing an arch-specific reflection root) is caught + # at PR time instead of on the tag build. # ------------------------------------------------------------------------- release-deb-launch: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} timeout-minutes: 45 steps: - name: Checkout code @@ -139,5 +147,6 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: Release DEB (smoke-tested) + # Artifact names must be unique across a run — disambiguate per arch. + name: Release DEB (smoke-tested, ${{ matrix.os }}) path: desktopApp/build/compose/binaries/main-release/deb/*.deb diff --git a/BUILDING.md b/BUILDING.md index 5a04173603..b931926c00 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -57,9 +57,12 @@ Install appimagetool locally (CI fetches its own — SHA-verified): # Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry sudo apt-get install -y desktop-file-utils -curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \ - https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage -chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage +# createReleaseAppImage picks appimagetool-.AppImage matching the JVM's +# os.arch — fetch the one for your host (x86_64 on Intel/AMD, aarch64 on ARM). +ARCH="$(uname -m)" +curl -fsSL -o "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage" \ + "https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-${ARCH}.AppImage" +chmod +x "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage" ``` --- @@ -110,8 +113,8 @@ are **not** required to build Amethyst from the committed sources. | Windows MSI | `./gradlew :desktopApp:packageReleaseMsi` | `desktopApp/build/compose/binaries/main-release/msi/Amethyst-*.msi` | | Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` | | Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` | -| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` | -| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) | +| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-.AppImage` (x86_64 or aarch64, from host) | +| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-.flatpak` (CI; x86_64 or aarch64) | | Windows `.zip` portable | See below (inline `7z`) | — | | Linux `.tar.gz` portable | See below (inline `tar`) | — | diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 10ca2b3d48..fa41a3e276 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -246,18 +246,30 @@ compose.desktop { // - amethyst.png 512x512 icon // // appimagetool binary is fetched by CI (SHA-verified) into -// desktopApp/packaging/appimage/ as appimagetool-x86_64.AppImage. +// desktopApp/packaging/appimage/ as appimagetool-.AppImage. +// The arch is selected at task-execution time from the host JVM's os.arch, so +// the same task builds the correct AppImage on both x86_64 and aarch64 hosts. // BUILDING.md documents local-dev fetch. val createReleaseAppImage by tasks.registering(Exec::class) { group = "compose desktop" description = "Package createReleaseDistributable output into a Linux AppImage via appimagetool." dependsOn("createReleaseDistributable") + // AppImage's ARCH env accepts the Linux kernel arch names: x86_64 / aarch64 + // / armhf / i686. jpackage produces host-native binaries, so mirror the + // host JVM arch. Do not read the property inside doFirst — it needs to be + // resolved at configuration time so outputs.file() below is stable. + val hostArch = when (val a = System.getProperty("os.arch").lowercase()) { + "amd64", "x86_64" -> "x86_64" + "aarch64", "arm64" -> "aarch64" + else -> a + } + val distDir = layout.buildDirectory.dir("compose/binaries/main-release/app/Amethyst") val appDir = layout.buildDirectory.dir("appimage/Amethyst.AppDir") - val outFile = layout.buildDirectory.file("appimage/Amethyst-$appVersion-x86_64.AppImage") + val outFile = layout.buildDirectory.file("appimage/Amethyst-$appVersion-$hostArch.AppImage") val toolRoot = layout.projectDirectory.dir("packaging/appimage") - val appimagetool = toolRoot.file("appimagetool-x86_64.AppImage") + val appimagetool = toolRoot.file("appimagetool-$hostArch.AppImage") inputs.dir(distDir) inputs.dir(toolRoot) @@ -292,7 +304,7 @@ val createReleaseAppImage by tasks.registering(Exec::class) { appDir.get().asFile.absolutePath, outFile.get().asFile.absolutePath, ) - environment("ARCH", "x86_64") + environment("ARCH", hostArch) // Bypass FUSE requirement on CI runners (ubuntu-latest lacks libfuse.so.2). // AppImage standard env var: extracts + runs without mounting. environment("APPIMAGE_EXTRACT_AND_RUN", "1") diff --git a/desktopApp/packaging/appimage/AppRun b/desktopApp/packaging/appimage/AppRun index c42b59c001..7e208a8d54 100755 --- a/desktopApp/packaging/appimage/AppRun +++ b/desktopApp/packaging/appimage/AppRun @@ -7,7 +7,10 @@ # (Equivalent packages on Fedora/Arch.) set -eu HERE="$(dirname "$(readlink -f "${0}")")" -export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}" +# Multiarch lib path is set by the host, not baked at build time — same +# AppRun works in both x86_64 and aarch64 AppImages. +GNU_TRIPLET="$(uname -m)-linux-gnu" +export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/${GNU_TRIPLET}:${LD_LIBRARY_PATH:-}" export PATH="${HERE}/usr/bin:${PATH}" export APPDIR="${HERE}" exec "${HERE}/usr/bin/Amethyst" "$@" diff --git a/desktopApp/packaging/flatpak/README.md b/desktopApp/packaging/flatpak/README.md index 552fba7e42..c6c1f7cd84 100644 --- a/desktopApp/packaging/flatpak/README.md +++ b/desktopApp/packaging/flatpak/README.md @@ -28,8 +28,12 @@ used two ways: manifest (archive source pinned to the release tarball URL + sha256, with `x-checker-data` so Flathub's update bot bumps it), its own metainfo (carries the permanent `` history Flathub requires), desktop - entry, icon, and `flathub.json` (`only-arches: x86_64` — we publish no - aarch64 tarball, and jpackage can't cross-compile one) + entry, icon, and `flathub.json` — currently gated to `only-arches: + x86_64` so the Flathub build machinery never tries the aarch64 tarball + before we've validated it end-to-end on Flathub's aarch64 builders. GitHub + releases already ship aarch64 flatpak bundles (built from the same source + tree on `ubuntu-24.04-arm`); flipping `only-arches` to include `aarch64` + is the follow-up once we've smoke-tested a Flathub aarch64 build. ## Local build diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh index d419b10c0d..ee7b2bda98 100755 --- a/scripts/asset-name.sh +++ b/scripts/asset-name.sh @@ -29,15 +29,26 @@ # amethyst-desktop-1.08.0-linux-x64.AppImage # amethyst-desktop-1.08.0-linux-x64.flatpak # amethyst-desktop-1.08.0-linux-x64.tar.gz +# amethyst-desktop-1.08.0-linux-arm64.deb +# amethyst-desktop-1.08.0-linux-arm64.rpm +# amethyst-desktop-1.08.0-linux-arm64.AppImage +# amethyst-desktop-1.08.0-linux-arm64.flatpak +# amethyst-desktop-1.08.0-linux-arm64.tar.gz # amy-1.08.0-macos-arm64.tar.gz # amy-1.08.0-macos-x64.tar.gz # amy-1.08.0-linux-x64.tar.gz # amy-1.08.0-linux-x64.deb # amy-1.08.0-linux-x64.rpm +# amy-1.08.0-linux-arm64.tar.gz +# amy-1.08.0-linux-arm64.deb +# amy-1.08.0-linux-arm64.rpm # geode-1.08.0-macos-arm64.tar.gz # geode-1.08.0-linux-x64.tar.gz # geode-1.08.0-linux-x64.deb # geode-1.08.0-linux-x64.rpm +# geode-1.08.0-linux-arm64.tar.gz +# geode-1.08.0-linux-arm64.deb +# geode-1.08.0-linux-arm64.rpm # # Two assets break the family/arch shape on purpose: the no-JRE jar bundles for # Homebrew-core are pure JVM bytecode (no bundled runtime), so a single From 7b29f57526fba1529c66a18a44af78fcdf2cd52f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 02:55:14 +0000 Subject: [PATCH 093/227] docs: add event-store-semantics skill (IEventStore/SQLite store contract) Documents the store's observable behavior as named rules (STORE-F/W/D/C/S/N) so external IEventStore implementations can review pin bumps and annotate divergences against a stated contract instead of reverse-engineering QueryBuilder. Requested by the vespa-eventstore consumer. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ADBfjWhsXyHZb7ea2eeBhJ --- .claude/skills/event-store-semantics/SKILL.md | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 .claude/skills/event-store-semantics/SKILL.md diff --git a/.claude/skills/event-store-semantics/SKILL.md b/.claude/skills/event-store-semantics/SKILL.md new file mode 100644 index 0000000000..1c99e335a9 --- /dev/null +++ b/.claude/skills/event-store-semantics/SKILL.md @@ -0,0 +1,325 @@ +--- +name: event-store-semantics +description: The authoritative behavioral contract of Quartz's event stores — `IEventStore` and its reference SQLite implementation (`nip01Core/store/sqlite/`). Use when implementing or asserting parity with a Quartz event store (external engines like Vespa, the filesystem store, geode), answering filter-semantics questions (since/until inclusivity, tag OR/AND, multi-filter limits, ordering tiebreaks), or working on the write-path rules for replaceable/addressable supersession, NIP-09 deletions, NIP-40 expiration, NIP-62 vanish, NIP-45 counts, or NIP-50 search inside the store. Every behavior has a named rule id (STORE-Fxx/Wxx/Dxx/Sxx/Cxx) so downstream implementations can annotate divergences precisely. +--- + +# Event Store Semantics — the `IEventStore` / SQLite-store contract + +The SQLite `EventStore` (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/`) +is the de-facto **reference implementation** of what a Quartz event store must do. Other +implementations — the in-repo filesystem store (`nip01Core/store/fs/`, held to parity by +`quartz/src/jvmTest/.../store/fs/FsParityTest.kt`) and external engines (e.g. a Vespa-backed +store) — reimplement its *observable behavior* and assert parity in CI. This skill states that +behavior as **named, numbered decisions** so a parity divergence becomes a lookup, not an +archaeology session through `QueryBuilder`/`MergeQueryExecutor`. + +Every rule below was verified against the code as of this skill's last update. When you change +store behavior, **update the rule here in the same PR** and add a line to the +[Semantics changelog](#semantics-changelog) — downstream implementations pin Quartz by commit and +review pin bumps against this file. + +## Key files + +| Concern | File | +|---|---| +| Public contract (KDoc is normative) | `nip01Core/store/IEventStore.kt` | +| High-level store (owns pool + planner) | `sqlite/EventStore.kt`, `sqlite/SQLiteEventStore.kt` | +| Filter → SQL, ordering, limits, counts | `sqlite/QueryBuilder.kt` | +| k-way merge fast path (feed shapes) | `sqlite/MergeQueryExecutor.kt` | +| Schema, tag hashing, immutability | `sqlite/EventIndexesModule.kt`, `sqlite/TagNameValueHasher.kt`, `sqlite/SeedModule.kt` | +| Replaceable / addressable supersession | `sqlite/ReplaceableModule.kt`, `sqlite/AddressableModule.kt` | +| NIP-09 / NIP-40 / NIP-62 / ephemeral | `sqlite/DeletionRequestModule.kt`, `sqlite/ExpirationModule.kt`, `sqlite/RightToVanishModule.kt`, `sqlite/EphemeralModule.kt` | +| NIP-50 FTS | `sqlite/FullTextSearchModule.kt` (see also the `searchable-events` skill) | +| Index/feature toggles | `sqlite/IndexingStrategy.kt` (client default) and geode's `RelayIndexingStrategy.kt` (relay preset) | +| Operational README | `sqlite/README.md` (concurrency, pragmas, maintenance) | + +Executable spec: the test suites in +`quartz/src/commonTest/.../store/sqlite/` (`BasicTest`, `ReplaceableTest`, `AddressableTest`, +`DeletionTest`, `ExpirationTest`, `RightToVanishTest`, `SearchTest`, `SearchRelevanceOrderTest`, +`MergeQueryCorrectnessTest`, `TagMergeCorrectnessTest`, `QueryAssemblerTest`, +`SnapshotIdsForNegentropyTest`, `FilterMatcherTest`, …). If a rule here ever contradicts a test, +the test wins — and this file has a bug to fix. + +## Kind classes (used throughout) + +- **Replaceable**: kind `0`, kind `3`, and `10000 ≤ kind < 20000`. +- **Ephemeral**: `20000 ≤ kind < 30000`. +- **Addressable**: `30000 ≤ kind < 40000`. +- Everything else is a regular event. + +--- + +## Filter matching (STORE-F) + +**STORE-F01 — `since`/`until` are both inclusive.** `since` compiles to +`created_at >= ?`, `until` to `created_at <= ?` (`QueryBuilder` uses +`greaterThanOrEquals`/`lessThanOrEquals` everywhere). An event with +`created_at == since == until` matches. + +**STORE-F02 — `ids` and `authors` are exact-match only.** They compile to `=`/`IN` against the +full 64-char hex columns. **NIP-01 prefix matching is NOT supported** anywhere in the store. +(`Filter`'s constructor logs an error for non-64-char ids/authors but still sends them; they +simply never match.) + +**STORE-F03 — tag filter combination.** Within one tag name, values are **OR** +(`tag_hash IN (…)`). Across different tag names in the same filter, conditions are **AND** +(each extra name becomes another `event_tags` self-join). `tagsAll` (NIP-91 `&x` syntax) demands +**every listed value** be present on the event — one join + equality per value — and composes by +AND with any plain `tags` in the same filter. + +**STORE-F04 — only single-letter tag names are indexed (by default).** +`DefaultIndexingStrategy.shouldIndex` indexes a tag iff `tag.size >= 2 && tag[0].length == 1`. +A filter on a multi-letter tag name (`#title`, `#alt`) matches **nothing** in the SQLite store. +Deployments can widen `shouldIndex`, but the stock contract is single-letter-only. + +**STORE-F05 — `d` is special-cased out of the tag index.** `#d` values are matched against the +`event_headers.d_tag` column, not `event_tags` (`Filter.toFilterWithDTags()`). Consequences: +`#d` works on addressable events (which populate `d_tag`); when all `kinds` are addressable the +query adds `kind >= 30000 AND kind < 40000` to pin the addressable index. **Only use `#d` via +plain `tags`.** A `#d` under `tagsAll` is handled inconsistently: on the simple (no other +tags/search) path it degrades to OR semantics (`toFilterWithDTags` folds it into `dTags`), and +when `tags["d"]` is also present it is dropped entirely; on the tag-join path it is ignored. +(An event has one d-tag, so AND-across-values could never match anyway.) + +**STORE-F06 — tag and author matching in the tag path is hash-based.** `event_tags` stores a +64-bit MurmurHash3 of `(tag name, value)` keyed by a per-database random seed (`SeedModule`, +`TagNameValueHasher`); the p/e/a-owner columns are hashes too. There is **no post-verification** +of hash matches, so a hash collision would return a false positive. Probability is negligible in +practice but nonzero — a parity harness comparing against an exact-match engine should know this +is the one place the reference can (theoretically) over-match. + +**STORE-F07 — multiple filters are a union with dedup; `limit` is per-filter.** Each filter +becomes its own row-id subquery with its **own** `ORDER BY … LIMIT`; branches are combined with +SQL `UNION` (dedup by row). There is **no global limit** — a 3-filter query with limits +10/20/30 can return up to 60 events, presented in one merged `created_at DESC` ordering. NIP-45 +counts and negentropy snapshots dedup the same way (`SELECT DISTINCT` / `UNION`). + +**STORE-F08 — result ordering.** Non-search queries order `created_at DESC`. The `id ASC` +tiebreak on equal `created_at` is applied **only when +`IndexingStrategy.useAndIndexIdOnOrderBy = true`** — which is `false` in the client default +**and** in geode's relay preset. So by default, same-second ordering is unspecified (SQLite +returns them in storage order). Any newest-N is valid; a parity suite must not assert +same-`created_at` order unless it configures the flag. One extra caveat with the flag ON: the +`MergeQueryExecutor` tag-stream path still yields same-second ties in rowid order (its cursors +run off `event_tags`, which has no id column) — a valid newest-N that may differ byte-for-byte +from the single-SQL ordering. + +**STORE-F09 — the merge fast path returns the same *set*.** Single-filter queries of the shape +"authors (+kinds) + limit" or "one `#x` IN-list (+kinds) + limit" (≤2048 streams) route through +`MergeQueryExecutor`, a k-way newest-first merge over per-(kind,author) / per-(tag-value,kind) +index cursors with dedup by id on the tag shape. This is an optimization, not a semantics +change — `MergeQueryCorrectnessTest`/`TagMergeCorrectnessTest` assert set-equality with the +single-SQL plan (ordering caveat per STORE-F08). + +**STORE-F10 — empty filter.** `query(Filter())` / `count(Filter())` match **everything** +(`Filter.isEmpty()` → the "everything" query). `delete(Filter())` is deliberately asymmetric: +it deletes **nothing** and returns 0, so a stray empty filter can't wipe the store (documented +on `QueryBuilder.delete`). + +**STORE-F11 — empty lists (`kinds = emptyList()` etc.) are a client error with inconsistent +handling; don't rely on either outcome.** On the single-filter simple path an empty list +renders as `1 = 0` → matches nothing. But `Filter.isEmpty()` treats empty lists the same as +`null`, so on the multi-filter union path such a filter contributes no subquery — and a list of +*only* empty-list filters degrades to the match-everything query. Known quirk; treat +empty-list filters as invalid input rather than replicating this shape. + +**STORE-F12 — `limit` edge cases.** `limit = 0` compiles to `LIMIT 0` → zero rows. +`limit = null` means unbounded. Negative limits are not defended against (don't send them). + +**STORE-F13 — the in-memory matcher is a separate (simpler) implementation.** +`Filter.match(event)` (`FilterMatcher`) is used for live-stream matching, not storage queries; +it checks ids/authors/kinds/tags/tagsAll/since/until but not `search` or `limit`. Parity work +targets the SQL semantics above, not `FilterMatcher`. + +--- + +## Write path (STORE-W) + +Inserts run every module in one transaction: header+tags → NIP-09 side effects → expiration +row → FTS row → vanish side effects. A trigger `RAISE(ABORT, …)` rejects the whole row with the +messages quoted below (they surface as the NIP-01 `OK false` reason). + +**STORE-W01 — replaceable supersession.** Unique index on `(kind, pubkey)` for replaceable +kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning +`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01 +lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored +row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract: +exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked. + +**STORE-W02 — addressable supersession.** Same as W01 with unique index +`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the +*parsed* event class (`AddressableEvent.dTag()`); an addressable-range kind whose class doesn't +parse as `AddressableEvent` stores `d_tag NULL`, and SQLite treats NULLs as distinct in unique +indexes — such events don't supersede each other. An event with no `d` tag parses as `dTag() = ""` +(empty string), which *does* dedupe normally. + +**STORE-W03 — ephemeral events are never stored but are acked as accepted.** +`insert()` returns silently and `batchInsert` reports `Accepted` for `20000 ≤ kind < 30000` +without writing (the live relay stream still broadcasts them). A DB-level backstop trigger +(`blocked: cannot store ephemeral events`) rejects any that sneak past the app-level check. + +**STORE-W04 — expired events are rejected at insert.** App-level check +(`event.isExpired()`) plus a trigger on the expiration-row insert +(`blocked: this event is expired` when `expiration <= unixepoch()`). Single-event `insert` +**throws**; `batchInsert` returns `Rejected`. + +**STORE-W05 — expiry is enforced at insert and by sweep, NOT at query time.** Events with a +future `expiration` store a row in `event_expirations`. Nothing filters them out of queries +after the timestamp passes: **a query between expiry and the next `deleteExpiredEvents()` sweep +returns the expired event.** Operators run the sweep periodically (README recommends ~15 min). +Re-inserting an already-expired event after the sweep is rejected per W04. + +**STORE-W06 — GiftWrap ownership is the recipient.** For kind 1059 the store computes +`pubkey_owner_hash` from the `p`-tag recipient (falling back to the random signer key if +absent). All owner-scoped machinery — NIP-09 re-insert blocking, NIP-62 vanish deletion and +blocking — operates on that owner hash, so **a user's deletions/vanish remove giftwraps +addressed to them**, even though the wrap's `pubkey` is a one-time key. (Consequently GiftWraps +are also excluded from `authorsMissingOutbox()`.) + +**STORE-W07 — immutability.** `event_headers`/`event_tags` rows are never updated +(`BEFORE UPDATE` triggers abort). All supersession is delete + insert; `event_tags`, +`event_expirations`, `event_vanish`, and the FTS row follow the header by +`ON DELETE CASCADE` / trigger. + +**STORE-W08 — batch insert.** One outer transaction, one SAVEPOINT per row: a bad row rolls +back alone and reports `Rejected(reason)`; the rest commit. If the **outer commit** fails, every +entry is treated as `Rejected` (the `IEventStore.batchInsert` contract). Outcomes are returned +in input order; OK frames pair by event id, not order. + +--- + +## Deletion lifecycle — NIP-09 / NIP-62 (STORE-D) + +**STORE-D01 — delete by id.** A kind-5's `e` tags delete stored events with those ids **whose +owner is the kind-5's author** (`pubkey_owner_hash` match — recipient for giftwraps per W06). +The id path has **no timestamp condition**: it deletes the target regardless of the relative +`created_at` values. + +**STORE-D02 — delete by address.** A kind-5's `a` tags delete events at that +`(kind, pubkey, d_tag)` coordinate with `created_at <= deletion.created_at` — **inclusive**; a +version newer than the deletion survives. Only coordinates whose pubkey equals the kind-5's +author are honored. Replaceable coordinates (`kind:pubkey:` with no d-tag) get the same +`created_at <=` treatment against `(kind, pubkey)`. + +**STORE-D03 — cross-author kind-5s are stored but inert.** A deletion naming someone else's +events is inserted like any regular event (it may be useful to other relays/clients) but its +delete pass removes zero rows and creates no blocking. + +**STORE-D04 — re-insert blocking.** A `BEFORE INSERT` trigger rejects +(`blocked: a deletion event exists`) any event whose id (`e`-hash) **or** address (`a`-hash) is +named by a stored kind-5 from the same owner with `deletion.created_at >= event.created_at`. +Note the asymmetry with D01: a *backdated* id-deletion (older `created_at` than its target) +still deletes on arrival, but would not block a later re-insert. + +**STORE-D05 — a kind-5 CAN delete another kind-5, and doing so un-blocks its targets.** +Nothing excludes kind 5 from the id path (D01). Deleting a deletion removes its tombstone rows +from `event_tags`, so events it had deleted become re-insertable. **Status: known quirk, not a +considered decision.** NIP-09 leaves it open; at least one external implementation +(vespa-eventstore) deliberately diverges by treating deletion-of-a-deletion as a no-op, which is +the safer reading (tombstones shouldn't be revocable). If you change this, update this rule and +the changelog — parity suites key off it. + +**STORE-D06 — NIP-62 vanish is relay-scoped.** A kind-62 only cascades when +`shouldVanishFrom(relay)` — its `relay` tags name this store's `relay` URL or `ALL_RELAYS`. +(A store constructed with `relay = null` matches only `ALL_RELAYS` requests.) Out-of-scope +vanish events are stored as regular events with no side effects. + +**STORE-D07 — vanish scope and horizon.** An in-scope vanish deletes every event whose +**owner** (W06) is the vanishing pubkey with `created_at < vanish.created_at` (strict — the +vanish event itself survives), and blocks inserts of owned events with +`created_at <= vanish.created_at` (`blocked: a request to vanish event exists`; note blocking is +inclusive where deletion is strict). Newer vanish requests supersede older ones per pubkey +(unique on `pubkey_hash`). + +**STORE-D08 — manual deletes.** `delete(id)` removes one row unconditionally (no blocking +created). `delete(filter)` deletes matching rows honoring per-filter limits, with the F10 +empty-filter no-op guard. Neither creates re-insert blocking — only stored kind-5/kind-62 +events do that. + +--- + +## NIP-45 count (STORE-C) + +**STORE-C01 — count = size of the deduped match set, honoring per-filter limits.** Single +filter: `COUNT(*)` over that filter's row-id subquery (including its `LIMIT`, so +`count(Filter(kinds=…, limit=10))` is at most 10). Multiple filters: branches are `UNION`ed +(dedup) **before** counting — an event matching several filters counts once. FTS-off + search +term → 0 (F-series search rules apply). + +--- + +## NIP-50 search inside the store (STORE-S) + +The indexing surface (which kinds are searchable, what text they contribute) is the +`searchable-events` skill; these rules are the store's query-side contract. + +**STORE-S01 — extension stripping at the store boundary.** Every filter-accepting method runs +`strippingSearchExtensions()`: NIP-50 `key:value` tokens (`include:spam`, `domain:…`, …) are +removed before FTS. Unsupported extensions are **ignored, never matched as literal text and +never match-nothing** — an extensions-only search collapses to an unconstrained query. Stores +that *do* implement extensions receive the raw string through the relay layer and parse it with +`nip50Search.SearchQuery.parse` (see the `IEventStore` KDoc). + +**STORE-S02 — relevance ordering.** Search results order by FTS5 `bm25` rank (best match +first), with `created_at DESC` only as tiebreak; the `LIMIT` keeps the most *relevant* N, not +the newest N. A multi-filter REQ is relevance-ordered only when **every** filter carries a +search term (best/min rank per event across branches); mixing search and non-search filters +falls back to `created_at DESC`. + +**STORE-S03 — search combines by AND with the structural parts** (ids/authors/kinds/tags/ +since/until) of the same filter — an FTS `MATCH` join on top of the normal conditions. + +**STORE-S04 — search grammar is SQLite FTS5 `MATCH`.** The raw (post-strip) string is passed to +FTS5, so implicit-AND terms, `"phrase queries"`, `OR`, and `prefix*` follow FTS5 semantics. +Tokenization details live in `FullTextSearchModule` (see `searchable-events`). + +**STORE-S05 — FTS off.** With `IndexingStrategy.indexFullTextSearch = false`: a filter with a +non-empty search term matches **nothing** (query/count/delete alike); an empty-string search +imposes no constraint. Everything else is unchanged. + +**STORE-S06 — deferred FTS.** Relays may set `deferFullTextSearchIndexing = true` (geode does): +tokenization moves off the insert path to a watermark-driven catch-up +(`needsFtsCatchUp`/`ftsCatchUp`), and search queries drain the backlog first — so NIP-50 +results are exactly as fresh as the synchronous path. + +--- + +## Negentropy / NIP-77 (STORE-N) + +**STORE-N01 —** `snapshotIdsForNegentropy(filters)` returns `(created_at, id)` pairs under the +**same filter semantics as `query`** (per-filter limits included, multi-filter dedup), order +unspecified (negentropy re-sorts). `maxEntries` returns up to `maxEntries + 1` as an overflow +sentinel. `liveNegentropySnapshot` serves full-corpus NEG-OPENs from an in-memory index when +`maintainLiveNegentropyIndex` is on; the delta plumbing in `SQLiteEventStore` keeps it exact +across replaceable displacement, kind-5s, and vanish (invalidate-and-rebuild for the +non-itemizable cases). + +--- + +## Configuration presets + +- **Client default** (`DefaultIndexingStrategy()`): FTS on (synchronous), optional indexes off, + `useAndIndexIdOnOrderBy` off, no live negentropy index. +- **Relay preset** (geode's `relayIndexingStrategy()`): adds created_at-alone, pubkey-alone and + tag+kind+pubkey indexes, defers FTS, maintains the live negentropy index — still leaves + `useAndIndexIdOnOrderBy` off. +- Flag-gated indexes are runtime config, not schema: flipping one on an existing DB builds the + index on next open (`ensureOptionalIndexes`), no migration. + +## For parity implementers + +- Treat the rule ids above as the vocabulary for divergence notes + (e.g. "diverges from STORE-D05: we no-op deletion-of-a-deletion"). +- The commonTest suites are the executable spec; `FsParityTest` shows the in-repo pattern for + holding a second engine to it. +- Remember F06 (hash-based tag matching) and F08 (unordered same-second ties by default) when + diffing results byte-for-byte — both are places where a "divergence" may be the reference's + own slack, not your bug. + +## Semantics changelog + +Add one line per behavior change, newest first: `YYYY-MM-DD — what changed`. + +- 2026-08-04 (baseline) — rules F01–F13, W01–W08, D01–D08, C01, S01–S06, N01 written from the + code at the time this skill was introduced. Changes before this date are not itemized; + archaeology starts at `git log` on `nip01Core/store/`. From 2b2e47256b0760ad21719e8cfe1a592521f94d9e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:04:17 +0000 Subject: [PATCH 094/227] docs: add nip85-trusted-assertions and searchable-events skills Completes the store-implementer skill set requested by the vespa-eventstore consumer: the NIP-85 trust-assertion model (kind map, tag vocabulary, value semantics, authorization conventions) and the NIP-50 indexing surface (the SearchableEvent contract plus an exhaustive kind -> indexableContent table external search engines can diff at version bumps). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ADBfjWhsXyHZb7ea2eeBhJ --- .claude/core-skills-plan.md | 27 ++ .../skills/nip85-trusted-assertions/SKILL.md | 232 ++++++++++++++++++ .claude/skills/searchable-events/SKILL.md | 117 +++++++++ .../references/searchable-kinds.md | 166 +++++++++++++ 4 files changed, 542 insertions(+) create mode 100644 .claude/skills/nip85-trusted-assertions/SKILL.md create mode 100644 .claude/skills/searchable-events/SKILL.md create mode 100644 .claude/skills/searchable-events/references/searchable-kinds.md diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index 1af746f6dd..35eafb646f 100644 --- a/.claude/core-skills-plan.md +++ b/.claude/core-skills-plan.md @@ -85,3 +85,30 @@ skills verified clean): `ParseReturn.entity` (the `Nip19Parser.Return.*` sealed class never existed); Event Store section corrected from "Android only" to commonMain/all platforms with the real `store.sqlite.EventStore` import and suspend generic `query`. + +## Phase 4 (2026-08): Store-implementer skills (external consumer request) + +Three skills added at the request of an external Quartz consumer +(vespa-eventstore — a server-side `IEventStore` on Vespa that asserts result +parity against the SQLite store in CI). All three document the +**store/relay-implementer's perspective**, which `quartz-integration` and +`nostr-expert` (client-side) did not cover. Requirements doc: the skill-requests +file reviewed 2026-08-04; the requester's items #4 (storage-lifecycle-nips) was +folded into `event-store-semantics` per their own recommendation, and #5 +(relay-server/geode policies) was declined as not currently needed. + +- **`event-store-semantics/`** — the `IEventStore`/SQLite-store behavioral + contract as named rules (STORE-Fxx/Wxx/Dxx/Cxx/Sxx/Nxx) with a semantics + changelog for pin-bump review. Written from `QueryBuilder`, + `MergeQueryExecutor`, the seven `*Module.kt` files, and `IEventStore` KDoc. +- **`nip85-trusted-assertions/`** — the NIP-85 model (10040/30382/30383/30384/ + 30385), full tag vocabulary with value semantics, authorization conventions, + worked JSON examples, stability notes. +- **`searchable-events/`** — the `SearchableEvent` contract + maintenance + mandate, with `references/searchable-kinds.md` holding the exhaustive + kind → class → `indexableContent()` table (126 classes / 129 kinds) that + external search engines diff at version bumps. + +Follow-ups suggested but not implemented: a shared JSON test-vector corpus for +filter semantics (testFixtures both the SQLite tests and external parity suites +could run), and a snapshot test pinning the searchable-kind set. diff --git a/.claude/skills/nip85-trusted-assertions/SKILL.md b/.claude/skills/nip85-trusted-assertions/SKILL.md new file mode 100644 index 0000000000..b0b17f22ea --- /dev/null +++ b/.claude/skills/nip85-trusted-assertions/SKILL.md @@ -0,0 +1,232 @@ +--- +name: nip85-trusted-assertions +description: The NIP-85 trusted-assertions model in Quartz (`nip85TrustedAssertions/`) — kind 10040 trust-provider lists, kind 30382 contact cards / user assertions, 30383 event assertions, 30384 addressable assertions, 30385 external-id assertions. Use when building or parsing these events, working with the typed tags (RankTag, HopsTag, FollowerCountTag, ServiceProviderTag/ServiceType, …), wiring a consumer that resolves a 10040 provider entry to the 30382s it signs, ranking on assertion values, or touching the GrapeRank publisher, contact-card nicknames, or the trust projection of an external store. +--- + +# NIP-85 Trusted Assertions — the Quartz model + +Package: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/`. +NIP-85 is still an evolving spec; **this package is the operative definition** of what +Amethyst-family software writes and reads. This skill states the model (who signs what about +whom), the exact kind/d-tag/tag vocabulary, and what consumers may — and may not — assume. + +## The model in one paragraph + +An **assertion is signed by the asserting party** (a trust provider service, or the user +themself) **about a subject named in the d-tag**. All assertion kinds are addressable, so +"latest card by provider P about subject S" is just the addressable coordinate +`(kind, P, S)` and supersession is standard NIP-01 latest-wins. Discovery is the observer's +**kind 10040 list**: each entry says *"for metric M on kind K, I trust provider P — fetch +their assertions at relay R"*. Quartz enforces none of this cryptographically beyond normal +event signatures; the 10040→assertion link is **consumer-side convention** (see +"Authorization" below). + +## Kind map + +| Kind | Class | Kind class | d-tag = the subject | Content | +|---|---|---|---|---| +| 10040 | `list/TrustProviderListEvent` | replaceable | *(none — always `""`)* | NIP-44 private provider entries (optional) | +| 30382 | `users/ContactCardEvent` | addressable | **target user's pubkey** (hex) | NIP-44 private tags (petname/summary/emoji) | +| 30383 | `events/EventAssertionEvent` | addressable | **target event id** (hex) | `""` | +| 30384 | `addressables/AddressableAssertionEvent` | addressable | **target coordinate** `kind:pubkey:dtag` | `""` | +| 30385 | `externalIds/ExternalIdAssertionEvent` | addressable | **external identifier** (e.g. `isbn:978-0-13-468599-1`) | `""` | + +Addresses: `ContactCardEvent.createAddress(owner, target)` → `Address(30382, owner, target)` +(owner = signer, target = subject). `TrustProviderListEvent.createAddress(pubKey)` uses +`FIXED_D_TAG = ""`. `AssertionEventTest.eventKindsAreCorrect` pins all five numbers. + +`ContactCardEvent` is also a `SearchableEvent` — it indexes only the **public** petname/summary +tags plus topics; the encrypted card content is intentionally never indexed. + +## The 10040 provider entry (`ServiceProviderTag` / `ServiceType`) + +There is **no fixed tag name**: `tag[0]` *is* the service string. + +```json +["30382:rank", "", "wss://nip85.brainstorm.world"] +``` + +- `ServiceType(kind, type)` parses/renders `":"` — kind must be an int, the first + `:` splits, colons in the remainder stay in `type`. `ServiceType.isOfKind` is the + allocation-free prefix check. +- `ServiceProviderTag.parse` requires ≥3 elements, non-empty service, 64-char pubkey + (length-only check), and a **normalizable relay URL** (`RelayUrlNormalizer.normalizeOrNull`) — + entries failing any check are silently dropped, which is what keeps foreign tags like + `["client","nostria"]` out (regression-tested in `ServiceTypeParserTest`). +- Entries may be **public** (tag array) or **private** (NIP-44 content); `create`/`add` take + `isPrivate`. `remove` always needs decryption and strips from both sides by parsed-value + equality. +- `object ProviderTypes` (`list/tags/ServiceType.kt`) enumerates the *known* service types — + `30382:rank`, `30382:followers`, `30382:first_created_at`, per-metric `30383:*`/`30384:*`/ + `30385:*`, etc. It is an **open vocabulary**: real 10040s in the wild (see the fiatjaf → + brainstorm fixture in `commonTest/.../nip85TrustedAssertions/ServiceParser.kt`) carry types + Quartz doesn't enumerate (`30382:personalizedGrapeRank_influence`, `30382:hops`, + `30382:verifiedFollowersCount`, …). Parse any `kind:type`; special-case only what you rank on. + +## Authorization — what a consumer may assume + +- **A 30382 (or 30383/…) is meaningful to an observer only if its author is listed in the + observer's 10040 for a matching service type.** Quartz does not enforce this; the consuming + code does. The in-repo pattern is `commons/.../model/nip85TrustedAssertions/UserCardsCache.kt`: + `rankFlow(trustProviderList)` picks the received card whose **author pubkey equals the + provider entry's pubkey** and reads `rank()` from it. Assertions from unlisted signers are + simply ignored for trust purposes (they may still be stored; dropping them — as an external + store's orphan sweep does — is a legitimate storage policy, not a protocol rule). +- What an entry authorizes is scoped by its `ServiceType`: `30382:rank` authorizes that + provider's user-rank cards, nothing else. Amethyst models this as one provider slot per + metric (`liveUserRankProvider`, `liveUserFollowerCount` in + `amethyst/.../model/trustedAssertions/TrustProviderListState.kt`). +- **Multi-provider combination is unprescribed.** When two listed providers assert different + ranks, there is no spec'd merge; Amethyst avoids the question by selecting one provider per + metric slot. Consumers choose their own policy — document it. +- The relay URL in the entry is a **fetch hint, and it is honored**: + `amethyst/.../UserCardsSubAssembler.kt` subscribes for cards at the provider's declared relay + (`kinds=[30382], authors=[provider], #d=[targets]`). + +### The dual use of kind 30382 + +The same kind serves two roles, distinguished **by author**: + +1. **Provider WoT cards** — signed by a trust provider; public metric tags (`rank`, + `followers`, `hops`, …); this is what 10040 discovery points at. +2. **The account's own contact cards (nicknames, NIP-81-style)** — signed by the account, + one per target user. The petname, summary, and their NIP-30 emoji mappings **always live in + the NIP-44 encrypted content, never in public tags** (`ContactCardEvent.build`/ + `updatePetNameAndSummary` strip stray public copies; asserted by `ContactCardPetNameTest`). + `commons/.../ContactCardsState.kt` keys everything on `author == account` and ignores + provider cards. + +## Tag vocabulary and value semantics + +All tag classes share one shape: `TAG_NAME` + `parse(tag)` (null on wrong name/empty/non-numeric +value — a bad tag is *dropped*, never an error) + `assemble(value)` → `[name, value.toString()]`. +**A missing tag means "unknown" (`null` accessor), never zero.** There is deliberately no range +validation (rank isn't clamped, hours aren't checked against 0–23, counts may be negative) — +consumers must defend. + +**On 30382** (`users/tags/`, accessors on `ContactCardEvent` and as `TagArray` extensions in +`users/TagArrayExt.kt` so they also work on decrypted private arrays): + +| Tag name | Accessor | Type | Semantics | +|---|---|---|---| +| `rank` | `rank()` | Int | Provider-relative score; higher is better. GrapeRank publishes `round(score × 100)` (so 0–100 in practice), but nothing enforces a scale — treat it as comparable only *within one provider*. | +| `followers` | `followerCount()` | Int | Follower count as the provider computes it (cumulative, provider-defined). | +| `hops` | `hops()` | Int | Shortest follow-path length **from the observer the provider computed for** to the subject (1 = directly followed). Mirrors Brainstorm GrapeRank's `hops`. The only tag with KDoc. | +| `first_created_at` | `firstCreatedAt()` | Long | Unix seconds of subject's earliest known event. | +| `post_cnt` / `reply_cnt` / `reactions_cnt` | `postCount()` etc. | Int | Activity counts. | +| `zap_amt_recd` / `zap_amt_sent` | `zapAmountReceived()`/`…Sent()` | Long | Sats. | +| `zap_cnt_recd` / `zap_cnt_sent` | `zapCountReceived()`/`…Sent()` | Int | Counts. | +| `zap_avg_amt_day_recd` / `zap_avg_amt_day_sent` | `zapAvgAmountDay…()` | Long | Sats/day averages. | +| `reports_cnt_recd` / `reports_cnt_sent` | `reportsCount…()` | Int | NIP-56 report counts. | +| `t` (repeatable) | `topics()` | List\ | Subject's topics/interests. | +| `active_hours_start` / `active_hours_end` | `activeHours…()` | Int | Hour-of-day; **no timezone is specified in code** — treat as provider-defined (UTC in practice) and unclamped. | +| `petname` / `summary` | `petName()`/`summary()` | String | Nickname fields — conventionally private (see dual use above). | + +**On 30383/30384** (`tags/`, shared): `rank`, `comment_cnt`, `quote_cnt`, `repost_cnt`, +`reaction_cnt`, `zap_cnt` (Int) and `zap_amount` (Long, sats). +**On 30385**: only `rank`, `comment_cnt`, `reaction_cnt`. + +## Building and parsing (use the typed helpers, not raw `arrayOf`) + +```kotlin +// Provider list: declare a rank provider (this is what `amy graperank register` does) +val tag = ServiceProviderTag(ProviderTypes.rank, providerPubkeyHex, relayUrl) +val list = TrustProviderListEvent.create(tag, isPrivate = false, signer) +// or append to an existing one: +val updated = TrustProviderListEvent.add(existing, tag, isPrivate = false, signer) +val providers: List = updated.serviceProviders() // public +val private = updated.privateTags(signer)?.serviceProviders() // private side + +// Provider-style contact card (public metrics) — the GrapeRankPublisher pattern: +val card = ContactCardEvent.create( + targetUser = subjectPubkey, + signer = providerSigner, + publicInitializer = { + rank(87) + followers(1234) + hops(2) + }, +) +card.aboutUser() // d-tag → subject pubkey +card.rank() // 87 + +// Event assertion: unsigned template only (30383/84/85 have build(), no create()) +val template = EventAssertionEvent.build(targetEventId) { + rank(12) + reactionCount(40) + zapAmount(2100) +} +val signed = signer.sign(template) +``` + +## Worked end-to-end example + +Observer `O` trusts provider `P` for user ranks (kind 10040, replaceable, by `O`): + +```json +{ "kind": 10040, "pubkey": "", + "tags": [ + ["30382:rank", "

", "wss://nip85.brainstorm.world"], + ["30382:followers", "

", "wss://nip85.brainstorm.world"] + ], + "content": "" } +``` + +Provider `P` asserts about subject `S` (kind 30382, addressable at `30382:

:`): + +```json +{ "kind": 30382, "pubkey": "

", + "tags": [ + ["d", ""], + ["rank", "87"], ["followers", "1234"], ["hops", "2"] + ], + "content": "" } +``` + +`P` asserts about an event `E` (kind 30383, addressable at `30383:

:`): + +```json +{ "kind": 30383, "pubkey": "

", + "tags": [["d", ""], ["rank", "12"], ["reaction_cnt", "40"], ["zap_amount", "2100"]], + "content": "" } +``` + +Consumption chain: read `O`'s 10040 → entry matching `ServiceType(30382, "rank")` → subscribe +`{kinds:[30382], authors:["

"], "#d":["", …]}` at the hinted relay → newest card per +address wins → `rank()`. + +Literal fixtures: `quartz/src/commonTest/.../nip85TrustedAssertions/ServiceParser.kt` (a real +10040 — fiatjaf's, pointing at the Brainstorm provider) and `AssertionEventTest.kt` (all four +assertion kinds with every tag populated). + +## Freshness / supersession + +Assertions are addressable: **latest per `(kind, author, d-tag)` wins**; there is no expiry tag +convention and **no prescribed refresh cadence** — staleness policy is the consumer's. +Writers should avoid churn: `GrapeRankPublisher` re-signs a card only when +`(rank, followers, hops)` actually changed, and retracts with a NIP-09 kind-5 carrying the +card's `a`-tag (`30382::`). + +## Stability notes (as of 2026-08) + +- **Settled** (shipped consumers on both ends): the kind map; `ServiceProviderTag` entry shape; + `rank`/`followers`/`hops` on 30382; petname/summary-in-encrypted-content; 10040 relay-hint + consumption. +- **Written but lightly consumed** (parse, but gate ranking features carefully): the activity/ + zap/report count tags, `active_hours_*` (no timezone semantics), 30383/30384/30385 (builders + + tests exist; no in-repo publisher yet). +- **Known warts**: `ServiceProviderTag.assemble(id: ServiceProviderTag)` infers `Array` — + dead code, don't use it; `SummaryTag.assemble(ip:)`/`ActiveHours*Tag.assemble(count:)` params + are misnamed; the tests live under `commonTest/.../experimental/nip85TrustedAssertions/` + (stale path); `TrustProviderListEvent` extends the addressable base, so a stray on-wire `d` + tag is reflected by `dTag()` even though the convention is `""`. + +## Where it's consumed (reading list) + +- **Publisher**: `quartz/.../experimental/graperank/GrapeRankPublisher.kt` (canonical 30382 + writer), `cli/.../graperank/` (`amy graperank register|unregister|providers|publish`). +- **Client model**: `commons/.../model/nip85TrustedAssertions/` (`ContactCardsState`, + `UserCardsCache`, `ContactCardDecryptionCache`, `TrustProviderListDecryptionCache`), + `amethyst/.../model/trustedAssertions/TrustProviderListState.kt`. +- **Relay plumbing**: `commons/.../relayClient/assemblers/ContactCardFilters.kt`, + `amethyst/.../reqCommand/user/watchers/UserCardsSubAssembler.kt`. diff --git a/.claude/skills/searchable-events/SKILL.md b/.claude/skills/searchable-events/SKILL.md new file mode 100644 index 0000000000..aad4744f68 --- /dev/null +++ b/.claude/skills/searchable-events/SKILL.md @@ -0,0 +1,117 @@ +--- +name: searchable-events +description: The NIP-50 indexing surface of Quartz — the `SearchableEvent` interface, which event kinds are searchable, exactly what text each kind's `indexableContent()` contributes, how the SQLite/filesystem stores consume it, and the NIP-50 `SearchQuery` extension grammar plus `SearchRelayListEvent` (kind 10007). Use when making a kind searchable, changing what a kind indexes, diffing the searchable set at a Quartz version bump (external search engines mirror this table), debugging why an event is or isn't found by search, or working with search extensions (`include:spam`, `domain:`, …). +--- + +# Searchable Events — the NIP-50 indexing surface + +## The contract + +`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt`: + +```kotlin +interface SearchableEvent { + fun indexableContent(): String +} +``` + +One method; marker and extractor in one. An event kind is searchable **iff** its event class +implements this interface **and** the class is wired into `EventFactory` (the stores probe +searchability by kind through `EventFactory.create` — an unwired implementor is invisible). + +Rules every implementation follows (keep them when adding one): + +- **Plain text out.** Return the human-meaningful fields joined with `"\n"` (a handful of + metadata-ish kinds use `" "`); no markup stripping is performed — markdown/asciidoc content + goes in raw, JSON-content kinds (kind 0 metadata, marketplace stalls, channel info) **parse + first and join the extracted fields**, never the raw JSON. +- **Never throw, never null.** There is no defensive wrapper at any call site; a throw aborts + the insert transaction. Parsed-JSON implementations use `?.let { … } ?: ""`. +- **Only public data.** Encrypted content stays out (e.g. kind 30382 contact cards index only + the public petname/summary/topics, never the NIP-44 payload). +- Typical shapes: `content` alone (~33 kinds); `listOfNotNull(title(), content)`; + `listOfNotNull(title(), summary(), content)`; lists index `title() + description()`. + +## The full kind table + +**`references/searchable-kinds.md`** in this skill holds the authoritative table — every +implementor with its kind number, class, and the exact `indexableContent()` expression +(126 concrete classes / 129 kind values as of 2026-08). Diff that file at a version bump to +answer "did the searchable set or any kind's indexed text change?". + +Notables that surprise people: + +- **Kind 9735 (zap receipt) indexes the embedded zap request's content** + (`zapRequest?.content.orEmpty()`) — receipts are searchable by the zapper's comment. +- **Kind 0 / 31990** index many profile fields space-joined (name, about, nip05, lud16, + website, picture URL, …). +- **Kind 30063 is claimed twice** (`ReleaseArtifactSetEvent` in nip51Lists and the experimental + `SoftwareReleaseEvent`); `EventFactory` resolves 30063 to `ReleaseArtifactSetEvent`, so + `title()\ndescription()` is what actually gets indexed — `SoftwareReleaseEvent.indexableContent()` + is dead on the store path. +- Poll kinds (1068, 6969) append each option label on its own line. + +## MANDATORY maintenance when you touch this surface + +Adding `SearchableEvent` to a kind, removing it, or changing any `indexableContent()` body: + +1. **Update `references/searchable-kinds.md`** in the same PR (external search engines — e.g. + the Vespa-backed store's `SearchExtractors` — mirror this table at pin bumps; a silent + change ships them stale search results). +2. **Remember existing databases don't reindex themselves.** Old rows keep their old (or + missing) FTS text until `IEventStore.reindexFullTextSearch()` runs — the KDoc on that method + is the contract. App-side, schedule the resumable overload after shipping such a change. +3. New implementors must be **registered in `EventFactory`** or the reindex scan and kind + pre-filter (`FullTextSearchModule.isSearchableKind`) will never see them. + +Eligibility policy: a kind becomes searchable when it carries human-authored, human-meaningful +text (titles, bodies, names, descriptions). Pure-machine kinds (reactions, follow lists, zaps +minus their comment, relay lists) stay out to keep the index small. + +## How the stores consume it + +**SQLite** (`nip01Core/store/sqlite/FullTextSearchModule.kt`): +`CREATE VIRTUAL TABLE event_fts USING fts5(content, content='', contentless_delete=1)` — +contentless, `rowid` = `event_headers.row_id`, an `AFTER DELETE` trigger keeps it in sync. On +insert (when FTS is on and not deferred): `if (event is SearchableEvent)` → bind +`event.indexableContent()` — the only method ever called. Tokenization is entirely SQLite's +default FTS5 `unicode61`; queries are always a bound `event_fts MATCH ?` (never concatenated), +ordered by bm25 `rank` then `created_at DESC`. Query-side semantics (relevance ordering, +extension stripping, FTS-off behavior, deferred catch-up) are rules STORE-S01…S06 in the +`event-store-semantics` skill. + +**Filesystem store** (`jvmMain/.../store/fs/FsIndexer.kt` + `FsSearchTokenizer.kt`): tokenizes +`indexableContent()` itself, approximating `unicode61` (split on non-letter/digit, lowercase); +the same tokenizer runs on queries so drift cancels. + +## NIP-50 client side + +**`SearchQuery`** (`nip50Search/SearchQuery.kt`) — typed parse of the `search` filter string +into `terms` + `extensions`. A whitespace token is an extension iff it looks like +`lowercasekey:value` (the value not starting with `//`, so URLs stay free text); duplicate keys +keep the last; unknown extensions are preserved (`extension(key)`). Typed accessors: +`includeSpam`, `domain`, `language`, `sentiment`, `nsfw`. `stripExtensions()` / +`Filter.strippingSearchExtensions()` is the bridge the built-in stores use — unsupported +extensions are **ignored** (NIP-50), so an extensions-only search collapses to an unconstrained +query, never match-nothing. A server-side store that implements its own extensions +(`observer:`, `sort:rank`, …) receives the raw string (see the `IEventStore` KDoc) and should +parse with `SearchQuery.parse` so its syntax stays compatible with what clients send. + +**`SearchRelayListEvent`** — **kind 10007**, the user's search-relay list (NIP-51-style, public +tags + NIP-44 private tags; *not* a `SearchableEvent` itself). Client consumption: +`commons/.../actions/SearchActions.kt`, bootstrap defaults in +`commons/.../account/AccountBootstrapEvents.kt`. + +Don't confuse it with `commons/.../commons/search/SearchQuery.kt` — an app-level local-feed +query model (authors/kinds/hashtags/or-terms), unrelated to the NIP-50 wire string. + +## Tests (executable spec) + +- `commonTest/.../nip50Search/SearchQueryTest.kt` — the extension grammar, token by token. +- `commonTest/.../store/sqlite/SearchTest.kt` — per-kind indexing (kind 0 profile fields, + 40/41 channel JSON, 31924/30617), extension-token ignoring, reindex/resumable-reindex, + FTS cleanup on replaceable rotation. +- `commonTest/.../store/sqlite/SearchRelevanceOrderTest.kt` — bm25-before-recency ordering, + limit-after-score, multi-filter rank union. +- `commonTest/.../store/sqlite/NoFullTextSearchTest.kt` — FTS-off contract. +- `jvmTest/.../store/fs/FsSearchTest.kt` — tokenizer parity for the filesystem store. diff --git a/.claude/skills/searchable-events/references/searchable-kinds.md b/.claude/skills/searchable-events/references/searchable-kinds.md new file mode 100644 index 0000000000..ccae95ebfb --- /dev/null +++ b/.claude/skills/searchable-events/references/searchable-kinds.md @@ -0,0 +1,166 @@ +# Searchable kinds — the authoritative implementor table + +Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()` +expression. **Update this file in the same PR as any change to the searchable set or to an +`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-04. + +Counts: 126 concrete classes covering 129 kind values (`GitStatusEvent` spans 4 kinds; +kind 30063 has a collision — see the footnote). File paths are under +`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`. + +Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. + +| Kind | Class | Package | `indexableContent()` | +|---|---|---|---| +| 0 | MetadataEvent | nip01Core/metadata | `contactMetaData()?.let { listOfNotNull(it.name, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner).joinToString(" ") } ?: ""` (SP) | +| 1 | TextNoteEvent | nip10Notes | `listOfNotNull(subject(), content)` NL | +| 9 | ChatEvent | nipC7Chats | `content` | +| 11 | ThreadEvent | nip7DThreads | `listOfNotNull(title(), content)` NL | +| 14 | ChatMessageEvent | nip17Dm/messages | `content` | +| 20 | PictureEvent | nip68Picture | `listOfNotNull(title(), content)` NL | +| 21 | VideoNormalEvent | nip71Video | inherited `RegularVideoEvent`: `listOfNotNull(title(), content)` NL | +| 22 | VideoShortEvent | nip71Video | inherited `RegularVideoEvent`: `listOfNotNull(title(), content)` NL | +| 24 | PublicMessageEvent | nipA4PublicMessages | `content` | +| 40 | ChannelCreateEvent | nip28PublicChat/admin | `channelInfo().let { listOfNotNull(it.name, it.about, it.picture).joinToString(" ") }` (SP) | +| 41 | ChannelMetadataEvent | nip28PublicChat/admin | same as kind 40 (SP) | +| 42 | ChannelMessageEvent | nip28PublicChat/message | `content` | +| 54 | PodcastEpisodeEvent | nipF4Podcasts/episode | `listOfNotNull(title(), description(), content)` NL | +| 1010 | TextNoteModificationEvent | experimental/edits | `listOfNotNull(content, summary())` NL (content first) | +| 1063 | FileHeaderEvent | nip94FileMetadata | `listOfNotNull(summary(), content)` NL | +| 1065 | FileStorageHeaderEvent | experimental/nip95/header | `listOfNotNull(summary())` NL | +| 1068 | PollEvent | nip88Polls/poll | `buildString { append(content); options().forEach { append('\n').append(it.label) } }` | +| 1111 | CommentEvent | nip22Comments | `(listOf(content) + tags.hashtags())` NL | +| 1163 | ProfileGalleryEntryEvent | experimental/profileGallery | `listOfNotNull(summary())` NL | +| 1301 | WorkoutRecordEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL | +| 1311 | LiveActivitiesChatMessageEvent | nip53LiveActivities/chat | `(listOf(content) + tags.hashtags())` NL | +| 1312 | LiveActivitiesRaidEvent | nip53LiveActivities/raid | `content` | +| 1313 | LiveActivitiesClipEvent | nip53LiveActivities/clip | `listOfNotNull(title(), content)` NL | +| 1315 | RoadEventReportEvent | experimental/roadstr/report | `content` | +| 1337 | CodeSnippetEvent | nipC0CodeSnippets | `listOfNotNull(snippetName(), snippetDescription(), content)` NL | +| 1617 | GitPatchEvent | nip34Git/patch | `content` | +| 1618 | GitPullRequestEvent | nip34Git/pr | `listOfNotNull(subject(), content)` NL | +| 1621 | GitIssueEvent | nip34Git/issue | `listOfNotNull(subject(), content)` NL | +| 1622 | GitReplyEvent | nip34Git/reply | `content` | +| 1630–1633 | GitStatusEvent | nip34Git/status | `content` (open/applied/closed/draft) | +| 1808 | AudioHeaderEvent | experimental/audio/header | `content` | +| 1985 | LabelEvent | nip32Labeling | `(listOf(content) + labels().map { it.label }).filter { it.isNotEmpty() }` NL | +| 2003 | TorrentEvent | nip35Torrents | `listOfNotNull(title(), content)` NL | +| 2004 | TorrentCommentEvent | nip35Torrents | `content` | +| 2473 | BirdDetectionEvent | experimental/birdstar | `listOfNotNull(summary(), speciesName())` NL | +| 3302 | ConcordChatEditEvent | concord/cord03Channels | `content` | +| 5050 | NIP90TextGenerationRequestEvent | nip90Dvms/textGeneration | `inputs().filter { it.type == "prompt" \|\| it.type == "text" }.joinToString(" ") { it.value }` (SP) | +| 5100 | NIP90ImageGenerationRequestEvent | nip90Dvms/imageGeneration | `listOfNotNull(prompt(), negativePrompt()).joinToString(" ")` (SP) | +| 5129 | NappletSnapshotEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL | +| 5250 | NIP90TextToSpeechRequestEvent | nip90Dvms/textToSpeech | `text() ?: ""` | +| 5302 | NIP90ContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` | +| 5303 | NIP90PeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` | +| 6969 | ZapPollEvent | experimental/zapPolls | `buildString { append(content); pollOptionsArray().forEach { append('\n').append(it.descriptor) } }` | +| 8333 | OnchainZapEvent | nipBCOnchainZaps/zap | `content` | +| 9002 | EditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL | +| 9041 | GoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL | +| 9321 | NutzapEvent | nip61Nutzaps/nutzap | `content` | +| 9734 | LnZapRequestEvent | nip57Zaps | `content` | +| 9735 | LnZapEvent | nip57Zaps | `zapRequest?.content.orEmpty()` — indexes the **embedded 9734's** content | +| 9736 | Bolt12ZapEvent | nipB1Bolt12Zaps/zap | `content` | +| 9737 | Bolt12ZapIntentEvent | nipB1Bolt12Zaps/intent | `content` | +| 9802 | HighlightEvent | nip84Highlights | `listOfNotNull(comment(), context(), content)` NL | +| 10003 | BookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL | +| 10100 | AgentProfileEvent | buzz/agentProfiles | `profileOrNull()?.let { listOfNotNull(it.name, it.displayName).joinToString("\n") } ?: ""` | +| 10154 | PodcastMetadataEvent | nipF4Podcasts/metadata | `listOfNotNull(title(), description())` NL | +| 11871 | AttestorProficiencyEvent | experimental/attestations/proficiency | `listOfNotNull(description())` NL | +| 12473 | BirdexEvent | experimental/birdstar | `(listOfNotNull(summary()) + speciesNames())` NL | +| 15128 | RootSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL | +| 15129 | RootNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL | +| 30000 | PeopleListEvent | nip51Lists/peopleList | `listOfNotNull(titleOrName(), description())` NL | +| 30001 | OldBookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL | +| 30002 | RelaySetEvent | nip51Lists/relaySets | `listOfNotNull(title(), description())` NL | +| 30003 | LabeledBookmarkListEvent | nip51Lists/labeledBookmarkList | `listOfNotNull(titleOrName(), description())` NL | +| 30004 | ArticleCurationSetEvent | nip51Lists/articleCurationSet | `listOfNotNull(title(), description())` NL | +| 30005 | VideoCurationSetEvent | nip51Lists/videoCurationSet | `listOfNotNull(title(), description())` NL | +| 30006 | PictureCurationSetEvent | nip51Lists/pictureCurationSet | `listOfNotNull(title(), description())` NL | +| 30009 | BadgeDefinitionEvent | nip58Badges/definition | `listOfNotNull(name(), description(), content)` NL | +| 30015 | InterestSetEvent | nip51Lists/interestSet | `(listOfNotNull(title(), description()) + publicHashtags())` NL | +| 30017 | StallEvent | nip15Marketplace/stall | `stallData()?.let { listOfNotNull(it.name, it.description).joinToString("\n") } ?: ""` | +| 30018 | ProductEvent | nip15Marketplace/product | `productData()?.let { (listOfNotNull(it.name, it.description) + categories()).joinToString("\n") } ?: ""` | +| 30019 | MarketplaceEvent | nip15Marketplace/marketplace | `marketplaceData()?.let { listOfNotNull(it.name, it.about).joinToString("\n") } ?: ""` | +| 30020 | AuctionEvent | nip15Marketplace/auction | `auctionData()?.let { (listOfNotNull(it.name, it.description) + tags.hashtags()).joinToString("\n") } ?: ""` | +| 30023 | LongTextNoteEvent | nip23LongContent | `listOfNotNull(title(), summary(), content)` NL | +| 30030 | EmojiPackEvent | nip30CustomEmoji/pack | `listOfNotNull(titleOrName(), description(), content)` NL | +| 30054 | Podcasting20EpisodeEvent | nipXXPodcasting20/episode | `(listOfNotNull(title(), description(), content) + topics())` NL | +| 30055 | Podcasting20TrailerEvent | nipXXPodcasting20/trailer | `listOfNotNull(title(), content)` NL | +| 30063 | ReleaseArtifactSetEvent † | nip51Lists/releaseArtifactSet | `listOfNotNull(title(), description())` NL | +| 30175 | PersonaEvent | buzz/apPersonas | `personaOrNull()?.let { listOfNotNull(it.displayName, it.systemPrompt).joinToString("\n") } ?: ""` | +| 30176 | TeamEvent | buzz/teams | `teamOrNull()?.let { listOfNotNull(it.name, it.description, it.instructions).joinToString("\n") } ?: ""` | +| 30177 | ManagedAgentEvent | buzz/managedAgents | `agentOrNull()?.let { listOfNotNull(it.name, it.systemPrompt).joinToString("\n") } ?: ""` | +| 30267 | AppCurationSetEvent | nip51Lists/appCurationSet | `listOfNotNull(title(), description())` NL | +| 30296 | InteractiveStoryPrologueEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL | +| 30297 | InteractiveStorySceneEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL | +| 30311 | LiveActivitiesEvent | nip53LiveActivities/streaming | `listOfNotNull(title(), summary(), content)` NL | +| 30312 | MeetingSpaceEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(room(), summary(), content)` NL | +| 30313 | MeetingRoomEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(title(), summary())` NL | +| 30315 | StatusEvent | nip38UserStatus | `content` | +| 30382 | ContactCardEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content | +| 30402 | ClassifiedsEvent | nip99Classifieds | `listOfNotNull(title(), summary(), content)` NL | +| 30617 | GitRepositoryEvent | nip34Git/repository | `listOfNotNull(name(), description(), content)` NL | +| 30620 | WorkflowDefEvent | buzz/workflow | `listOfNotNull(name(), content)` NL | +| 30817 | NipTextEvent | experimental/nipsOnNostr | `listOfNotNull(title(), content)` NL | +| 30818 | WikiNoteEvent | nip54Wiki | `listOfNotNull(title(), summary(), content)` NL | +| 31337 | AudioTrackEvent | experimental/audio/track | `listOfNotNull(subject())` NL | +| 31871 | AttestationEvent | experimental/attestations/attestation | `content` | +| 31872 | AttestationRequestEvent | experimental/attestations/request | `content` | +| 31873 | AttestorRecommendationEvent | experimental/attestations/recommendation | `listOfNotNull(description())` NL | +| 31890 | FeedDefinitionEvent | feedDefinition | `title().orEmpty()` | +| 31922 | CalendarDateSlotEvent | nip52Calendar/appt/day | `listOfNotNull(title(), summary(), content)` NL | +| 31923 | CalendarTimeSlotEvent | nip52Calendar/appt/time | `listOfNotNull(title(), summary(), content)` NL | +| 31924 | CalendarEvent | nip52Calendar/calendar | `listOfNotNull(title(), content)` NL | +| 31925 | CalendarRSVPEvent | nip52Calendar/rsvp | `content` | +| 31990 | AppDefinitionEvent | nip89AppHandlers/definition | `appMetaData()?.let { listOfNotNull(it.name, it.username, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner, it.image).joinToString(" ") } ?: ""` (SP) | +| 32267 | SoftwareApplicationEvent | experimental/nip82SoftwareApps/application | `listOfNotNull(name(), summary(), content)` NL | +| 33401 | ExerciseTemplateEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL | +| 33863 | FundraiserEvent | experimental/agora | `listOfNotNull(title(), content)` NL | +| 34139 | MusicPlaylistEvent | experimental/music/playlist | `listOfNotNull(title(), description(), content)` NL | +| 34235 | VideoHorizontalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL | +| 34236 | VideoVerticalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL | +| 34550 | CommunityDefinitionEvent | nip72ModCommunities/definition | `listOfNotNull(name(), description(), rules(), content)` NL | +| 35128 | NamedSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL | +| 35129 | NamedNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL | +| 36787 | MusicTrackEvent | experimental/music/track | `listOfNotNull(title(), artist(), album(), content)` NL | +| 38000 | MintRecommendationEvent | nip87Ecash/recommendation | `content` | +| 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL | +| 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) | +| 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL | +| 39089 | FollowListEvent | nip51Lists/followList | `listOfNotNull(title(), description())` NL | +| 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL | +| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL | +| 40002 | StreamMessageV2Event | buzz/stream | `content` | +| 40100 | CanvasEvent | buzz/stream | `content` | +| 45001 | ForumPostEvent | buzz/forum | `content` | +| 45003 | ForumCommentEvent | buzz/forum | `content` | +| 48106 | HuddleGuidelinesEvent | buzz/huddles | `content` | + +† **Kind 30063 collision:** `experimental/nip82SoftwareApps/release/SoftwareReleaseEvent` also +declares `KIND = 30063` and implements `SearchableEvent` (`content`), but `EventFactory` maps +30063 to `ReleaseArtifactSetEvent`, so on every store path kind 30063 indexes +`title()\ndescription()`. If the factory mapping ever changes, this table changes with it. + +## Abstract bases (no kind of their own) + +| Base class | Body | Concrete kinds | +|---|---|---| +| `InteractiveStoryBaseEvent` | `listOfNotNull(title(), summary(), content)` NL | 30296, 30297 | +| `AddressableVideoEvent` | `listOfNotNull(title(), content)` NL | 34235, 34236 | +| `RegularVideoEvent` | `listOfNotNull(title(), content)` NL | 21, 22 | + +## How to regenerate / verify this table + +```bash +# All implementor files: +grep -rln "override fun indexableContent" quartz/src/commonMain +# For each, pair the KIND constant with the indexableContent() body. +# Searchability on the store path additionally requires EventFactory registration: +grep -n "" quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +``` + +A CI-diffable snapshot test (assert the set of kinds whose `EventFactory` product implements +`SearchableEvent` against a checked-in list) would make this table impossible to go stale — +suggested follow-up, not yet implemented. From d54e54b48ad3c5a49b2e74013fa6e29bc4df656a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:33:48 +0000 Subject: [PATCH 095/227] Add battle-tested sync accessories from vespa-relay's mirror Four pieces extracted from a production Nostr mirror (NosFabrica/vespa-relay), generalized to quartz's multiplatform primitives, with their test suites: - nip01Core.store.insertBisecting: batchInsert fails as a unit, so one bad event costs its whole batch (999 good events per bad one at a 1000-event batch). Bisecting isolates the offender in ~2*log2(n) extra writes, and a fixed write budget keeps a store-wide failure (full disk, dead engine) from turning one failed write into ~2n. - accessories.SyncBands: resume memory for fetchAllPages. Remembers the created_at band covered per (relay, filter) and asks only for the legs outside it, with inclusive edges so a page boundary cannot strand a run of same-second events. A finished negentropy reconcile records completeness through its start instant; a periodic full re-walk keeps stale claims from narrowing forever. Persistence is the caller's, via export/restore and an onChange hook. - accessories.PagingProgress: progress for paged walks measured on the time axis, the only axis whose end is known in advance - count-based percentages degenerate to downloaded/downloaded = 100%. Needs no COUNT support. - nip66RelayMonitor.reachability.HostStrikes: per-authority strike counting for outbox-scale fan-outs, where a filtering relay mints one url per user and per-url counters never converge. Ever-delivered overrides eviction in both race orders, and eviction surfaces exactly once for publishing. reachability.Unreachability (jvmAndroid): which failures may be published as a signed NIP-66 unreachable record - connection-level only, so a relay that answered the handshake and hung up mid-page is never libelled, and a caller's own bug is never the relay's fault. 57 tests pass on the JVM target; the common code uses quartz's ConcurrentMap, ConcurrentSet and TimeUtils only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y4Pi9YYMhdzTFxRiV2jF9R --- .../client/accessories/PagingProgress.kt | 124 ++++++ .../relay/client/accessories/SyncBands.kt | 275 ++++++++++++++ .../quartz/nip01Core/store/BisectingInsert.kt | 89 +++++ .../reachability/HostStrikes.kt | 133 +++++++ .../client/accessories/PagingProgressTest.kt | 140 +++++++ .../relay/client/accessories/SyncBandsTest.kt | 355 ++++++++++++++++++ .../nip01Core/store/BisectingInsertTest.kt | 200 ++++++++++ .../reachability/HostStrikesTest.kt | 176 +++++++++ .../reachability/Unreachability.kt | 48 +++ .../reachability/UnreachabilityTest.kt | 70 ++++ 10 files changed, 1610 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgress.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBands.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsert.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/HostStrikes.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgressTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBandsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsertTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/HostStrikesTest.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/Unreachability.kt create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/UnreachabilityTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgress.kt new file mode 100644 index 0000000000..028fa34979 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgress.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap +import kotlin.concurrent.Volatile + +/** + * How far a paged walk has got, measured on the time axis — the only axis + * whose end is known in advance. + * + * A paged fetch ([fetchAllPages]) has no event denominator: how many events + * exist is exactly what it is finding out, so every count-based percentage + * degenerates to `downloaded/downloaded = 100%`. The time axis has both ends + * before the first request — the filter's `until` (or now) down to its + * `since` (or [SyncBands.PLAUSIBLE_FLOOR]) — with each page's new `until` + * reporting the exact position between them. It needs no COUNT support. + * + * The estimate assumes events are spread evenly over time, which they are + * not — so it errs pessimistic on the tail, and is a bound, not a promise. + * + * One instance can serve many concurrent walks: keys are `"group|walk"`, and + * the group prefix scopes [fraction], [reached] and [etaMs] so two groups + * never report each other's numbers. + */ +class PagingProgress( + private val nowMillis: () -> Long = { TimeUtils.nowMillis() }, +) { + private class Walk( + val top: Long, + val bottom: Long, + val startedMs: Long, + @Volatile var current: Long, + ) + + private val walks = ConcurrentMap() + + /** Begin a walk over `[bottom, top]` seconds. An inverted window is not a walk. */ + fun begin( + key: String, + top: Long, + bottom: Long, + ) { + if (top > bottom) walks[key] = Walk(top, bottom, nowMillis(), top) + } + + /** The walk reached [until]; monotonic, so a page that jumps back cannot un-advance it. */ + fun mark( + key: String, + until: Long, + ) { + walks[key]?.let { + // Clamped to the walk's own floor: relays serve events stamped 0, + // and one of those would drag the position to the epoch. Below the + // floor means the walk is done, not time travel. + val reached = until.coerceAtLeast(it.bottom) + if (reached < it.current) it.current = reached + } + } + + fun finish(key: String) { + walks.remove(key) + } + + /** + * Fraction of the walk complete, averaged over every walk still going in + * [group] (or all of them when null) — averaged rather than summed + * because each covers its own span, so "half the walks done and half at + * zero" is 50%. + */ + fun fraction(group: String? = null): Double? { + val live = live(group) + if (live.isEmpty()) return null + return live.sumOf { w -> + val span = (w.top - w.bottom).coerceAtLeast(1) + ((w.top - w.current).toDouble() / span).coerceIn(0.0, 1.0) + } / live.size + } + + private fun live(group: String?): List = + if (group == null) { + walks.snapshot().values.toList() + } else { + walks + .snapshot() + .entries + .filter { it.key.startsWith("$group|") } + .map { it.value } + } + + /** The oldest second [group] has reached, or null when it is not walking. */ + fun reached(group: String? = null): Long? = live(group).minOfOrNull { it.current } + + /** Milliseconds left at the rate achieved so far, or null before it means anything. */ + fun etaMs(group: String? = null): Long? { + val f = fraction(group) ?: return null + // Under a few percent the extrapolation is dominated by connect time + // and produces numbers worse than saying nothing. + if (f < 0.02) return null + val oldestStart = live(group).minOfOrNull { it.startedMs } ?: return null + val elapsed = nowMillis() - oldestStart + if (elapsed < 5_000) return null + return ((elapsed / f) - elapsed).toLong() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBands.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBands.kt new file mode 100644 index 0000000000..68bff2ca85 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBands.kt @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap + +/** + * How much of a filter's history has already been pulled from one relay, so a + * restart does not pull it again. + * + * A negentropy relay needs none of this — reconciliation downloads only the + * diff. Most relays lack NIP-77, and a paged fetch ([fetchAllPages]) has no + * memory: it re-downloads everything it walked last time, every restart, + * forever. So for those, remember the band of `created_at` covered per + * (relay, filter), and the next run asks only for the two legs outside it: + * + * stored band: |<-------- covered -------->| + * next fetch: <------| |------> + * + * Keyed by the WHOLE filter deliberately: any edit to a filter is a new key + * with no band, so the next run starts over — the safe direction to be wrong + * in, and the intended way to force a re-walk. + * + * A band does not guarantee completeness (a truncating relay, an event + * back-dated into a walked span). The trade is deliberate: re-reading a + * corpus on every restart is a certain daily cost, while both holes are + * occasional and self-heal on the next filter change or full re-walk. + * + * Persistence is the caller's: [export] the map on a schedule and [restore] + * it at startup. [onChange] fires whenever a band changes, so a persistence + * layer can mark itself dirty without polling. + */ +class SyncBands( + // How long a band may narrow work before the whole filter is walked + // again. Everything a band claims is a claim about the past; this is how + // long to trust it without re-testing. + private val fullResyncSeconds: Long = DEFAULT_FULL_RESYNC_SECONDS, + private val now: () -> Long = { TimeUtils.now() }, + private val onChange: () -> Unit = {}, +) { + /** + * What is already covered for one (relay, filter) pair. + * + * [complete] is the difference between "we walked this span" (a paged + * fetch) and "we are in sync below this point" (a finished negentropy + * reconcile, which compared the whole range). Only a complete band may + * skip its older leg. + * + * [fullAt] is when the last pass that started from nothing finished — the + * clock for the periodic re-walk. + */ + data class Band( + val minCreatedAt: Long, + val maxCreatedAt: Long, + val complete: Boolean = false, + val fullAt: Long = 0, + ) + + private val bands = ConcurrentMap() + + // filter -> its canonical json. Filter.toJson() runs to tens of thousands + // of characters for author-scoped filters, and a fan-out keys once per + // relay per cycle over the SAME handful of filter instances. Filter + // compares by identity, so this map is an identity cache; an + // equal-but-distinct filter still keys correctly, just without the cache. + private val fingerprints = ConcurrentMap() + + /** + * The filters to actually run now, given what is already covered: the + * whole filter when nothing is recorded (or the band went stale), + * otherwise the legs outside the band, clamped to the filter's own + * `since`/`until`. + * + * The legs are INCLUSIVE of the band's edges (`until = min`, not + * `min - 1`): a page boundary can split a run of events sharing one + * `created_at`, and excluding the edge would strand the rest of that + * second in no leg at all. The cost is re-reading one second's worth of + * events per leg, which a store rejects as duplicates. + */ + fun legs( + url: NormalizedRelayUrl, + filter: Filter, + ): List { + val band = bands[key(url, filter)] ?: return listOf(filter) + // Time for another full pass: relays gain old events, and without + // this the band's claim is never re-tested. + if (isStale(band)) return listOf(filter) + val legs = mutableListOf() + + // Older: up to and including the band's floor, but not past the + // filter's. A complete band has no older leg at all — the reconcile + // already compared the whole range. + if (!band.complete && (filter.since == null || band.minCreatedAt >= filter.since)) { + legs.add(filter.copy(until = minOf(band.minCreatedAt, filter.until ?: Long.MAX_VALUE))) + } + + // Newer: from the band's ceiling on, but not past the filter's. + if (filter.until == null || band.maxCreatedAt <= filter.until) { + legs.add(filter.copy(since = maxOf(band.maxCreatedAt, filter.since ?: Long.MIN_VALUE))) + } + return legs + } + + /** + * Widen the band for (url, filter) to include what a completed fetch saw. + * + * [paged] gates the mechanism: a negentropy sync needs no band, and + * recording one would only risk narrowing a future reconciliation. + * Nothing is recorded for a fetch that saw no events — an empty result + * says nothing about what the relay holds. + * + * [reconciledThrough] is the strong case: a FINISHED reconcile compared + * the filter's whole range, so the caller is in sync up to the instant + * the sync STARTED — recorded against that instant rather than the newest + * event seen, because "the relay had nothing newer" and "we never asked" + * must not look alike. + */ + fun record( + url: NormalizedRelayUrl, + filter: Filter, + observedMin: Long?, + observedMax: Long?, + paged: Boolean, + reconciledThrough: Long? = null, + ) { + if (reconciledThrough != null) { + put(url, filter, observedMin ?: reconciledThrough, reconciledThrough, complete = true) + return + } + if (!paged) return + // Guarded even though callers should filter with [isPlausible] per + // event: a 1970 floor or a far-future ceiling would make the band + // claim the whole timeline, and the leg outside it would ask for a + // range nothing can be in, forever. + if (observedMin == null || observedMax == null) return + if (!isPlausible(observedMin, now()) || !isPlausible(observedMax, now())) return + put(url, filter, observedMin, observedMax, complete = false) + } + + /** + * Widen (or reset) the band. A pass that ran because the previous band + * had gone stale REPLACES it: it re-walked the whole filter, so its own + * span is the complete picture and [Band.fullAt] restarts from here. + */ + private fun put( + url: NormalizedRelayUrl, + filter: Filter, + min: Long, + max: Long, + complete: Boolean, + ) { + val fresh = Band(min, max, complete, now()) + bands.merge(key(url, filter), fresh) { old, new -> + if (isStale(old)) { + new + } else { + Band( + minOf(old.minCreatedAt, new.minCreatedAt), + maxOf(old.maxCreatedAt, new.maxCreatedAt), + old.complete || new.complete, + old.fullAt, + ) + } + } + onChange() + } + + private fun isStale(band: Band): Boolean = now() - band.fullAt >= fullResyncSeconds + + /** + * The narrowest single filter that still covers what every one of [urls] + * needs — the window a shared negentropy snapshot has to be taken over. + * + * In steady state every relay carries a complete band and this collapses + * to `since = the oldest of their ceilings` — the difference between + * snapshotting an id set of millions and one of a few thousand. One relay + * that has never synced puts it back to the full filter, correctly: that + * relay genuinely needs everything. + */ + fun coveringWindow( + urls: List, + filter: Filter, + ): Filter { + if (urls.isEmpty()) return filter + var since = Long.MAX_VALUE + for (url in urls) { + val legs = legs(url, filter) + // More than one leg means an older gap this relay still wants, so + // the snapshot cannot start above the filter's own floor. + val only = legs.singleOrNull() ?: return filter + val legSince = only.since ?: return filter + since = minOf(since, legSince) + } + return if (since == Long.MAX_VALUE) filter else filter.copy(since = since) + } + + /** What is currently covered, for logging and tests. */ + fun band( + url: NormalizedRelayUrl, + filter: Filter, + ): Band? = bands[key(url, filter)] + + fun size(): Int = bands.size() + + /** A point-in-time copy of every band, for a persistence layer to write out. */ + fun export(): Map = bands.snapshot() + + /** Load previously [export]ed bands, e.g. at startup. */ + fun restore(entries: Map) { + for ((key, band) in entries) bands[key] = band + } + + /** + * The identity of one (relay, filter) pair. [Filter.toJson] is the + * protocol's own canonical form, so two filters that mean the same thing + * key the same way and any edit keys differently — exactly the "config + * changed, start over" rule. + */ + private fun key( + url: NormalizedRelayUrl, + filter: Filter, + ): String = "${url.url} ${fingerprints.getOrPut(filter) { filter.toJson() }}" + + companion object { + /** + * A week. Long enough that the narrow path is the normal one, short + * enough that anything a band is wrong about is wrong for days, not + * forever. + */ + const val DEFAULT_FULL_RESYNC_SECONDS = 7L * 24 * 60 * 60 + + /** + * 2020-01-01. Below this a `created_at` is a bug, not a date — the + * protocol did not exist. Also the natural floor for measuring a + * paged walk's progress when a filter names no `since`. + */ + const val PLAUSIBLE_FLOOR = 1_577_836_800L + + // Clock skew a relay may legitimately be ahead by. Past this, a + // created_at is the author's fiction rather than a time. + private const val FUTURE_SKEW_SECONDS = 86_400L + + /** + * Whether a `created_at` can be believed as evidence of coverage. + * Filter with this per EVENT, not over a leg's aggregate: one + * misdated event among hundreds of thousands would otherwise discard + * the whole relay's band. + */ + fun isPlausible( + createdAt: Long, + now: Long = TimeUtils.now(), + ): Boolean = createdAt in PLAUSIBLE_FLOOR..(now + FUTURE_SKEW_SECONDS) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsert.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsert.kt new file mode 100644 index 0000000000..deae869a8d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsert.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlinx.coroutines.CancellationException + +/** + * Write [events] through [write]; if that throws, split the batch and write + * the halves, down to the single event the writer cannot take. + * + * A bulk write like [IEventStore.batchInsert] fails as a unit, so one bad + * event would otherwise cost the whole batch — 999 good events lost per bad + * one at a 1000-event batch, with no retry. Bisecting costs ~2·log2(n) extra + * writes on a failing batch, nothing on a healthy one, and ends holding the + * offender by itself for [onPoison] to report. Re-writing the good halves is + * safe: re-inserting an already-applied event is a duplicate the store + * rejects. + * + * Splitting assumes ONE event is at fault. When the store itself is refusing + * (a full disk, a dead engine) every half fails all the way down and + * isolation would turn one failed write into ~2n — precisely the wrong moment + * to multiply the load. So isolation spends a fixed [budget] of writes and + * hands the remainder to [onGaveUp]: "we could not say which" is a different + * fact from "this event is bad", and a caller should count them apart. + */ +suspend fun insertBisecting( + events: List, + write: suspend (List) -> List, + onOutcomes: (List) -> Unit, + onPoison: (Event, Throwable) -> Unit, + onGaveUp: (List, Throwable) -> Unit = { _, _ -> }, + budget: Int = ISOLATION_WRITE_BUDGET, +) = bisect(events, write, onOutcomes, onPoison, onGaveUp, intArrayOf(budget)) + +private suspend fun bisect( + events: List, + write: suspend (List) -> List, + onOutcomes: (List) -> Unit, + onPoison: (Event, Throwable) -> Unit, + onGaveUp: (List, Throwable) -> Unit, + budget: IntArray, +) { + if (events.isEmpty()) return + try { + onOutcomes(write(events)) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + if (events.size == 1) { + onPoison(events.single(), e) + return + } + if (budget[0] <= 0) { + onGaveUp(events, e) + return + } + budget[0] -= 2 + val mid = events.size / 2 + bisect(events.subList(0, mid), write, onOutcomes, onPoison, onGaveUp, budget) + bisect(events.subList(mid, events.size), write, onOutcomes, onPoison, onGaveUp, budget) + } +} + +/** + * Writes one batch may spend isolating its bad events before giving up. + * Isolating k bad events out of n costs about `2·k·log2(n)` writes, so 64 + * covers three in a 1000-event batch — past the rate seen in practice. What + * it really bounds is the store-wide case, where every write fails. + */ +const val ISOLATION_WRITE_BUDGET = 64 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/HostStrikes.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/HostStrikes.kt new file mode 100644 index 0000000000..11a2c2d51d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/HostStrikes.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip66RelayMonitor.reachability + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap +import com.vitorpamplona.quartz.utils.concurrent.ConcurrentSet + +/** + * Which relays are worth dialling, within one fan-out cycle. A discovered + * relay list is five figures of urls and most of them are corpses; without + * this, every cycle re-dials all of them and the working relays queue behind + * hosts that stopped existing years ago. + * + * Failures are counted per AUTHORITY (`host[:port]`), not per url: the outbox + * model mints one url per user for a filtering relay, so a per-url counter + * never reaches a threshold on any single one. The authority is host-only and + * does NOT fold a subdomain into its parent — those are different servers. + * + * [produced] overrides a strike race: a host that has ever delivered is never + * treated as dead for the rest of the cycle, whichever order the two events + * land in. Cycle-local; nothing persists — see [RelayReachabilityStore] for + * the part that survives a restart. + */ +class HostStrikes( + private val strikeLimit: Int = DEFAULT_STRIKE_LIMIT, + // Relays a previous run proved unreachable, and still within their TTL. + private val knownDead: Set = emptySet(), +) { + private val strikes = ConcurrentMap() + private val deadHosts = ConcurrentSet() + private val producedHosts = ConcurrentSet() + + private val delivered = ConcurrentSet() + private val failed = ConcurrentSet() + + /** Relays this cycle actually got something from — worth remembering as live. */ + val reachable: Set get() = delivered.snapshot() + + /** Relays this cycle could not reach at all. A relay that later delivered is not in it. */ + val unreachable: Set get() = failed.snapshot() - delivered.snapshot() + + /** + * Skip this relay? True when a previous run proved it dead (and no + * [produced] since), or when its whole authority has been struck out here. + */ + fun isDead(url: NormalizedRelayUrl): Boolean { + val authority = authorityOf(url.url) + if (authority in producedHosts) return false + return url in knownDead || authority in deadHosts + } + + /** + * This relay connected but delivered nothing before giving up. Count it + * against its authority and, at [strikeLimit], stop dialling the host. + * Returns the eviction — for the caller to publish — exactly when this + * strike is the one that took the host down: that is the only point where + * the evidence exists, because every sibling url is skipped without being + * dialled from here on. + */ + fun strike(url: NormalizedRelayUrl): Evicted? { + failed.add(url) + if (strikeLimit <= 0) return null + val authority = authorityOf(url.url) + if (authority in producedHosts || authority in deadHosts) return null + if (strikes.merge(authority, 1) { old, new -> old + new } < strikeLimit) return null + deadHosts.add(authority) + return Evicted(authority, strikeLimit) + } + + /** An authority struck out, and the evidence for it. */ + class Evicted( + val authority: String, + val strikes: Int, + ) + + /** This relay delivered. Its authority is alive, whatever else happened. */ + fun produced(url: NormalizedRelayUrl) { + delivered.add(url) + producedHosts.add(authorityOf(url.url)) + } + + /** For a cycle's closing line: how many hosts were dropped. */ + fun evictedHosts(): Int = deadHosts.size() + + fun summary(total: Int): String = + "${reachable.size} live, ${unreachable.size} unreachable, " + + "${deadHosts.size()} host(s) struck out, ${knownDead.size} skipped as known-dead of $total" + + companion object { + /** + * Three, because a single timeout is ordinary — a busy relay that + * never answered one REQ is not a dead one — while three separate + * urls on the same host all going silent is a server, not a + * coincidence. + */ + const val DEFAULT_STRIKE_LIMIT = 3 + + /** + * `host[:port]` — everything between the scheme and the first path + * slash. The port is part of it: two ports on one machine are two + * relays. + */ + fun authorityOf(url: String): String { + val afterScheme = + when { + url.startsWith("wss://") -> url.substring(6) + url.startsWith("ws://") -> url.substring(5) + else -> url + } + val slash = afterScheme.indexOf('/') + return if (slash >= 0) afterScheme.substring(0, slash) else afterScheme + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgressTest.kt new file mode 100644 index 0000000000..bf8a550c63 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgressTest.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PagingProgressTest { + private fun assertClose( + expected: Double, + actual: Double?, + message: String? = null, + ) { + assertTrue(actual != null && kotlin.math.abs(expected - actual) < 0.001, "${message ?: ""} expected $expected got $actual") + } + + @Test + fun `progress is the walked share of the time window`() { + val p = PagingProgress() + p.begin("a", top = 1_000L, bottom = 0L) + + assertClose(0.0, p.fraction(), "nothing walked yet") + + p.mark("a", 750L) + assertClose(0.25, p.fraction()) + + p.mark("a", 100L) + assertClose(0.90, p.fraction()) + } + + @Test + fun `a page that jumps backwards cannot un-advance the walk`() { + // Pages arrive from one relay in order, but nothing in the protocol + // guarantees it, and a percentage that goes DOWN is worse than one that + // is slightly wrong — it reads as the sync having lost ground. + val p = PagingProgress() + p.begin("a", top = 1_000L, bottom = 0L) + + p.mark("a", 200L) + p.mark("a", 900L) + + assertClose(0.80, p.fraction(), "the later higher until is ignored") + } + + @Test + fun `walks average rather than sum`() { + // Two relays each walking their own window: one done and one untouched + // is half way — not 100% as summing would give. + val p = PagingProgress() + p.begin("a", top = 1_000L, bottom = 0L) + p.begin("b", top = 500L, bottom = 0L) + + p.mark("a", 0L) + + assertClose(0.5, p.fraction()) + } + + @Test + fun `a finished walk leaves the average`() { + val p = PagingProgress() + p.begin("a", top = 1_000L, bottom = 0L) + p.begin("b", top = 1_000L, bottom = 0L) + p.mark("b", 500L) + + p.finish("a") + + assertClose(0.5, p.fraction(), "only b is still walking") + p.finish("b") + assertNull(p.fraction(), "nothing walking means no number to report") + } + + @Test + fun `a group prefix scopes the numbers to its own walks`() { + // One instance serves many concurrent walks; without the scope two + // streams would print each other's percentages. + val p = PagingProgress() + p.begin("streamA|wss://r1", top = 1_000L, bottom = 0L) + p.begin("streamB|wss://r2", top = 1_000L, bottom = 0L) + p.mark("streamA|wss://r1", 0L) + + assertClose(1.0, p.fraction("streamA")) + assertClose(0.0, p.fraction("streamB")) + assertClose(0.5, p.fraction()) + } + + @Test + fun `an inverted or empty window is not a walk`() { + // A leg whose since is above its until asks for a range nothing can be + // in. Dividing by that span would produce infinities on the status line. + val p = PagingProgress() + + p.begin("a", top = 100L, bottom = 900L) + + assertNull(p.fraction()) + } + + @Test + fun `no ETA before the estimate means anything`() { + val p = PagingProgress() + p.begin("a", top = 1_000_000L, bottom = 0L) + + p.mark("a", 999_000L) + + // 0.1% in: extrapolating here yields days-long ETAs from connect + // latency alone, which is worse than printing nothing. + assertNull(p.etaMs(), "too early to extrapolate") + } + + @Test + fun `ETA extrapolates from the rate achieved so far`() { + var clock = 1_000_000L + val p = PagingProgress(nowMillis = { clock }) + p.begin("a", top = 1_000L, bottom = 0L) + p.mark("a", 500L) + + // Half way after six seconds: whatever has elapsed is also what remains. + clock += 6_000 + assertEquals(6_000L, p.etaMs()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBandsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBandsTest.kt new file mode 100644 index 0000000000..d0ffe5290a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBandsTest.kt @@ -0,0 +1,355 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * A paged relay has no memory of what it already sent, so without a band every + * restart re-downloads its whole corpus. These pin the band arithmetic and, more + * importantly, the cases where a band must NOT be used — a stale band silently + * skips events, which is a worse failure than re-reading them. + */ +class SyncBandsTest { + private val relay = RelayUrlNormalizer.normalize("wss://relay.example") + private val other = RelayUrlNormalizer.normalize("wss://other.example") + private val profiles = Filter(kinds = listOf(0)) + + private fun now(): Long = TimeUtils.now() + + // ---- the band arithmetic ---------------------------------------------- + + @Test + fun `with nothing recorded the whole filter is fetched`() { + val c = SyncBands() + assertEquals(listOf(profiles), c.legs(relay, profiles)) + } + + @Test + fun `a recorded band is fetched around rather than through`() { + val c = SyncBands() + c.record(relay, profiles, observedMin = 1_700_001_000L, observedMax = 1_700_002_000L, paged = true) + + val legs = c.legs(relay, profiles) + assertEquals(2, legs.size, "one leg older than the band and one newer") + assertEquals(1_700_001_000L, legs[0].until, "older leg stops AT the band floor") + assertNull(legs[0].since, "and reaches as far back as the filter allows") + assertEquals(1_700_002_000L, legs[1].since, "newer leg starts AT its ceiling") + assertNull(legs[1].until) + } + + @Test + fun `an event sharing the band boundary second is still reachable`() { + // A paged relay cuts pages by count, so a boundary can fall inside a run + // of events sharing one created_at. Excluding the edge would strand the + // rest of that second in no leg at all, while the band called it covered. + val c = SyncBands() + c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) + + val legs = c.legs(relay, profiles) + + fun reachable(t: Long) = legs.any { (it.since ?: Long.MIN_VALUE) <= t && t <= (it.until ?: Long.MAX_VALUE) } + + assertTrue(reachable(1_700_001_000L), "the band floor second must be re-read") + assertTrue(reachable(1_700_002_000L), "and its ceiling second") + assertTrue(reachable(1_700_000_999L), "below the band") + assertTrue(reachable(1_700_002_001L), "above it") + // Only the interior is skipped, which is the entire point. + assertTrue(!reachable(1_700_001_500L), "the covered interior is not re-read") + } + + @Test + fun `successive runs widen the band rather than replacing it`() { + val c = SyncBands() + c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) + // A later run reaches further back and picks up newer events. + c.record(relay, profiles, 1_700_000_500L, 1_700_002_500L, paged = true) + + val band = c.band(relay, profiles)!! + assertEquals(1_700_000_500L, band.minCreatedAt) + assertEquals(1_700_002_500L, band.maxCreatedAt) + } + + @Test + fun `a capped relay walks further back on each run`() { + // The case that makes this worth having: a relay that only ever answers + // with its newest N events. Each run starts below the last one's floor. + val c = SyncBands() + c.record(relay, profiles, 1_700_009_000L, 1_700_010_000L, paged = true) + assertEquals(1_700_009_000L, c.legs(relay, profiles)[0].until) + + c.record(relay, profiles, 1_700_008_000L, 1_700_008_999L, paged = true) + assertEquals(1_700_008_000L, c.legs(relay, profiles)[0].until) + } + + // ---- when a band must not be used -------------------------------------- + + @Test + fun `a negentropy sync that reported no outcome records nothing`() { + // Only a sync that says how far it reconciled earns a band; a bare + // paged=false call carries no claim to record. + val c = SyncBands() + c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = false) + assertNull(c.band(relay, profiles)) + assertEquals(listOf(profiles), c.legs(relay, profiles)) + } + + // ---- coverage: what a finished reconcile earns ------------------------- + + @Test + fun `a finished reconcile is in sync through the instant it started`() { + // Not through the newest event it happened to see: "the relay had nothing + // newer" and "we never asked" must not record the same thing. + val c = SyncBands() + val startedAt = now() - 60 + c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = false, reconciledThrough = startedAt) + + val band = c.band(relay, profiles)!! + assertTrue(band.complete) + assertEquals(startedAt, band.maxCreatedAt) + } + + @Test + fun `a reconcile that downloaded nothing still records coverage`() { + // The empty case is the WHOLE point: nothing came back because we already + // have it, and that is exactly when the next run should ask for a sliver. + val c = SyncBands() + val startedAt = now() - 60 + c.record(relay, profiles, null, null, paged = false, reconciledThrough = startedAt) + + val leg = c.legs(relay, profiles).single() + assertEquals(startedAt, leg.since) + assertNull(leg.until) + } + + @Test + fun `a complete band drops its older leg while a paged one keeps it`() { + val reconciled = SyncBands() + reconciled.record(relay, profiles, null, null, paged = false, reconciledThrough = 1_700_002_000L) + val only = reconciled.legs(relay, profiles).single() + assertEquals(1_700_002_000L, only.since) + + val walked = SyncBands() + walked.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) + assertEquals(2, walked.legs(relay, profiles).size, "a paged walk says nothing about what it never asked for") + } + + // ---- the periodic full re-walk ----------------------------------------- + + @Test + fun `a band stops narrowing once it is older than the resync period`() { + val c = SyncBands(fullResyncSeconds = 60) + c.record(relay, profiles, null, null, paged = false, reconciledThrough = now() - 3600) + // Recorded 'now' whatever the created_at claim, so age it by rewriting. + c.record(relay, profiles, null, null, paged = false, reconciledThrough = now()) + assertEquals(1, c.legs(relay, profiles).size, "fresh band still narrows") + + val stale = SyncBands(fullResyncSeconds = 0) + stale.record(relay, profiles, null, null, paged = false, reconciledThrough = now()) + assertSame(profiles, stale.legs(relay, profiles).single(), "a band past its period re-walks everything") + } + + @Test + fun `the re-walk replaces the old claim instead of widening it`() { + // Widening would carry the stale band's floor forward forever and the + // periodic pass would never actually reset anything. + val c = SyncBands(fullResyncSeconds = 0) + c.record(relay, profiles, 1_700_000_000L, 1_700_001_000L, paged = true) + c.record(relay, profiles, 1_700_005_000L, 1_700_006_000L, paged = true) + + val band = c.band(relay, profiles)!! + assertEquals(1_700_005_000L, band.minCreatedAt, "the second pass walked everything; its span is the whole picture") + } + + // ---- the shared snapshot window ---------------------------------------- + + @Test + fun `covering window collapses to the oldest ceiling once everyone is caught up`() { + val c = SyncBands() + c.record(relay, profiles, null, null, paged = false, reconciledThrough = 1_700_009_000L) + c.record(other, profiles, null, null, paged = false, reconciledThrough = 1_700_003_000L) + + assertEquals(1_700_003_000L, c.coveringWindow(listOf(relay, other), profiles).since) + } + + @Test + fun `one relay that has never synced puts the window back to the whole filter`() { + // It genuinely needs everything — narrowing the shared snapshot would + // reconcile it against ids we never looked up. + val c = SyncBands() + c.record(relay, profiles, null, null, paged = false, reconciledThrough = 1_700_009_000L) + + // The filter itself, unnarrowed — identity, since Filter has no equals. + assertSame(profiles, c.coveringWindow(listOf(relay, other), profiles)) + assertSame(profiles, c.coveringWindow(emptyList(), profiles)) + } + + @Test + fun `one shared window serves a whole stream of relays`() { + // Every url in a stream shares that stream's filter, so a backfill can + // take ONE snapshot for all of them instead of walking the identical + // range once per relay for byte-identical answers. + val c = SyncBands() + val third = RelayUrlNormalizer.normalize("wss://third.example") + c.record(relay, profiles, null, null, paged = false, reconciledThrough = 1_700_009_000L) + c.record(other, profiles, null, null, paged = false, reconciledThrough = 1_700_003_000L) + c.record(third, profiles, null, null, paged = false, reconciledThrough = 1_700_007_000L) + + // The hungriest of them sets the floor; the other two re-read a little. + assertEquals(1_700_003_000L, c.coveringWindow(listOf(relay, other, third), profiles).since) + } + + @Test + fun `a relay with an older gap also widens the shared window`() { + val c = SyncBands() + c.record(relay, profiles, null, null, paged = false, reconciledThrough = 1_700_009_000L) + c.record(other, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) + + assertSame(profiles, c.coveringWindow(listOf(relay, other), profiles)) + } + + @Test + fun `an empty fetch records nothing`() { + // No events says nothing about what the relay holds, only that this + // window was empty — recording it would fabricate coverage. + val c = SyncBands() + c.record(relay, profiles, null, null, paged = true) + assertNull(c.band(relay, profiles)) + } + + @Test + fun `one misdated event does not cost a relay its whole band`() { + // A single future-dated stamp among hundreds of thousands must not fail + // a check applied to the aggregate. Screening per event keeps the rest. + val c = SyncBands() + val far = now() + 400L * 86_400 + val observed = listOf(1_700_001_000L, far, 1_700_002_000L, 0L) + + val plausible = observed.filter { SyncBands.isPlausible(it) } + c.record(relay, profiles, plausible.min(), plausible.max(), paged = true) + + val band = c.band(relay, profiles)!! + assertEquals(1_700_001_000L, band.minCreatedAt) + assertEquals(1_700_002_000L, band.maxCreatedAt) + } + + @Test + fun `changing the filter starts over`() { + val c = SyncBands() + c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) + + // Widening the kinds means the old band skipped events it never fetched. + val wider = Filter(kinds = listOf(0, 10002)) + assertEquals(listOf(wider), c.legs(relay, wider), "a new filter has no band") + assertNull(c.band(relay, wider)) + // ...and the original is untouched, so reverting resumes where it was. + assertEquals(1_700_001_000L, c.band(relay, profiles)!!.minCreatedAt) + } + + @Test + fun `each relay keeps its own band`() { + val c = SyncBands() + c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) + assertEquals(listOf(profiles), c.legs(other, profiles)) + } + + // ---- the filter's own bounds still win --------------------------------- + + @Test + fun `a bounded filter never widens past its own since and until`() { + val bounded = Filter(kinds = listOf(0), since = 1_700_001_000L, until = 1_700_005_000L) + val c = SyncBands() + c.record(relay, bounded, 1_700_002_000L, 1_700_003_000L, paged = true) + + val legs = c.legs(relay, bounded) + assertEquals(2, legs.size) + assertEquals(1_700_001_000L, legs[0].since, "the older leg keeps the configured floor") + assertEquals(1_700_002_000L, legs[0].until) + assertEquals(1_700_003_000L, legs[1].since) + assertEquals(1_700_005_000L, legs[1].until, "the newer leg keeps the configured ceiling") + } + + @Test + fun `a fully covered bounded filter re-reads only its two edge seconds`() { + // Inclusive edges mean "covered" can never quite mean "ask for nothing": + // the two boundary seconds are always re-read, because that is the only + // way to catch a run of same-second events a page boundary cut in half. + val bounded = Filter(kinds = listOf(0), since = 1_700_001_000L, until = 1_700_005_000L) + val c = SyncBands() + c.record(relay, bounded, 1_700_001_000L, 1_700_005_000L, paged = true) + + val legs = c.legs(relay, bounded) + assertEquals(2, legs.size) + assertEquals(1_700_001_000L to 1_700_001_000L, legs[0].since to legs[0].until, "the floor second only") + assertEquals(1_700_005_000L to 1_700_005_000L, legs[1].since to legs[1].until, "the ceiling second only") + } + + // ---- persistence hooks -------------------------------------------------- + + @Test + fun `export and restore round-trip the bands`() { + val c = SyncBands() + c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) + + val reopened = SyncBands() + reopened.restore(c.export()) + + val band = reopened.band(relay, profiles)!! + assertEquals(1_700_001_000L, band.minCreatedAt) + assertEquals(1_700_002_000L, band.maxCreatedAt) + } + + @Test + fun `onChange fires when a band changes so persistence can mark dirty`() { + var changes = 0 + val c = SyncBands(onChange = { changes++ }) + + c.record(relay, profiles, null, null, paged = true) + assertEquals(0, changes, "an empty fetch records nothing and must not dirty the store") + + c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) + assertEquals(1, changes) + } + + @Test + fun `the same filter instance is fingerprinted once`() { + // Filter.toJson() runs to tens of thousands of characters for an + // author-scoped filter, and a fan-out keys once per relay per cycle. + val big = Filter(kinds = listOf(30382), authors = (1..500).map { it.toString(16).padStart(64, '0') }) + val c = SyncBands() + c.record(relay, big, 1_700_001_000L, 1_700_002_000L, paged = true) + + // Same instance, many lookups: still one band, and cheap. + repeat(50) { c.legs(relay, big) } + assertEquals(1_700_001_000L, c.band(relay, big)!!.minCreatedAt) + + // An equal-but-distinct instance keys the same way; it just misses the cache. + val copy = Filter(kinds = listOf(30382), authors = (1..500).map { it.toString(16).padStart(64, '0') }) + assertEquals(1_700_001_000L, c.band(relay, copy)?.minCreatedAt, "identity caching must not change the key") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsertTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsertTest.kt new file mode 100644 index 0000000000..2b813995c9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsertTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * A bulk write fails as a unit, so without isolation one event the store cannot + * take costs its whole batch — 999 good events per bad one at the default size, + * dropped silently and counted as a multiple of the batch rather than as a number + * of bad events. These pin the isolation that stops that. + */ +class BisectingInsertTest { + private fun event(n: Int) = + Event( + id = n.toString().padStart(64, '0'), + pubKey = "a1".repeat(32), + createdAt = 1_700_000_000L + n, + kind = 1, + tags = emptyArray(), + content = "e$n", + sig = "b2".repeat(32), + ) + + /** Accepts everything except the named ids, which make the whole write throw. */ + private class Writer( + private val poison: Set, + ) { + val calls = mutableListOf() + var eventsWritten = 0 + + suspend fun write(batch: List): List { + calls.add(batch.size) + batch.firstOrNull { it.id in poison }?.let { + throw IndexOutOfBoundsException("Index: 1 Size: 1") + } + eventsWritten += batch.size + return batch.map { IEventStore.InsertOutcome.Accepted } + } + } + + private val gaveUp = mutableListOf() + + private suspend fun run( + events: List, + poison: Set, + ): Triple>> { + val writer = Writer(poison) + var accepted = 0 + val poisoned = mutableListOf>() + gaveUp.clear() + insertBisecting( + events = events, + write = { writer.write(it) }, + onOutcomes = { accepted += it.size }, + onPoison = { e, t -> poisoned.add(e to t) }, + onGaveUp = { batch, _ -> gaveUp.add(batch.size) }, + ) + return Triple(writer, accepted, poisoned) + } + + @Test + fun `a healthy batch is written once and costs nothing extra`() = + runTest { + val events = (1..64).map(::event) + val (writer, accepted, poisoned) = run(events, emptySet()) + + assertEquals(listOf(64), writer.calls, "no bisection on a batch that works") + assertEquals(64, accepted) + assertTrue(poisoned.isEmpty()) + } + + @Test + fun `one poison event costs only itself and not the batch`() = + runTest { + val events = (1..64).map(::event) + val bad = events[37].id + val (_, accepted, poisoned) = run(events, setOf(bad)) + + // This is the whole point: 63 of 64 still land. + assertEquals(63, accepted, "every event except the poison one must still be written") + assertEquals(1, poisoned.size) + assertEquals(bad, poisoned.single().first.id, "the isolated event is the one that throws") + assertTrue(poisoned.single().second is IndexOutOfBoundsException) + } + + @Test + fun `isolating stays logarithmic instead of falling back to one-by-one`() = + runTest { + val events = (1..1024).map(::event) + val (writer, accepted, _) = run(events, setOf(events[500].id)) + + assertEquals(1023, accepted) + // ~2*log2(n) writes, nowhere near the 1024 a per-event fallback would cost. + assertTrue(writer.calls.size < 32, "expected a logarithmic split, got ${writer.calls.size} writes") + } + + @Test + fun `several poison events are each isolated`() = + runTest { + val events = (1..64).map(::event) + val bad = setOf(events[0].id, events[31].id, events[63].id) + val (_, accepted, poisoned) = run(events, bad) + + assertEquals(61, accepted) + assertEquals(bad, poisoned.map { it.first.id }.toSet()) + } + + @Test + fun `a store-wide failure gives up instead of splitting all the way down`() = + runTest { + // Everything fails — a full disk, a dead engine. Splitting to singletons + // would cost ~2n writes at the worst possible moment. + val events = (1..1024).map(::event) + val (writer, accepted, poisoned) = run(events, events.map { it.id }.toSet()) + + assertEquals(0, accepted) + assertTrue( + writer.calls.size < 100, + "a store-wide failure must not cost ~2n writes; spent ${writer.calls.size}", + ) + // Nothing is silently lost: whatever isolation could not name is still + // handed back, so the caller can count it. + assertEquals(1024, poisoned.size + gaveUp.sum(), "every event must be accounted for") + assertTrue(gaveUp.isNotEmpty(), "the remainder should be reported as unisolated") + } + + @Test + fun `the budget is spent isolating rather than hoarded`() = + runTest { + // One bad event in 1024 must still be found — the guard bounds the + // pathological case without breaking the case it was built for. + val events = (1..1024).map(::event) + val (_, accepted, poisoned) = run(events, setOf(events[900].id)) + + assertEquals(1023, accepted) + assertEquals(events[900].id, poisoned.single().first.id) + assertTrue(gaveUp.isEmpty(), "a single bad event fits well inside the budget") + } + + @Test + fun `a batch of nothing but poison loses nothing else`() = + runTest { + val events = (1..4).map(::event) + val (_, accepted, poisoned) = run(events, events.map { it.id }.toSet()) + + assertEquals(0, accepted) + assertEquals(4, poisoned.size, "each one named, rather than one count of four") + } + + @Test + fun `cancellation propagates instead of being mistaken for a poison event`() = + runTest { + // Shutdown cancels the ingest scope mid-write. Treating that as "this + // event is bad" would drop good events and keep bisecting while the + // caller is trying to stop. + val events = (1..16).map(::event) + assertFailsWith { + insertBisecting( + events = events, + write = { throw CancellationException("shutting down") }, + onOutcomes = { }, + onPoison = { _, _ -> error("cancellation must not be reported as poison") }, + ) + } + } + + @Test + fun `an empty batch is a no-op`() = + runTest { + val (writer, accepted, poisoned) = run(emptyList(), emptySet()) + assertTrue(writer.calls.isEmpty()) + assertEquals(0, accepted) + assertTrue(poisoned.isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/HostStrikesTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/HostStrikesTest.kt new file mode 100644 index 0000000000..d86141992a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/HostStrikesTest.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip66RelayMonitor.reachability + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * An outbox list is five figures of urls and mostly corpses. These pin the two + * rules that make the difference: failures count per HOST (or a filtering + * relay's hundreds of per-user urls never add up to anything), and a host that + * has ever delivered is never dropped (or a fan-out this wide sheds working + * relays on a race). + */ +class HostStrikesTest { + private fun url(u: String) = RelayUrlNormalizer.normalize(u) + + // ---- the authority key -------------------------------------------------- + + @Test + fun `per-user path urls on one host share an authority`() { + val a = HostStrikes.authorityOf("wss://filter.nostr.wine/npub1aaaa?broadcast=true") + val b = HostStrikes.authorityOf("wss://filter.nostr.wine/npub1bbbb") + assertEquals("filter.nostr.wine", a) + assertEquals(a, b) + assertEquals(a, HostStrikes.authorityOf("wss://filter.nostr.wine")) + } + + @Test + fun `a subdomain is not folded into its parent`() { + // Different servers. Shedding the filtering one must never take out the + // bare host, which may be perfectly open. + assertTrue( + HostStrikes.authorityOf("wss://filter.nostr.wine/npub1x") != + HostStrikes.authorityOf("wss://nostr.wine"), + ) + } + + @Test + fun `the port is part of the authority`() { + assertEquals("relay.example.com:443", HostStrikes.authorityOf("wss://relay.example.com:443/npub1z")) + assertTrue( + HostStrikes.authorityOf("wss://example.com:443") != HostStrikes.authorityOf("wss://example.com:8080"), + ) + } + + // ---- striking ----------------------------------------------------------- + + @Test + fun `one host is struck out by failures spread across its many urls`() { + // The whole point: no single url would ever reach the threshold alone. + val h = HostStrikes() + h.strike(url("wss://filter.example/npub1aaa")) + h.strike(url("wss://filter.example/npub1bbb")) + assertFalse(h.isDead(url("wss://filter.example/npub1ccc")), "two strikes is not yet a verdict") + + h.strike(url("wss://filter.example/npub1ccc")) + assertTrue(h.isDead(url("wss://filter.example/npub1ddd")), "the host is out — including urls never tried") + } + + @Test + fun `eviction returns a verdict exactly once for publishing`() { + // The eviction is the only finding that will ever exist about the sibling + // urls under this host: from here they are skipped without being dialled, + // so nothing observes them again. It must surface exactly once — silent + // would publish nothing, repeated would rewrite the record every strike. + val h = HostStrikes() + assertNull(h.strike(url("wss://filter.example/npub1")), "one strike is not a verdict") + assertNull(h.strike(url("wss://filter.example/npub2")), "two is not either") + + val evicted = h.strike(url("wss://filter.example/npub3")) + assertNotNull(evicted, "the third strike is the finding") + assertEquals("filter.example", evicted.authority) + assertEquals(3, evicted.strikes) + + assertNull(h.strike(url("wss://filter.example/npub4")), "already evicted — do not report it again") + } + + @Test + fun `a host that has delivered is never evicted so nothing is published`() { + val h = HostStrikes() + h.produced(url("wss://busy.example/npubY")) + repeat(5) { assertNull(h.strike(url("wss://busy.example/npub$it")), "ever-produced outranks any strike") } + } + + @Test + fun `striking one host leaves every other alone`() { + val h = HostStrikes() + repeat(5) { h.strike(url("wss://filter.example/npub$it")) } + assertTrue(h.isDead(url("wss://filter.example/npub1"))) + assertFalse(h.isDead(url("wss://example.com"))) + assertFalse(h.isDead(url("wss://other.example"))) + } + + @Test + fun `a host that ever delivered is never dead whichever way the race lands`() { + // At a hundred relays in flight one worker can strike an authority out at + // the same instant another is receiving from it. Ever-produced must win in + // both orders, which is why it overrides rather than clearing strikes. + val strikeFirst = HostStrikes() + repeat(3) { strikeFirst.strike(url("wss://busy.example/npub$it")) } + assertTrue(strikeFirst.isDead(url("wss://busy.example/npubX"))) + strikeFirst.produced(url("wss://busy.example/npubY")) + assertFalse(strikeFirst.isDead(url("wss://busy.example/npubX")), "a delivery revives the whole host") + + val produceFirst = HostStrikes() + produceFirst.produced(url("wss://busy.example/npubY")) + repeat(5) { produceFirst.strike(url("wss://busy.example/npub$it")) } + assertFalse(produceFirst.isDead(url("wss://busy.example/npubX")), "later strikes cannot bury it") + } + + @Test + fun `a zero strike limit disables eviction entirely`() { + val h = HostStrikes(strikeLimit = 0) + repeat(50) { h.strike(url("wss://filter.example/npub$it")) } + assertFalse(h.isDead(url("wss://filter.example/npub1"))) + } + + // ---- what a previous run already learned --------------------------------- + + @Test + fun `a relay a previous run proved dead is skipped without dialling`() { + val gone = url("wss://gone.example") + val h = HostStrikes(knownDead = setOf(gone)) + assertTrue(h.isDead(gone)) + assertFalse(h.isDead(url("wss://alive.example"))) + } + + @Test + fun `a known-dead relay that answers anyway is believed over the record`() { + // Relays come back. A TTL'd record is "not now" and never "never again": + // a delivery this cycle must beat what an earlier one wrote down. + val back = url("wss://back.example") + val h = HostStrikes(knownDead = setOf(back)) + h.produced(back) + assertFalse(h.isDead(back)) + } + + // ---- what gets written back --------------------------------------------- + + @Test + fun `only relays actually dialled are reported and delivery clears a failure`() { + val h = HostStrikes() + val good = url("wss://good.example") + val bad = url("wss://bad.example") + h.strike(bad) + h.strike(good) + h.produced(good) + + assertEquals(setOf(good), h.reachable) + assertEquals(setOf(bad), h.unreachable, "a relay that later delivered is not reported dead") + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/Unreachability.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/Unreachability.kt new file mode 100644 index 0000000000..a9b1cf634e --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/Unreachability.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip66RelayMonitor.reachability + +/** + * Whether a failure may be published as "this relay is unreachable". + * + * The distinction matters because the answer is PUBLISHED: a negative NIP-66 + * record is a signed, public statement about someone else's server. A relay + * that completes a handshake and then hangs up mid-page is emphatically + * reachable, and an exception thrown by the caller's own code says nothing + * about the relay at all. So this asks only about the connection itself — + * name resolution, routing, refusal, TLS. + * + * Unknown failures stay quiet: the cost of silence is one retry next cycle, + * the cost of being wrong is a false record carrying the monitor's signature. + */ +object Unreachability { + fun proves(e: Exception): Boolean = + when (e) { + is java.net.UnknownHostException, + is java.net.ConnectException, + is java.net.NoRouteToHostException, + is java.net.PortUnreachableException, + is javax.net.ssl.SSLHandshakeException, + -> true + + else -> false + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/UnreachabilityTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/UnreachabilityTest.kt new file mode 100644 index 0000000000..066f4b4b67 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/UnreachabilityTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip66RelayMonitor.reachability + +import java.io.EOFException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.net.ssl.SSLHandshakeException +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * What a monitor is willing to SAY about someone else's relay. A negative + * NIP-66 record is signed and public, so only failures of the connection + * itself may be published as "unreachable". + */ +class UnreachabilityTest { + private fun proves(e: Exception) = Unreachability.proves(e) + + @Test + fun `a connection that never opened is unreachable`() { + assertTrue(proves(UnknownHostException("no such host"))) + assertTrue(proves(ConnectException("connection refused"))) + assertTrue(proves(SSLHandshakeException("cert expired"))) + } + + @Test + fun `a relay that hung up mid-transfer is not unreachable`() { + // A relay that answers the handshake in 50ms and then sends EOFException + // part-way through a large page is reachable; it declined to finish a + // query. Publishing "unreachable" would be a false statement about a + // working server. + assertFalse(proves(EOFException("stream closed"))) + } + + @Test + fun `our own bug is never the relay's fault`() { + assertFalse(proves(ConcurrentModificationException())) + assertFalse(proves(NullPointerException())) + assertFalse(proves(ClassCastException("HashMap\$Node cannot be cast"))) + } + + @Test + fun `an unrecognised failure stays quiet`() { + // Conservative on purpose: staying quiet costs one retry next cycle, + // being wrong costs a false record carrying the monitor's signature. + assertFalse(proves(SocketTimeoutException("read timed out"))) + assertFalse(proves(RuntimeException("something new"))) + } +} From b46063713e81a494dcf6867698075eca04ae72bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:48:04 +0000 Subject: [PATCH 096/227] Centralize the insert-rejection vocabulary in RejectionReason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every store spelled its rejection reasons inline — the expired-event string was duplicated four times across ObservableEventStore and SQLiteEventStore. RejectionReason now carries the NIP-01 OK machine-readable prefixes plus the standard store reasons, so InsertOutcome.Rejected tallies and OK-frame building see one vocabulary no matter which store produced the outcome. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW --- .../quartz/nip01Core/store/IEventStore.kt | 2 +- .../nip01Core/store/ObservableEventStore.kt | 2 +- .../quartz/nip01Core/store/RejectionReason.kt | 58 +++++++++++++++++++ .../store/sqlite/SQLiteEventStore.kt | 9 +-- 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index 8f77817522..68e1b18fb9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -122,7 +122,7 @@ interface IEventStore : AutoCloseable { insert(event) InsertOutcome.Accepted } catch (e: Throwable) { - InsertOutcome.Rejected(e.message ?: e::class.simpleName ?: "insert failed") + InsertOutcome.Rejected(e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt index c314c4e3b2..6111f2106e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt @@ -114,7 +114,7 @@ class ObservableEventStore( if (event.kind.isEphemeral()) { outcomes[i] = if (event.isExpired()) { - IEventStore.InsertOutcome.Rejected("blocked: Cannot insert an expired event") + IEventStore.InsertOutcome.Rejected(RejectionReason.EXPIRED) } else { IEventStore.InsertOutcome.Accepted } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt new file mode 100644 index 0000000000..a9262e6482 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store + +/** + * The machine-readable insert-rejection vocabulary, shared by every + * [IEventStore] implementation so the same condition always rejects with the + * same words — a caller tallying [IEventStore.InsertOutcome.Rejected] reasons, + * or a relay building `OK false` frames, must never see two stores spell + * "duplicate" differently. + * + * NIP-01: an `OK false` message SHOULD begin with a single-word + * machine-readable prefix followed by `:`. The `PREFIX_*` constants are that + * vocabulary; the full-sentence constants are the standard reasons the + * built-in stores emit. `replaced:` is not in NIP-01 but is the de-facto + * prefix (strfry and others) for a replaceable event that lost to a stored + * newer version — distinct from `duplicate:` (the exact event is already + * held). + */ +object RejectionReason { + // The NIP-01 machine-readable prefixes. + const val PREFIX_DUPLICATE = "duplicate:" + const val PREFIX_POW = "pow:" + const val PREFIX_BLOCKED = "blocked:" + const val PREFIX_RATE_LIMITED = "rate-limited:" + const val PREFIX_INVALID = "invalid:" + const val PREFIX_RESTRICTED = "restricted:" + const val PREFIX_ERROR = "error:" + + /** De-facto prefix (not in NIP-01) for a stale version of a replaceable/addressable event. */ + const val PREFIX_REPLACED = "replaced:" + + // The standard store reasons. + const val DUPLICATE = "duplicate: already have this event" + const val EXPIRED = "blocked: Cannot insert an expired event" + const val DELETED = "blocked: a deletion event exists" + const val VANISHED = "blocked: a request to vanish event exists" + const val REPLACED = "replaced: a newer version exists" + const val INSERT_FAILED = "error: insert failed" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 515ca0b916..80e31f58d6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.store.FtsReindexProgress import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IdAndTime import com.vitorpamplona.quartz.nip01Core.store.RawEvent +import com.vitorpamplona.quartz.nip01Core.store.RejectionReason import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip50Search.strippingSearchExtensions @@ -433,7 +434,7 @@ class SQLiteEventStore( } suspend fun insertEvent(event: Event) { - if (event.isExpired()) throw SQLiteException("blocked: Cannot insert an expired event") + if (event.isExpired()) throw SQLiteException(RejectionReason.EXPIRED) if (event.kind.isEphemeral()) return pool.useWriter { db -> @@ -489,7 +490,7 @@ class SQLiteEventStore( delta: LiveIndexDelta?, ): IEventStore.InsertOutcome { if (event.isExpired()) { - return IEventStore.InsertOutcome.Rejected("blocked: Cannot insert an expired event") + return IEventStore.InsertOutcome.Rejected(RejectionReason.EXPIRED) } if (event.kind.isEphemeral()) return IEventStore.InsertOutcome.Accepted @@ -509,7 +510,7 @@ class SQLiteEventStore( // ROLLBACK shouldn't mask the original cause. runCatching { db.execSQL("ROLLBACK TRANSACTION TO SAVEPOINT $sp") } runCatching { db.execSQL("RELEASE SAVEPOINT $sp") } - IEventStore.InsertOutcome.Rejected(e.message ?: e::class.simpleName ?: "insert failed") + IEventStore.InsertOutcome.Rejected(e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED) } } @@ -518,7 +519,7 @@ class SQLiteEventStore( private val delta: LiveIndexDelta?, ) : IEventStore.ITransaction { override fun insert(event: Event) { - if (event.isExpired()) throw SQLiteException("blocked: Cannot insert an expired event") + if (event.isExpired()) throw SQLiteException(RejectionReason.EXPIRED) if (event.kind.isEphemeral()) return innerInsertEvent(event, db, delta) From e54a546d107b44a88b879537277e6f7c9671c7ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:48:04 +0000 Subject: [PATCH 097/227] SearchQuery: parse quoted phrases and -word exclusions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-50 search strings in the wild carry Google-style syntax the extension tokenizer could not see: "exact phrase" requirements, -"phrase" and -word exclusions. SearchQuery now lifts quoted spans BEFORE the extension pass — the order is load-bearing: the extension pass is quote-blind, so a span ending in an extension-shaped token would lose its closing quote, and lifting first also lets quotes protect extension-shaped tokens ("include:spam" is a phrase, not an extension). toSearchString() and stripExtensions() reassemble the full grammar; existing parses are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW --- .../quartz/nip50Search/SearchQuery.kt | 155 ++++++++++++++---- .../quartz/nip50Search/SearchQueryTest.kt | 117 +++++++++++++ 2 files changed, 242 insertions(+), 30 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt index f96b620d5c..fbcb4c35d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt @@ -28,43 +28,70 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter * NIP-50 defines the [com.vitorpamplona.quartz.nip01Core.relay.filters.Filter.search] * field as "a string describing a query in a human-readable form", optionally * carrying `key:value` extension tokens such as `domain:example.com` or - * `language:en`. This class splits that raw string into the free-text [terms] - * and the recognized [extensions], giving relays (and search redirectors) a - * typed view of the query instead of forcing each one to re-parse the string. + * `language:en`. This class splits that raw string into the free-text [terms], + * the Google-style term syntax ([phrases], [notPhrases], [notTerms]), and the + * recognized [extensions], giving relays (and search redirectors) a typed view + * of the query instead of forcing each one to re-parse the string. * * Example: * ``` - * val q = SearchQuery.parse("best nostr apps domain:example.com language:en") - * q.terms // "best nostr apps" + * val q = SearchQuery.parse("best \"nostr apps\" -spam domain:example.com") + * q.terms // "best" + * q.phrases // ["nostr apps"] + * q.notTerms // ["spam"] * q.domain // "example.com" - * q.language // "en" * ``` * - * ## Tokenization + * ## Parse order (load-bearing) * - * The string is split on whitespace. A token is treated as an extension when: - * - it contains a `:`, - * - the part before the `:` is a non-empty run of lowercase ASCII letters - * (`a`–`z`), and - * - the part after the `:` is non-empty and does not start with `//` (so URLs - * like `https://example.com` stay in [terms]). + * Quoted spans are lifted off the RAW string FIRST, then the residual is + * tokenized for extensions, then `-word` exclusions split off. The order + * matters twice over: the extension pass is quote-blind, so a span ending in + * an extension-shaped token (`"pizza sort:rank" -spam`) would otherwise lose + * its closing quote and the unclosed quote would swallow the rest of the + * query; and lifting first lets quotes protect extension-shaped tokens — + * `"include:spam"` is the phrase [include, spam], not an extension. * - * Everything else is free text. Per NIP-50 unknown extensions are kept (so they - * can be forwarded to a backend) — relays "SHOULD ignore extensions they don't - * support", which this models by simply not having a typed accessor for them; - * they remain readable through [extensions] / [extension]. + * ## Term syntax * - * Extension keys are matched case-sensitively against the lowercase forms - * documented by NIP-50. Duplicate keys keep the last occurrence. + * - A `"quoted span"` is an exact-phrase requirement ([phrases]); `-"…"` is a + * phrase exclusion ([notPhrases]). A quote opens a span only at a token + * boundary — mid-token quotes stay ordinary characters. An unclosed span + * runs to the end of the string. Empty spans are dropped, but a positive + * phrase keeps content a text index may not hold ("⚡"): it is an + * unsatisfiable requirement the backend turns into provably-no-match — + * dropping it here would silently flip that into match-all. + * - A leading `-` on a 2+ character token makes it an exclusion ([notTerms], + * all leading dashes stripped); a lone `-` stays an ordinary term. There is + * no `-extension` syntax: extension keys are strictly `a`–`z`, so + * `-include:spam` fails the key test and becomes the excluded literal. + * + * ## Extension tokenization + * + * The residual is split on whitespace. A token is treated as an extension when + * it contains a `:`, the part before the `:` is a non-empty run of lowercase + * ASCII letters (`a`–`z`), and the part after is non-empty and does not start + * with `//` (so URLs like `https://example.com` stay in [terms]). Per NIP-50 + * unknown extensions are kept (readable through [extensions] / [extension]) so + * they can be forwarded to a backend; relays "SHOULD ignore extensions they + * don't support". Extension keys are matched case-sensitively against the + * lowercase forms documented by NIP-50. Duplicate keys keep the last + * occurrence. */ class SearchQuery( - /** The human-readable search terms with all extension tokens removed. */ + /** The loose human-readable search terms: extensions, phrases, and exclusions all removed. */ val terms: String, /** * All recognized `key:value` extension tokens, in the order they appeared. * Known keys: [INCLUDE], [DOMAIN], [LANGUAGE], [SENTIMENT], [NSFW]. */ val extensions: Map, + /** Exact-phrase requirements (`"nostr apps"`), quotes removed, in order. */ + val phrases: List = emptyList(), + /** Exact-phrase exclusions (`-"nostr apps"`), quotes removed, in order. */ + val notPhrases: List = emptyList(), + /** Single-word exclusions (`-spam`), dashes removed, in order. */ + val notTerms: List = emptyList(), ) { /** `true` when the query carries the `include:spam` token (NIP-50: disable spam filtering). */ val includeSpam: Boolean @@ -93,20 +120,41 @@ class SearchQuery( val nsfwIncluded: Boolean get() = nsfw ?: true + /** + * Whether the query REQUIRES any text — loose terms or phrases. Exclusions + * alone don't count: an exclusions-only query is plain recall minus the + * excluded words, not a ranked text search. + */ + val hasText: Boolean + get() = terms.isNotEmpty() || phrases.isNotEmpty() + /** Returns the raw value of an arbitrary extension key (including unknown ones), or null. */ fun extension(key: String): String? = extensions[key] - /** Returns true when there are no free-text terms (the query is extensions-only or empty). */ + /** Returns true when there are no loose free-text terms. Phrases don't count — see [hasText] for "any required text". */ fun isTermsEmpty(): Boolean = terms.isEmpty() /** - * Re-assembles a canonical NIP-50 search string: the free-text [terms] - * followed by each `key:value` extension. Useful for a redirector that - * normalizes the incoming query before forwarding it to a backend. + * Re-assembles a canonical NIP-50 search string: the free-text [terms], + * then each `"phrase"`, `-exclusion`, `-"phrase exclusion"`, and + * `key:value` extension. Canonical, not order-preserving. Useful for a + * redirector that normalizes the incoming query before forwarding it. */ fun toSearchString(): String = buildString { append(terms) + for (phrase in phrases) { + if (isNotEmpty()) append(' ') + append('"').append(phrase).append('"') + } + for (word in notTerms) { + if (isNotEmpty()) append(' ') + append('-').append(word) + } + for (phrase in notPhrases) { + if (isNotEmpty()) append(' ') + append("-\"").append(phrase).append('"') + } for ((key, value) in extensions) { if (isNotEmpty()) append(' ') append(key).append(':').append(value) @@ -134,20 +182,24 @@ class SearchQuery( private val WHITESPACE = Regex("\\s+") - /** Empty query — no terms and no extensions. */ + /** Empty query — no terms, no syntax, no extensions. */ val EMPTY = SearchQuery("", emptyMap()) /** * Parses a raw NIP-50 [search] string into a [SearchQuery]. A null or - * blank input yields [EMPTY]. + * blank input yields [EMPTY]. See the class KDoc for the grammar and + * why the quote pass runs before the extension pass. */ fun parse(search: String?): SearchQuery { if (search.isNullOrBlank()) return EMPTY + val quoted = liftQuotedSpans(search) val extensions = LinkedHashMap() val terms = StringBuilder() + val notTerms = ArrayList() - for (token in search.trim().split(WHITESPACE)) { + for (token in quoted.residual.trim().split(WHITESPACE)) { + if (token.isEmpty()) continue val colon = token.indexOf(':') if (colon > 0 && colon < token.length - 1) { val key = token.substring(0, colon) @@ -157,18 +209,60 @@ class SearchQuery( continue } } + if (token.length > 1 && token[0] == '-') { + notTerms += token.trimStart('-') + continue + } if (terms.isNotEmpty()) terms.append(' ') terms.append(token) } - return SearchQuery(terms.toString(), extensions) + return SearchQuery(terms.toString(), extensions, quoted.phrases, quoted.notPhrases, notTerms) } private fun isExtensionKey(key: String): Boolean = key.isNotEmpty() && key.all { it in 'a'..'z' } + /** The quoted spans lifted off the raw text, plus the residual for the extension and `-word` passes. */ + private class QuotedSpans( + val phrases: List, + val notPhrases: List, + val residual: String, + ) + + /** Stage one, over the RAW string: lift every `"…"` / `-"…"` span. See the class KDoc for the rules. */ + private fun liftQuotedSpans(text: String): QuotedSpans { + val phrases = ArrayList() + val notPhrases = ArrayList() + val residual = StringBuilder() + var i = 0 + var boundary = true + while (i < text.length) { + val c = text[i] + val neg = c == '-' && i + 1 < text.length && text[i + 1] == '"' + if (boundary && (c == '"' || neg)) { + val start = i + if (neg) 2 else 1 + val close = text.indexOf('"', start) + val end = if (close < 0) text.length else close + val span = text.substring(start, end).trim() + i = if (close < 0) text.length else close + 1 + if (span.isNotEmpty()) { + if (neg) notPhrases += span else phrases += span + } + // The lifted span's place stays a token boundary for what follows. + residual.append(' ') + } else { + residual.append(c) + boundary = c.isWhitespace() + i++ + } + } + return QuotedSpans(phrases, notPhrases, residual.toString()) + } + /** * Returns [search] with every `key:value` extension token removed, - * leaving only the free-text terms (tokenized as in [parse]). + * leaving the free-text query (terms, phrases, and exclusions, + * re-assembled as in [toSearchString]). * * Backends that hand the search string to an engine with its own * query syntax — e.g. SQLite FTS, where `:` is column-filter @@ -184,7 +278,8 @@ class SearchQuery( fun stripExtensions(search: String?): String? { if (search.isNullOrBlank()) return search val parsed = parse(search) - return if (parsed.extensions.isEmpty()) search else parsed.terms + if (parsed.extensions.isEmpty()) return search + return SearchQuery(parsed.terms, emptyMap(), parsed.phrases, parsed.notPhrases, parsed.notTerms).toSearchString() } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQueryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQueryTest.kt index 4864e04162..956c73fee6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQueryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQueryTest.kt @@ -199,4 +199,121 @@ class SearchQueryTest { assertSame(plain, mixed[0]) assertEquals("bitcoin", mixed[1].search) } + + // ---- quoted phrases and -word exclusions -------------------------------- + + @Test + fun quotedSpanBecomesPhrase() { + val q = SearchQuery.parse("best \"nostr apps\" today") + assertEquals("best today", q.terms) + assertEquals(listOf("nostr apps"), q.phrases) + assertTrue(q.notPhrases.isEmpty()) + assertTrue(q.hasText) + } + + @Test + fun negatedQuotedSpanBecomesPhraseExclusion() { + val q = SearchQuery.parse("pizza -\"pineapple pizza\"") + assertEquals("pizza", q.terms) + assertEquals(listOf("pineapple pizza"), q.notPhrases) + assertTrue(q.phrases.isEmpty()) + } + + @Test + fun minusWordBecomesExclusion() { + val q = SearchQuery.parse("pizza -pineapple") + assertEquals("pizza", q.terms) + assertEquals(listOf("pineapple"), q.notTerms) + // Exclusions alone are not required text. + assertFalse(SearchQuery.parse("-pineapple").hasText) + } + + @Test + fun loneMinusStaysATerm() { + val q = SearchQuery.parse("a - b") + assertEquals("a - b", q.terms) + assertTrue(q.notTerms.isEmpty()) + } + + @Test + fun allLeadingDashesAreStripped() { + assertEquals(listOf("word"), SearchQuery.parse("--word").notTerms) + } + + @Test + fun quotesProtectExtensionShapedTokens() { + // The quote pass runs BEFORE the extension pass, so a quoted + // extension-shaped token is a phrase, not an extension. + val q = SearchQuery.parse("\"include:spam\"") + assertEquals(listOf("include:spam"), q.phrases) + assertFalse(q.includeSpam) + assertTrue(q.extensions.isEmpty()) + } + + @Test + fun spanEndingInExtensionKeepsTrailingExclusion() { + // Quote-blind extension parsing would eat the closing quote of + // "pizza include:spam" and swallow the trailing -word; the quote-first + // order keeps the exclusion an exclusion. + val q = SearchQuery.parse("\"pizza include:spam\" -pineapple") + assertEquals(listOf("pizza include:spam"), q.phrases) + assertEquals(listOf("pineapple"), q.notTerms) + assertTrue(q.extensions.isEmpty()) + } + + @Test + fun minusOnExtensionShapedTokenExcludesTheLiteral() { + // There is no `-extension` syntax: keys are strictly a-z, so the `-` + // makes the whole token an excluded literal. + val q = SearchQuery.parse("pizza -include:spam") + assertEquals(listOf("include:spam"), q.notTerms) + assertFalse(q.includeSpam) + } + + @Test + fun unclosedQuoteRunsToEnd() { + val q = SearchQuery.parse("\"nostr apps today") + assertEquals(listOf("nostr apps today"), q.phrases) + assertEquals("", q.terms) + } + + @Test + fun midTokenQuoteStaysOrdinary() { + val q = SearchQuery.parse("don\"t panic") + assertEquals("don\"t panic", q.terms) + assertTrue(q.phrases.isEmpty()) + } + + @Test + fun emptySpansAreDropped() { + val q = SearchQuery.parse("a \"\" b -\"\"") + assertEquals("a b", q.terms) + assertTrue(q.phrases.isEmpty()) + assertTrue(q.notPhrases.isEmpty()) + } + + @Test + fun phrasesComposeWithExtensions() { + val q = SearchQuery.parse("\"nostr apps\" best domain:example.com -spam") + assertEquals("best", q.terms) + assertEquals(listOf("nostr apps"), q.phrases) + assertEquals(listOf("spam"), q.notTerms) + assertEquals("example.com", q.domain) + } + + @Test + fun toSearchStringRoundTripsFullGrammar() { + val q = SearchQuery.parse("best \"nostr apps\" -spam -\"bad phrase\" domain:example.com") + assertEquals("best \"nostr apps\" -spam -\"bad phrase\" domain:example.com", q.toSearchString()) + } + + @Test + fun stripExtensionsKeepsPhrasesAndExclusions() { + assertEquals( + "best \"nostr apps\" -spam", + SearchQuery.stripExtensions("best \"nostr apps\" -spam language:en"), + ) + // No extensions -> the original string comes back untouched. + assertEquals("best \"nostr apps\" -spam", SearchQuery.stripExtensions("best \"nostr apps\" -spam")) + } } From 564cafedc4d5b999b243a4df8b34326d97c91380 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:53:39 +0000 Subject: [PATCH 098/227] Replace insertBisecting with a Failed outcome on the batchInsert contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisecting existed to reconstruct per-event attribution after a store threw a batch-wide exception. Better to never lose the attribution: batchInsert's contract now requires per-row isolation, with a third outcome telling whose fault a miss was. Rejected is the EVENT's fault (duplicate, expired, invalid, blocked) and is final; Failed is the STORE's fault (schema drift, a failed feed, a resource error) — the event was good, it is lost unless re-offered, and a rising Failed count means the store is broken rather than that upstreams send junk. Throwing is reserved for failures with no per-event answer (engine unreachable, transaction never started), readable as "nothing in this batch was written". Consumers updated: RelaySession maps Failed to OK false with NIP-01's "error:" prefix; IngestQueue converts a thrown batch and a missing outcome to Failed instead of Rejected; NdjsonImportExport counts failed apart from rejected; geode's MirrorWorker logs store failures at warn instead of folding them into debug-level rejections. BisectingInsert and its test are removed — with attribution guaranteed by the contract, retry-by-splitting has nothing left to do. Full :quartz:jvmTest passes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y4Pi9YYMhdzTFxRiV2jF9R --- .../geode/mirror/MirrorWorker.kt | 11 + .../nip01Core/relay/server/RelaySession.kt | 7 + .../relay/server/backend/IngestQueue.kt | 8 +- .../relay/server/backend/LiveEventStore.kt | 4 + .../quartz/nip01Core/store/BisectingInsert.kt | 89 -------- .../quartz/nip01Core/store/IEventStore.kt | 41 +++- .../nip01Core/store/NdjsonImportExport.kt | 10 +- .../store/sqlite/SQLiteEventStore.kt | 5 +- .../nip01Core/store/BisectingInsertTest.kt | 200 ------------------ .../prodbench/ConcurrentIngestLossTest.kt | 1 + 10 files changed, 69 insertions(+), 307 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsert.kt delete mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsertTest.kt diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt index 62936d17fe..942fe7769f 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt @@ -300,6 +300,13 @@ class MirrorWorker( rejected.incrementAndGet() Log.d("MirrorWorker") { "rejected ${msg.event.id}: ${outcome.reason}" } } + is IEventStore.InsertOutcome.Failed -> { + // The store's error, not the event's: the event + // was good and nothing will re-offer it. Louder + // than a rejection on purpose. + rejected.incrementAndGet() + Log.w("MirrorWorker") { "store failed ${msg.event.id}: ${outcome.reason}" } + } } } } catch (e: CancellationException) { @@ -431,6 +438,10 @@ class MirrorWorker( when (outcome) { IEventStore.InsertOutcome.Accepted -> accepted.incrementAndGet() is IEventStore.InsertOutcome.Rejected -> rejected.incrementAndGet() + is IEventStore.InsertOutcome.Failed -> { + rejected.incrementAndGet() + Log.w("MirrorWorker") { "store failed ${event.id}: ${outcome.reason}" } + } } } } catch (e: CancellationException) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 630ddec74a..9520dd9848 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -214,6 +214,13 @@ class RelaySession( is IEventStore.InsertOutcome.Rejected -> { send(OkMessage(cmd.event.id, false, outcome.reason)) } + + is IEventStore.InsertOutcome.Failed -> { + // The store's error, not the event's — NIP-01's + // machine-readable prefix for that is "error:". + val reason = outcome.reason + send(OkMessage(cmd.event.id, false, if (reason.startsWith("error:")) reason else "error: $reason")) + } } } } catch (_: ClosedSendChannelException) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt index 39f1191882..d955ed927c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt @@ -268,8 +268,8 @@ class IngestQueue( * Run the SQLite transaction for the verified subset of [batch] * and stitch outcomes back to a per-batch-index array. Failed * verifies pre-mark `Rejected` and skip the insert. A whole-batch - * commit failure converts every persisted entry to `Rejected` - * with the throw message. + * commit failure converts every persisted entry to `Failed` with + * the throw message — the events were good; the store was not. */ private suspend fun runInsertStage( batch: List, @@ -293,7 +293,7 @@ class IngestQueue( } catch (e: Throwable) { Log.w("IngestQueue") { "batchInsert failed for ${toInsert.size} events: ${e.message}" } val reason = e.message ?: e::class.simpleName ?: "insert failed" - List(toInsert.size) { IEventStore.InsertOutcome.Rejected(reason) } + List(toInsert.size) { IEventStore.InsertOutcome.Failed(reason) } } } @@ -349,7 +349,7 @@ class IngestQueue( * inserts), so the message is informational, not user-facing. */ private val missingOutcome = - IEventStore.InsertOutcome.Rejected("internal error: missing outcome") + IEventStore.InsertOutcome.Failed("internal error: missing outcome") /** * Cap per batch. Sized to keep per-batch latency low (each diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/LiveEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/LiveEventStore.kt index 345a4337a5..2c37a5b904 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/LiveEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/LiveEventStore.kt @@ -180,6 +180,10 @@ class LiveEventStore( is IEventStore.InsertOutcome.Rejected -> { done.completeExceptionally(IllegalStateException(outcome.reason)) } + + is IEventStore.InsertOutcome.Failed -> { + done.completeExceptionally(IllegalStateException(outcome.reason)) + } } } done.await() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsert.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsert.kt deleted file mode 100644 index deae869a8d..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsert.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip01Core.store - -import com.vitorpamplona.quartz.nip01Core.core.Event -import kotlinx.coroutines.CancellationException - -/** - * Write [events] through [write]; if that throws, split the batch and write - * the halves, down to the single event the writer cannot take. - * - * A bulk write like [IEventStore.batchInsert] fails as a unit, so one bad - * event would otherwise cost the whole batch — 999 good events lost per bad - * one at a 1000-event batch, with no retry. Bisecting costs ~2·log2(n) extra - * writes on a failing batch, nothing on a healthy one, and ends holding the - * offender by itself for [onPoison] to report. Re-writing the good halves is - * safe: re-inserting an already-applied event is a duplicate the store - * rejects. - * - * Splitting assumes ONE event is at fault. When the store itself is refusing - * (a full disk, a dead engine) every half fails all the way down and - * isolation would turn one failed write into ~2n — precisely the wrong moment - * to multiply the load. So isolation spends a fixed [budget] of writes and - * hands the remainder to [onGaveUp]: "we could not say which" is a different - * fact from "this event is bad", and a caller should count them apart. - */ -suspend fun insertBisecting( - events: List, - write: suspend (List) -> List, - onOutcomes: (List) -> Unit, - onPoison: (Event, Throwable) -> Unit, - onGaveUp: (List, Throwable) -> Unit = { _, _ -> }, - budget: Int = ISOLATION_WRITE_BUDGET, -) = bisect(events, write, onOutcomes, onPoison, onGaveUp, intArrayOf(budget)) - -private suspend fun bisect( - events: List, - write: suspend (List) -> List, - onOutcomes: (List) -> Unit, - onPoison: (Event, Throwable) -> Unit, - onGaveUp: (List, Throwable) -> Unit, - budget: IntArray, -) { - if (events.isEmpty()) return - try { - onOutcomes(write(events)) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - if (events.size == 1) { - onPoison(events.single(), e) - return - } - if (budget[0] <= 0) { - onGaveUp(events, e) - return - } - budget[0] -= 2 - val mid = events.size / 2 - bisect(events.subList(0, mid), write, onOutcomes, onPoison, onGaveUp, budget) - bisect(events.subList(mid, events.size), write, onOutcomes, onPoison, onGaveUp, budget) - } -} - -/** - * Writes one batch may spend isolating its bad events before giving up. - * Isolating k bad events out of n costs about `2·k·log2(n)` writes, so 64 - * covers three in a 1000-event batch — past the rate seen in practice. What - * it really bounds is the store-wide case, where every write fails. - */ -const val ISOLATION_WRITE_BUDGET = 64 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index 8f77817522..16b82132e9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -92,29 +92,52 @@ interface IEventStore : AutoCloseable { /** * Per-row outcome from [batchInsert]. The OK frame on the wire is - * built from this — `Accepted` becomes `OK true`, `Rejected.reason` - * becomes the false reason. NIP-01 says OK pairs to its EVENT by + * built from this — `Accepted` becomes `OK true`, the other two + * become the false reason. NIP-01 says OK pairs to its EVENT by * id, not by order, so callers may dispatch outcomes in any order. */ sealed class InsertOutcome { data object Accepted : InsertOutcome() + /** + * The EVENT's fault: policy said no — a duplicate, an expired + * or invalid event, a blocked author. Final: re-offering the + * same event yields the same answer, so dropping it is correct. + */ data class Rejected( val reason: String, ) : InsertOutcome() + + /** + * The STORE's fault: the event was acceptable but could not be + * written — schema drift, a failed feed, a resource error. The + * event is lost unless the caller re-offers it, and nothing + * else will. Callers should count these apart from [Rejected]: + * a rising [Rejected] is usually the protocol working + * (duplicates on a wide fan-out), while a rising [Failed] + * means the store is losing good events. + */ + data class Failed( + val reason: String, + ) : InsertOutcome() } /** - * Bulk insert in a single transaction with per-row error isolation. - * Returns one outcome per input event in the same order. + * Bulk insert with per-row attribution. Returns one outcome per + * input event in the same order. * - * Implementations must isolate per-row failures so one bad event - * doesn't roll back the others (SQLite uses SAVEPOINTs). If the - * outer commit itself fails, every entry in the returned list is - * `Rejected` with the commit-failure reason. + * Implementations must isolate per-row problems so one bad event + * never costs the batch: a policy refusal is [InsertOutcome.Rejected], + * a store-side write error is [InsertOutcome.Failed] (SQLite uses + * SAVEPOINTs for the isolation). Throwing is reserved for failures + * with no per-event answer — the engine unreachable, the transaction + * never started — and a caller may read a throw as "nothing in this + * batch was written". * * Default impl runs each insert in its own transaction — correct - * but loses the group-commit win. SQLite overrides this. + * but loses the group-commit win — and cannot classify a throw from + * [insert], so it reports `Rejected`. Implementations that can tell + * a refusal from a write error should override and say which. */ suspend fun batchInsert(events: List): List = events.map { event -> diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/NdjsonImportExport.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/NdjsonImportExport.kt index d7c85a742a..5c9125bd2e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/NdjsonImportExport.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/NdjsonImportExport.kt @@ -52,15 +52,17 @@ object NdjsonImportExport { val imported: Long, /** Events the store rejected — overwhelmingly duplicates (unique-id). */ val rejected: Long, + /** Good events the store could not write — a store-side error, not the event's. */ + val failed: Long, /** Events dropped for a bad signature (only when verifying). */ val invalid: Long, /** Lines that didn't parse as a NIP-01 event. */ val malformed: Long, ) { - operator fun plus(o: ImportStats) = ImportStats(read + o.read, imported + o.imported, rejected + o.rejected, invalid + o.invalid, malformed + o.malformed) + operator fun plus(o: ImportStats) = ImportStats(read + o.read, imported + o.imported, rejected + o.rejected, failed + o.failed, invalid + o.invalid, malformed + o.malformed) companion object { - val ZERO = ImportStats(0, 0, 0, 0, 0) + val ZERO = ImportStats(0, 0, 0, 0, 0, 0) } } @@ -81,6 +83,7 @@ object NdjsonImportExport { var read = 0L var imported = 0L var rejected = 0L + var failed = 0L var invalid = 0L var malformed = 0L val batch = ArrayList(batchSize) @@ -91,6 +94,7 @@ object NdjsonImportExport { when (outcome) { IEventStore.InsertOutcome.Accepted -> imported++ is IEventStore.InsertOutcome.Rejected -> rejected++ + is IEventStore.InsertOutcome.Failed -> failed++ } } batch.clear() @@ -112,7 +116,7 @@ object NdjsonImportExport { if (batch.size >= batchSize) flush() } flush() - return ImportStats(read, imported, rejected, invalid, malformed) + return ImportStats(read, imported, rejected, failed, invalid, malformed) } /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 515ca0b916..93f3bb5bb6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -463,8 +463,9 @@ class SQLiteEventStore( * stream still surfaces them; persistence is intentionally a * no-op per NIP-01. * - * Outer-commit failure throws; the caller treats every entry as - * `Rejected` (this is what the IEventStore contract documents). + * Outer-commit failure throws; per the IEventStore contract a + * throw means "nothing in this batch was written", and the + * IngestQueue converts it to per-event `Failed`. */ suspend fun batchInsertEvents(events: List): List { if (events.isEmpty()) return emptyList() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsertTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsertTest.kt deleted file mode 100644 index 2b813995c9..0000000000 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/BisectingInsertTest.kt +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip01Core.store - -import com.vitorpamplona.quartz.nip01Core.core.Event -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertTrue - -/** - * A bulk write fails as a unit, so without isolation one event the store cannot - * take costs its whole batch — 999 good events per bad one at the default size, - * dropped silently and counted as a multiple of the batch rather than as a number - * of bad events. These pin the isolation that stops that. - */ -class BisectingInsertTest { - private fun event(n: Int) = - Event( - id = n.toString().padStart(64, '0'), - pubKey = "a1".repeat(32), - createdAt = 1_700_000_000L + n, - kind = 1, - tags = emptyArray(), - content = "e$n", - sig = "b2".repeat(32), - ) - - /** Accepts everything except the named ids, which make the whole write throw. */ - private class Writer( - private val poison: Set, - ) { - val calls = mutableListOf() - var eventsWritten = 0 - - suspend fun write(batch: List): List { - calls.add(batch.size) - batch.firstOrNull { it.id in poison }?.let { - throw IndexOutOfBoundsException("Index: 1 Size: 1") - } - eventsWritten += batch.size - return batch.map { IEventStore.InsertOutcome.Accepted } - } - } - - private val gaveUp = mutableListOf() - - private suspend fun run( - events: List, - poison: Set, - ): Triple>> { - val writer = Writer(poison) - var accepted = 0 - val poisoned = mutableListOf>() - gaveUp.clear() - insertBisecting( - events = events, - write = { writer.write(it) }, - onOutcomes = { accepted += it.size }, - onPoison = { e, t -> poisoned.add(e to t) }, - onGaveUp = { batch, _ -> gaveUp.add(batch.size) }, - ) - return Triple(writer, accepted, poisoned) - } - - @Test - fun `a healthy batch is written once and costs nothing extra`() = - runTest { - val events = (1..64).map(::event) - val (writer, accepted, poisoned) = run(events, emptySet()) - - assertEquals(listOf(64), writer.calls, "no bisection on a batch that works") - assertEquals(64, accepted) - assertTrue(poisoned.isEmpty()) - } - - @Test - fun `one poison event costs only itself and not the batch`() = - runTest { - val events = (1..64).map(::event) - val bad = events[37].id - val (_, accepted, poisoned) = run(events, setOf(bad)) - - // This is the whole point: 63 of 64 still land. - assertEquals(63, accepted, "every event except the poison one must still be written") - assertEquals(1, poisoned.size) - assertEquals(bad, poisoned.single().first.id, "the isolated event is the one that throws") - assertTrue(poisoned.single().second is IndexOutOfBoundsException) - } - - @Test - fun `isolating stays logarithmic instead of falling back to one-by-one`() = - runTest { - val events = (1..1024).map(::event) - val (writer, accepted, _) = run(events, setOf(events[500].id)) - - assertEquals(1023, accepted) - // ~2*log2(n) writes, nowhere near the 1024 a per-event fallback would cost. - assertTrue(writer.calls.size < 32, "expected a logarithmic split, got ${writer.calls.size} writes") - } - - @Test - fun `several poison events are each isolated`() = - runTest { - val events = (1..64).map(::event) - val bad = setOf(events[0].id, events[31].id, events[63].id) - val (_, accepted, poisoned) = run(events, bad) - - assertEquals(61, accepted) - assertEquals(bad, poisoned.map { it.first.id }.toSet()) - } - - @Test - fun `a store-wide failure gives up instead of splitting all the way down`() = - runTest { - // Everything fails — a full disk, a dead engine. Splitting to singletons - // would cost ~2n writes at the worst possible moment. - val events = (1..1024).map(::event) - val (writer, accepted, poisoned) = run(events, events.map { it.id }.toSet()) - - assertEquals(0, accepted) - assertTrue( - writer.calls.size < 100, - "a store-wide failure must not cost ~2n writes; spent ${writer.calls.size}", - ) - // Nothing is silently lost: whatever isolation could not name is still - // handed back, so the caller can count it. - assertEquals(1024, poisoned.size + gaveUp.sum(), "every event must be accounted for") - assertTrue(gaveUp.isNotEmpty(), "the remainder should be reported as unisolated") - } - - @Test - fun `the budget is spent isolating rather than hoarded`() = - runTest { - // One bad event in 1024 must still be found — the guard bounds the - // pathological case without breaking the case it was built for. - val events = (1..1024).map(::event) - val (_, accepted, poisoned) = run(events, setOf(events[900].id)) - - assertEquals(1023, accepted) - assertEquals(events[900].id, poisoned.single().first.id) - assertTrue(gaveUp.isEmpty(), "a single bad event fits well inside the budget") - } - - @Test - fun `a batch of nothing but poison loses nothing else`() = - runTest { - val events = (1..4).map(::event) - val (_, accepted, poisoned) = run(events, events.map { it.id }.toSet()) - - assertEquals(0, accepted) - assertEquals(4, poisoned.size, "each one named, rather than one count of four") - } - - @Test - fun `cancellation propagates instead of being mistaken for a poison event`() = - runTest { - // Shutdown cancels the ingest scope mid-write. Treating that as "this - // event is bad" would drop good events and keep bisecting while the - // caller is trying to stop. - val events = (1..16).map(::event) - assertFailsWith { - insertBisecting( - events = events, - write = { throw CancellationException("shutting down") }, - onOutcomes = { }, - onPoison = { _, _ -> error("cancellation must not be reported as poison") }, - ) - } - } - - @Test - fun `an empty batch is a no-op`() = - runTest { - val (writer, accepted, poisoned) = run(emptyList(), emptySet()) - assertTrue(writer.calls.isEmpty()) - assertEquals(0, accepted) - assertTrue(poisoned.isEmpty()) - } -} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ConcurrentIngestLossTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ConcurrentIngestLossTest.kt index cd1e7b21ba..335d587cc6 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ConcurrentIngestLossTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ConcurrentIngestLossTest.kt @@ -130,6 +130,7 @@ class ConcurrentIngestLossTest { when (outcome) { is IEventStore.InsertOutcome.Accepted -> accepted.add(e.id) is IEventStore.InsertOutcome.Rejected -> rejected.incrementAndGet() + is IEventStore.InsertOutcome.Failed -> rejected.incrementAndGet() } window.release() if (remaining.decrementAndGet() == 0) done.complete(Unit) From 19d8e3069de5ca5144307bb3ab1ba153d24a1811 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 04:24:19 +0000 Subject: [PATCH 099/227] Align the sync accessories with quartz vocabulary; geode catch-up resumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames from review: SyncBands -> SyncCoverage ("sync" reads negentropy-ish in quartz, and coverage is the role — the bands are the records), and PagingProgress moves into relay.client.paging as PagingWindowProgress, beside RelayLoadingCursors and RelayPagingProgress, with its docs swept from "walk" dialect to quartz's pagination vocabulary and cross-references delineating the three: cursors are in-memory positions for demand-driven UI paging, the window progress is fraction/ETA for a bulk pagination over a known window, coverage is persistent intervals that license skipping work. geode adopts both halves of the new contract. MirrorWorker counts InsertOutcome.Failed in its own `failed` counter instead of folding it into `rejected`, and the down catch-up gains resume memory: SyncCoverageFile persists SyncCoverage next to the event database (admin state-file convention, temp-file + atomic move, daemon flush), and runCatchUpDown asks only for the legs outside the covered band. Bands are keyed on the stable scoped filter — never the boot window, whose since/until change every start — and clamped to the window, which only slides forward, so an old band can never license skipping a range an earlier boot could not ask about. A clean reconcile records completeness through its snapshot instant; a paged fallback earns only the span it saw. For an upstream without NIP-77 this turns the every-boot full re-download of the backfill window into a resumed walk. Off unless wired: MirrorWorker's coverage parameter defaults to null and in-memory stores keep no state file, so existing tests and setups are unchanged. Full :quartz:jvmTest and :geode:test pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y4Pi9YYMhdzTFxRiV2jF9R --- .../kotlin/com/vitorpamplona/geode/Main.kt | 16 ++ .../geode/config/StaticConfig.kt | 10 ++ .../geode/mirror/MirrorWorker.kt | 124 ++++++++++++--- .../geode/mirror/SyncCoverageFile.kt | 147 ++++++++++++++++++ .../geode/mirror/SyncCoverageFileTest.kt | 130 ++++++++++++++++ .../{SyncBands.kt => SyncCoverage.kt} | 7 +- .../PagingWindowProgress.kt} | 60 +++---- .../{SyncBandsTest.kt => SyncCoverageTest.kt} | 58 +++---- .../PagingWindowProgressTest.kt} | 32 ++-- 9 files changed, 489 insertions(+), 95 deletions(-) create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt create mode 100644 geode/src/test/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFileTest.kt rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/{SyncBands.kt => SyncCoverage.kt} (97%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/{accessories/PagingProgress.kt => paging/PagingWindowProgress.kt} (62%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/{SyncBandsTest.kt => SyncCoverageTest.kt} (93%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/{accessories/PagingProgressTest.kt => paging/PagingWindowProgressTest.kt} (84%) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 729b95b3a3..2026a7f3ba 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.geode.config.StaticConfig import com.vitorpamplona.geode.mirror.MirrorDirection import com.vitorpamplona.geode.mirror.MirrorUpstream import com.vitorpamplona.geode.mirror.MirrorWorker +import com.vitorpamplona.geode.mirror.SyncCoverageFile import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -411,6 +412,16 @@ private fun serve(args: Array) { require(upstreams.none { it.url.displayUrl() == advertisedIdentity }) { "[[mirror]] must not list this relay's own URL ($advertisedUrl)" } + // Resume state for the mirror catch-up, following the admin state-file + // convention: next to the event database unless configured. An in-memory + // store keeps none — bands only pay off across restarts. + val syncCoverage = + if (upstreams.isEmpty()) { + null + } else { + (config.options.mirror_sync_state_file ?: config.database.file?.let { "$it.sync-coverage.json" }) + ?.let { SyncCoverageFile(File(it)) } + } val mirror = if (upstreams.isEmpty()) { null @@ -425,6 +436,9 @@ private fun serve(args: Array) { // for the historical window, then live REQ tail. Auto-falls back // to paged REQ for upstreams without NIP-77. negentropyBackfill = true, + // Without NIP-77 the catch-up is a paged re-download; the + // coverage bands remember what previous boots already walked. + coverage = syncCoverage?.coverage, ).also { it.start() } } @@ -462,6 +476,8 @@ private fun serve(args: Array) { // queue and store beneath them shut down. runCatching { maintenanceScope.cancel() } runCatching { mirror?.close() } + // After the mirror, so the final flush carries the last bands. + runCatching { syncCoverage?.close() } runCatching { server.stop() } runCatching { relay.close() } }, diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index dffe4e50e3..8cfecb4e41 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -185,6 +185,16 @@ data class StaticConfig( * that don't offer NIP-50 at all (strfry, for example). */ val full_text_search: Boolean = true, + /** + * Where the mirror catch-up's resume state lives: the per-upstream + * `created_at` coverage bands (quartz's `SyncCoverage`). Without it + * every restart re-syncs each upstream's whole backfill window — + * a full re-download for an upstream without NIP-77. Defaults to + * `.sync-coverage.json` when the store is + * file-backed; an in-memory store keeps no resume state (bands + * only pay off across restarts). + */ + val mirror_sync_state_file: String? = null, ) /** diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt index 942fe7769f..5916a5f794 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.geode.mirror import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.SyncCoverage import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcile import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySyncOrFetch import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener @@ -159,6 +160,13 @@ class MirrorWorker( * transparent and needs no separate toggle. */ private val negentropyBackfill: Boolean = false, + /** + * Resume memory for the down catch-up, shared across upstreams and — via + * [SyncCoverageFile] — across restarts. Null keeps the old behavior: + * every boot re-syncs the whole backfill window, which for an upstream + * without NIP-77 is a full re-download. + */ + private val coverage: SyncCoverage? = null, ) : AutoCloseable { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -201,6 +209,14 @@ class MirrorWorker( /** Events the store rejected — mostly duplicate replays after a reconnect. */ val rejected = AtomicLong(0) + /** + * Good events the store could not write ([IEventStore.InsertOutcome.Failed]). + * The store's fault, not the event's, and nothing re-offers them — counted + * apart from [rejected] so a schema drift reads as store damage rather + * than as upstreams sending junk. + */ + val failed = AtomicLong(0) + /** * Deliveries dropped before ever reaching the store: events outside * the [MirrorUpstream.filter] scope (an upstream answering outside @@ -304,7 +320,7 @@ class MirrorWorker( // The store's error, not the event's: the event // was good and nothing will re-offer it. Louder // than a rejection on purpose. - rejected.incrementAndGet() + failed.incrementAndGet() Log.w("MirrorWorker") { "store failed ${msg.event.id}: ${outcome.reason}" } } } @@ -419,11 +435,21 @@ class MirrorWorker( initialSince: Long, until: Long, ) { - val catchUpFilter = scopedBase.copy(since = initialSince, until = until) - // Reconcile against what we already hold in this window → download only - // the diff (like `strfry sync`). No store wired → empty local set → the - // whole window is downloaded and the store's unique-id constraint dedups. - val localEntries = store?.snapshotIdsForNegentropy(listOf(catchUpFilter)) ?: emptyList() + // Resume memory: coverage is keyed on the STABLE scoped filter, never + // on the boot window — whose since/until change every start and would + // never match a stored band. The window only slides forward + // (initialSince = boot − backfillSeconds), so a band recorded against + // an earlier boot's lower floor never licenses skipping a range this + // boot can ask about but the last one could not. + val legs = + coverage + ?.legs(up.url, scopedBase) + ?.mapNotNull { clampToWindow(it, initialSince, until) } + ?: listOf(scopedBase.copy(since = initialSince, until = until)) + if (legs.isEmpty()) { + Log.i("MirrorWorker") { "catch-up from ${up.url.url}: window already covered - nothing outside the synced band" } + return + } // Bounded hand-off → one ingest consumer. `onEvent` can't suspend, so it // blocks here when the sink falls behind; because negentropySyncOrFetch's @@ -439,7 +465,7 @@ class MirrorWorker( IEventStore.InsertOutcome.Accepted -> accepted.incrementAndGet() is IEventStore.InsertOutcome.Rejected -> rejected.incrementAndGet() is IEventStore.InsertOutcome.Failed -> { - rejected.incrementAndGet() + failed.incrementAndGet() Log.w("MirrorWorker") { "store failed ${event.id}: ${outcome.reason}" } } } @@ -454,24 +480,59 @@ class MirrorWorker( } try { - val result = - client.negentropySyncOrFetch( - relay = up.url, - filter = catchUpFilter, - localEntries = localEntries, - onEvent = { event -> - // Same containment as the live path: even a trusted - // upstream may only inject events inside the declared scope. - if (up.filter == null || up.filter.match(event)) { - handoff.trySendBlocking(event) - } else { - filtered.incrementAndGet() - } - }, + var downloaded = 0 + var paged = false + for (leg in legs) { + // Reconcile against what we already hold in this leg → download + // only the diff (like `strfry sync`). No store wired → empty + // local set → the whole leg is downloaded and the store's + // unique-id constraint dedups. + val localEntries = store?.snapshotIdsForNegentropy(listOf(leg)) ?: emptyList() + // Coverage is stamped from when the local ids were read — that + // is the state the relay is being compared against. + val syncStartedAt = TimeUtils.now() + var seenMin: Long? = null + var seenMax: Long? = null + val result = + client.negentropySyncOrFetch( + relay = up.url, + filter = leg, + localEntries = localEntries, + onEvent = { event -> + // Same containment as the live path: even a trusted + // upstream may only inject events inside the declared scope. + if (up.filter == null || up.filter.match(event)) { + // Only plausible stamps widen a band — one + // misdated event must not discard the rest. + if (SyncCoverage.isPlausible(event.createdAt)) { + seenMin = minOf(seenMin ?: event.createdAt, event.createdAt) + seenMax = maxOf(seenMax ?: event.createdAt, event.createdAt) + } + handoff.trySendBlocking(event) + } else { + filtered.incrementAndGet() + } + }, + ) + downloaded += result.downloaded + paged = paged || result.pagedFallback + // Recorded per leg, so a failure between legs keeps the ground + // the first one gained. A clean reconcile is complete through + // the instant its snapshot was read; a paged fallback earns + // only the span it actually saw. + coverage?.record( + up.url, + scopedBase, + seenMin, + seenMax, + paged = result.pagedFallback, + reconciledThrough = if (result.pagedFallback) null else syncStartedAt, ) + } Log.i("MirrorWorker") { - val how = if (result.pagedFallback) "paged REQ (upstream has no NIP-77)" else "negentropy" - "catch-up from ${up.url.url}: ${result.downloaded} events via $how" + val how = if (paged) "paged REQ (upstream has no NIP-77)" else "negentropy" + val resumed = if (coverage != null && legs.size > 1) " [resumed: ${legs.size} legs outside the synced band]" else "" + "catch-up from ${up.url.url}: $downloaded events via $how$resumed" } } catch (e: CancellationException) { throw e @@ -731,3 +792,20 @@ class MirrorWorker( const val UP_SYNC_SETTLE_MS = 1_500L } } + +/** + * [leg] intersected with the boot window `[since, until]`, or null when the + * band already covers everything this window could ask. Coverage legs come + * off the stable scoped filter and are unbounded on one side; the catch-up + * only ever asks inside its own window. + */ +internal fun clampToWindow( + leg: Filter, + since: Long, + until: Long, +): Filter? { + val newSince = maxOf(leg.since ?: since, since) + val newUntil = minOf(leg.until ?: until, until) + if (newSince > newUntil) return null + return leg.copy(since = newSince, until = newUntil) +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt new file mode 100644 index 0000000000..f02d8f56f5 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.geode.mirror + +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.SyncCoverage +import com.vitorpamplona.quartz.utils.Log +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * File persistence for [SyncCoverage], so the mirror's catch-up resumes + * across restarts instead of re-syncing each upstream's whole backfill + * window — which, for an upstream without NIP-77, is a full re-download + * every boot. + * + * Same shape as the admin state file: JSON next to the event database, + * written via a temp file and an atomic move so a reader never sees a half + * map. A daemon timer flushes changed state so progress survives a hard + * kill; [close] flushes the rest. A corrupt file starts fresh — the cost of + * losing it is one re-sync, the cost of refusing to start is the relay. + */ +class SyncCoverageFile( + private val file: File, + flushSeconds: Long = DEFAULT_FLUSH_SECONDS, +) : AutoCloseable { + @Volatile private var dirty = false + + val coverage = SyncCoverage(onChange = { dirty = true }) + + private val flusher: Thread + + init { + load() + // Loading marks every restored band dirty; the file already has them. + dirty = false + flusher = + Thread { + while (!Thread.currentThread().isInterrupted) { + try { + Thread.sleep(flushSeconds * 1000) + } catch (_: InterruptedException) { + return@Thread + } + flush() + } + }.apply { + isDaemon = true + name = "mirror-sync-coverage-flush" + start() + } + } + + /** Write the map if anything changed since the last write. */ + @Synchronized + fun flush() { + if (!dirty) return + dirty = false + save() + } + + override fun close() { + flusher.interrupt() + flush() + } + + private fun load() { + if (!file.isFile) return + runCatching { + val root = Json.parseToJsonElement(file.readText()).jsonObject + coverage.restore( + root.mapValues { (_, v) -> + val o = v.jsonObject + SyncCoverage.Band( + o.getValue("min").jsonPrimitive.long, + o.getValue("max").jsonPrimitive.long, + o["complete"]?.jsonPrimitive?.boolean ?: false, + o["fullAt"]?.jsonPrimitive?.long ?: 0L, + ) + }, + ) + }.onFailure { + Log.w("SyncCoverageFile") { "could not read ${file.path} (${it.message}); starting fresh" } + } + } + + @Synchronized + private fun save() { + runCatching { + val doc = + buildJsonObject { + coverage.export().forEach { (key, band) -> + put( + key, + buildJsonObject { + put("min", band.minCreatedAt) + put("max", band.maxCreatedAt) + put("complete", band.complete) + put("fullAt", band.fullAt) + }, + ) + } + } + file.parentFile?.mkdirs() + val tmp = File(file.parentFile ?: File("."), "${file.name}.tmp") + tmp.writeText(json.encodeToString(JsonObject.serializer(), doc)) + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING) + }.onFailure { + Log.w("SyncCoverageFile") { "could not write ${file.path}: ${it.message}" } + } + } + + companion object { + // Pretty-printed: this file is read by a human debugging why an + // upstream re-synced. + private val json = Json { prettyPrint = true } + + // Often enough that a kill costs little, rare enough to be free. + private const val DEFAULT_FLUSH_SECONDS = 30L + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFileTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFileTest.kt new file mode 100644 index 0000000000..4a02507db8 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFileTest.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.geode.mirror + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The catch-up's resume memory across restarts. Without it every boot + * re-syncs each upstream's whole backfill window — a full re-download for an + * upstream without NIP-77. These pin the restart round-trip and the window + * clamping that keys bands on the stable filter rather than the sliding + * boot window. + */ +class SyncCoverageFileTest { + private val relay = RelayUrlNormalizer.normalize("wss://relay.example") + private val profiles = Filter(kinds = listOf(0)) + + private fun tempFile(): File { + val f = File.createTempFile("sync-coverage", ".json") + f.delete() + return f + } + + @Test + fun `bands survive a restart`() { + val f = tempFile() + SyncCoverageFile(f).use { + it.coverage.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) + } + + // A fresh instance, as a restart would build. + SyncCoverageFile(f).use { reopened -> + val band = reopened.coverage.band(relay, profiles)!! + assertEquals(1_700_001_000L, band.minCreatedAt) + assertEquals(1_700_002_000L, band.maxCreatedAt) + assertFalse(band.complete) + } + } + + @Test + fun `a complete band survives with its completeness`() { + val f = tempFile() + SyncCoverageFile(f).use { + it.coverage.record(relay, profiles, null, null, paged = false, reconciledThrough = 1_700_005_000L) + } + SyncCoverageFile(f).use { reopened -> + assertTrue(reopened.coverage.band(relay, profiles)!!.complete) + } + } + + @Test + fun `a corrupt file starts fresh instead of refusing to start`() { + val f = tempFile() + f.writeText("{ not json") + SyncCoverageFile(f).use { + assertNull(it.coverage.band(relay, profiles)) + } + } + + @Test + fun `recording does not write but closing does`() { + val f = tempFile() + val store = SyncCoverageFile(f) + store.coverage.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) + assertFalse(f.isFile, "a record marks dirty; only a flush writes") + store.close() + assertTrue(f.isFile, "close flushes") + } + + @Test + fun `reopening without new records does not rewrite the file`() { + val f = tempFile() + SyncCoverageFile(f).use { + it.coverage.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) + } + val written = f.lastModified() + SyncCoverageFile(f).close() + assertEquals(written, f.lastModified(), "restoring bands must not mark the store dirty") + } + + // ---- the window clamp -------------------------------------------------- + + @Test + fun `an unbanded filter clamps to exactly the boot window`() { + val leg = clampToWindow(profiles, since = 1_000L, until = 2_000L)!! + assertEquals(1_000L, leg.since) + assertEquals(2_000L, leg.until) + } + + @Test + fun `a leg outside the window is dropped rather than inverted`() { + // The band covers past the window's floor: the older leg would ask + // [since..band.min] with since above until — a range nothing can be in. + val olderLeg = profiles.copy(until = 500L) + assertNull(clampToWindow(olderLeg, since = 1_000L, until = 2_000L)) + } + + @Test + fun `a leg inside the window keeps its own tighter bound`() { + val newerLeg = profiles.copy(since = 1_500L) + val clamped = clampToWindow(newerLeg, since = 1_000L, until = 2_000L)!! + assertEquals(1_500L, clamped.since, "the band's ceiling wins over the window floor") + assertEquals(2_000L, clamped.until) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBands.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBands.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt index 68bff2ca85..c6ddff4dce 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBands.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt @@ -50,8 +50,13 @@ import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap * Persistence is the caller's: [export] the map on a schedule and [restore] * it at startup. [onChange] fires whenever a band changes, so a persistence * layer can mark itself dirty without polling. + * + * Not to be confused with the `relay.client.paging` package: its + * `RelayLoadingCursors` are in-memory POSITIONS for demand-driven UI paging + * within one session, while these are persistent INTERVALS — a claim about + * coverage that outlives the process and licenses skipping work. */ -class SyncBands( +class SyncCoverage( // How long a band may narrow work before the whole filter is walked // again. Everything a band claims is a claim about the past; this is how // long to trust it without re-testing. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgress.kt similarity index 62% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgress.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgress.kt index 028fa34979..0f515d0ca5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgress.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgress.kt @@ -18,73 +18,81 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relay.client.accessories +package com.vitorpamplona.quartz.nip01Core.relay.client.paging import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap import kotlin.concurrent.Volatile /** - * How far a paged walk has got, measured on the time axis — the only axis - * whose end is known in advance. + * How far a bulk pagination has got, measured on the time axis — the only + * axis whose end is known in advance. * - * A paged fetch ([fetchAllPages]) has no event denominator: how many events + * A paged fetch (`fetchAllPages`) has no event denominator: how many events * exist is exactly what it is finding out, so every count-based percentage * degenerates to `downloaded/downloaded = 100%`. The time axis has both ends * before the first request — the filter's `until` (or now) down to its - * `since` (or [SyncBands.PLAUSIBLE_FLOOR]) — with each page's new `until` - * reporting the exact position between them. It needs no COUNT support. + * `since` (or the accessories' `SyncCoverage.PLAUSIBLE_FLOOR`) — with each + * page's new `until` cursor reporting the exact position between them. It + * needs no COUNT support. * * The estimate assumes events are spread evenly over time, which they are * not — so it errs pessimistic on the tail, and is a bound, not a promise. * - * One instance can serve many concurrent walks: keys are `"group|walk"`, and - * the group prefix scopes [fraction], [reached] and [etaMs] so two groups - * never report each other's numbers. + * One instance can serve many concurrent paginations: keys are + * `"group|name"`, and the group prefix scopes [fraction], [reached] and + * [etaMs] so two groups never report each other's numbers. + * + * Its siblings in this package track different things: [RelayLoadingCursors] + * is demand-driven `until`+`limit` paging for one scope (a feed pulling + * older pages on demand, no window), and [RelayPagingProgress] is the + * per-relay display state derived from it. This class is for a BULK + * pagination over a known `[since, until]` window, where "how far through + * the window, and when will it finish" is the question. */ -class PagingProgress( +class PagingWindowProgress( private val nowMillis: () -> Long = { TimeUtils.nowMillis() }, ) { - private class Walk( + private class Window( val top: Long, val bottom: Long, val startedMs: Long, @Volatile var current: Long, ) - private val walks = ConcurrentMap() + private val windows = ConcurrentMap() - /** Begin a walk over `[bottom, top]` seconds. An inverted window is not a walk. */ + /** Begin a pagination over `[bottom, top]` seconds. An inverted window is not one. */ fun begin( key: String, top: Long, bottom: Long, ) { - if (top > bottom) walks[key] = Walk(top, bottom, nowMillis(), top) + if (top > bottom) windows[key] = Window(top, bottom, nowMillis(), top) } - /** The walk reached [until]; monotonic, so a page that jumps back cannot un-advance it. */ + /** The pagination reached [until]; monotonic, so a page that jumps back cannot un-advance it. */ fun mark( key: String, until: Long, ) { - walks[key]?.let { - // Clamped to the walk's own floor: relays serve events stamped 0, - // and one of those would drag the position to the epoch. Below the - // floor means the walk is done, not time travel. + windows[key]?.let { + // Clamped to the window's own floor: relays serve events stamped + // 0, and one of those would drag the position to the epoch. Below + // the floor means the pagination is done, not time travel. val reached = until.coerceAtLeast(it.bottom) if (reached < it.current) it.current = reached } } fun finish(key: String) { - walks.remove(key) + windows.remove(key) } /** - * Fraction of the walk complete, averaged over every walk still going in + * Fraction complete, averaged over every pagination still going in * [group] (or all of them when null) — averaged rather than summed - * because each covers its own span, so "half the walks done and half at + * because each covers its own span, so "half of them done and half at * zero" is 50%. */ fun fraction(group: String? = null): Double? { @@ -96,18 +104,18 @@ class PagingProgress( } / live.size } - private fun live(group: String?): List = + private fun live(group: String?): List = if (group == null) { - walks.snapshot().values.toList() + windows.snapshot().values.toList() } else { - walks + windows .snapshot() .entries .filter { it.key.startsWith("$group|") } .map { it.value } } - /** The oldest second [group] has reached, or null when it is not walking. */ + /** The oldest second [group] has reached, or null when nothing is paging. */ fun reached(group: String? = null): Long? = live(group).minOfOrNull { it.current } /** Milliseconds left at the rate achieved so far, or null before it means anything. */ diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBandsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt similarity index 93% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBandsTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt index d0ffe5290a..78abe61682 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncBandsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt @@ -35,7 +35,7 @@ import kotlin.test.assertTrue * importantly, the cases where a band must NOT be used — a stale band silently * skips events, which is a worse failure than re-reading them. */ -class SyncBandsTest { +class SyncCoverageTest { private val relay = RelayUrlNormalizer.normalize("wss://relay.example") private val other = RelayUrlNormalizer.normalize("wss://other.example") private val profiles = Filter(kinds = listOf(0)) @@ -46,13 +46,13 @@ class SyncBandsTest { @Test fun `with nothing recorded the whole filter is fetched`() { - val c = SyncBands() + val c = SyncCoverage() assertEquals(listOf(profiles), c.legs(relay, profiles)) } @Test fun `a recorded band is fetched around rather than through`() { - val c = SyncBands() + val c = SyncCoverage() c.record(relay, profiles, observedMin = 1_700_001_000L, observedMax = 1_700_002_000L, paged = true) val legs = c.legs(relay, profiles) @@ -68,7 +68,7 @@ class SyncBandsTest { // A paged relay cuts pages by count, so a boundary can fall inside a run // of events sharing one created_at. Excluding the edge would strand the // rest of that second in no leg at all, while the band called it covered. - val c = SyncBands() + val c = SyncCoverage() c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) val legs = c.legs(relay, profiles) @@ -85,7 +85,7 @@ class SyncBandsTest { @Test fun `successive runs widen the band rather than replacing it`() { - val c = SyncBands() + val c = SyncCoverage() c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) // A later run reaches further back and picks up newer events. c.record(relay, profiles, 1_700_000_500L, 1_700_002_500L, paged = true) @@ -99,7 +99,7 @@ class SyncBandsTest { fun `a capped relay walks further back on each run`() { // The case that makes this worth having: a relay that only ever answers // with its newest N events. Each run starts below the last one's floor. - val c = SyncBands() + val c = SyncCoverage() c.record(relay, profiles, 1_700_009_000L, 1_700_010_000L, paged = true) assertEquals(1_700_009_000L, c.legs(relay, profiles)[0].until) @@ -113,7 +113,7 @@ class SyncBandsTest { fun `a negentropy sync that reported no outcome records nothing`() { // Only a sync that says how far it reconciled earns a band; a bare // paged=false call carries no claim to record. - val c = SyncBands() + val c = SyncCoverage() c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = false) assertNull(c.band(relay, profiles)) assertEquals(listOf(profiles), c.legs(relay, profiles)) @@ -125,7 +125,7 @@ class SyncBandsTest { fun `a finished reconcile is in sync through the instant it started`() { // Not through the newest event it happened to see: "the relay had nothing // newer" and "we never asked" must not record the same thing. - val c = SyncBands() + val c = SyncCoverage() val startedAt = now() - 60 c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = false, reconciledThrough = startedAt) @@ -138,7 +138,7 @@ class SyncBandsTest { fun `a reconcile that downloaded nothing still records coverage`() { // The empty case is the WHOLE point: nothing came back because we already // have it, and that is exactly when the next run should ask for a sliver. - val c = SyncBands() + val c = SyncCoverage() val startedAt = now() - 60 c.record(relay, profiles, null, null, paged = false, reconciledThrough = startedAt) @@ -149,12 +149,12 @@ class SyncBandsTest { @Test fun `a complete band drops its older leg while a paged one keeps it`() { - val reconciled = SyncBands() + val reconciled = SyncCoverage() reconciled.record(relay, profiles, null, null, paged = false, reconciledThrough = 1_700_002_000L) val only = reconciled.legs(relay, profiles).single() assertEquals(1_700_002_000L, only.since) - val walked = SyncBands() + val walked = SyncCoverage() walked.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) assertEquals(2, walked.legs(relay, profiles).size, "a paged walk says nothing about what it never asked for") } @@ -163,13 +163,13 @@ class SyncBandsTest { @Test fun `a band stops narrowing once it is older than the resync period`() { - val c = SyncBands(fullResyncSeconds = 60) + val c = SyncCoverage(fullResyncSeconds = 60) c.record(relay, profiles, null, null, paged = false, reconciledThrough = now() - 3600) // Recorded 'now' whatever the created_at claim, so age it by rewriting. c.record(relay, profiles, null, null, paged = false, reconciledThrough = now()) assertEquals(1, c.legs(relay, profiles).size, "fresh band still narrows") - val stale = SyncBands(fullResyncSeconds = 0) + val stale = SyncCoverage(fullResyncSeconds = 0) stale.record(relay, profiles, null, null, paged = false, reconciledThrough = now()) assertSame(profiles, stale.legs(relay, profiles).single(), "a band past its period re-walks everything") } @@ -178,7 +178,7 @@ class SyncBandsTest { fun `the re-walk replaces the old claim instead of widening it`() { // Widening would carry the stale band's floor forward forever and the // periodic pass would never actually reset anything. - val c = SyncBands(fullResyncSeconds = 0) + val c = SyncCoverage(fullResyncSeconds = 0) c.record(relay, profiles, 1_700_000_000L, 1_700_001_000L, paged = true) c.record(relay, profiles, 1_700_005_000L, 1_700_006_000L, paged = true) @@ -190,7 +190,7 @@ class SyncBandsTest { @Test fun `covering window collapses to the oldest ceiling once everyone is caught up`() { - val c = SyncBands() + val c = SyncCoverage() c.record(relay, profiles, null, null, paged = false, reconciledThrough = 1_700_009_000L) c.record(other, profiles, null, null, paged = false, reconciledThrough = 1_700_003_000L) @@ -201,7 +201,7 @@ class SyncBandsTest { fun `one relay that has never synced puts the window back to the whole filter`() { // It genuinely needs everything — narrowing the shared snapshot would // reconcile it against ids we never looked up. - val c = SyncBands() + val c = SyncCoverage() c.record(relay, profiles, null, null, paged = false, reconciledThrough = 1_700_009_000L) // The filter itself, unnarrowed — identity, since Filter has no equals. @@ -214,7 +214,7 @@ class SyncBandsTest { // Every url in a stream shares that stream's filter, so a backfill can // take ONE snapshot for all of them instead of walking the identical // range once per relay for byte-identical answers. - val c = SyncBands() + val c = SyncCoverage() val third = RelayUrlNormalizer.normalize("wss://third.example") c.record(relay, profiles, null, null, paged = false, reconciledThrough = 1_700_009_000L) c.record(other, profiles, null, null, paged = false, reconciledThrough = 1_700_003_000L) @@ -226,7 +226,7 @@ class SyncBandsTest { @Test fun `a relay with an older gap also widens the shared window`() { - val c = SyncBands() + val c = SyncCoverage() c.record(relay, profiles, null, null, paged = false, reconciledThrough = 1_700_009_000L) c.record(other, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) @@ -237,7 +237,7 @@ class SyncBandsTest { fun `an empty fetch records nothing`() { // No events says nothing about what the relay holds, only that this // window was empty — recording it would fabricate coverage. - val c = SyncBands() + val c = SyncCoverage() c.record(relay, profiles, null, null, paged = true) assertNull(c.band(relay, profiles)) } @@ -246,11 +246,11 @@ class SyncBandsTest { fun `one misdated event does not cost a relay its whole band`() { // A single future-dated stamp among hundreds of thousands must not fail // a check applied to the aggregate. Screening per event keeps the rest. - val c = SyncBands() + val c = SyncCoverage() val far = now() + 400L * 86_400 val observed = listOf(1_700_001_000L, far, 1_700_002_000L, 0L) - val plausible = observed.filter { SyncBands.isPlausible(it) } + val plausible = observed.filter { SyncCoverage.isPlausible(it) } c.record(relay, profiles, plausible.min(), plausible.max(), paged = true) val band = c.band(relay, profiles)!! @@ -260,7 +260,7 @@ class SyncBandsTest { @Test fun `changing the filter starts over`() { - val c = SyncBands() + val c = SyncCoverage() c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) // Widening the kinds means the old band skipped events it never fetched. @@ -273,7 +273,7 @@ class SyncBandsTest { @Test fun `each relay keeps its own band`() { - val c = SyncBands() + val c = SyncCoverage() c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) assertEquals(listOf(profiles), c.legs(other, profiles)) } @@ -283,7 +283,7 @@ class SyncBandsTest { @Test fun `a bounded filter never widens past its own since and until`() { val bounded = Filter(kinds = listOf(0), since = 1_700_001_000L, until = 1_700_005_000L) - val c = SyncBands() + val c = SyncCoverage() c.record(relay, bounded, 1_700_002_000L, 1_700_003_000L, paged = true) val legs = c.legs(relay, bounded) @@ -300,7 +300,7 @@ class SyncBandsTest { // the two boundary seconds are always re-read, because that is the only // way to catch a run of same-second events a page boundary cut in half. val bounded = Filter(kinds = listOf(0), since = 1_700_001_000L, until = 1_700_005_000L) - val c = SyncBands() + val c = SyncCoverage() c.record(relay, bounded, 1_700_001_000L, 1_700_005_000L, paged = true) val legs = c.legs(relay, bounded) @@ -313,10 +313,10 @@ class SyncBandsTest { @Test fun `export and restore round-trip the bands`() { - val c = SyncBands() + val c = SyncCoverage() c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) - val reopened = SyncBands() + val reopened = SyncCoverage() reopened.restore(c.export()) val band = reopened.band(relay, profiles)!! @@ -327,7 +327,7 @@ class SyncBandsTest { @Test fun `onChange fires when a band changes so persistence can mark dirty`() { var changes = 0 - val c = SyncBands(onChange = { changes++ }) + val c = SyncCoverage(onChange = { changes++ }) c.record(relay, profiles, null, null, paged = true) assertEquals(0, changes, "an empty fetch records nothing and must not dirty the store") @@ -341,7 +341,7 @@ class SyncBandsTest { // Filter.toJson() runs to tens of thousands of characters for an // author-scoped filter, and a fan-out keys once per relay per cycle. val big = Filter(kinds = listOf(30382), authors = (1..500).map { it.toString(16).padStart(64, '0') }) - val c = SyncBands() + val c = SyncCoverage() c.record(relay, big, 1_700_001_000L, 1_700_002_000L, paged = true) // Same instance, many lookups: still one band, and cheap. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgressTest.kt similarity index 84% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgressTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgressTest.kt index bf8a550c63..2c1c0ed433 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/PagingProgressTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgressTest.kt @@ -18,14 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip01Core.relay.client.accessories +package com.vitorpamplona.quartz.nip01Core.relay.client.paging import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue -class PagingProgressTest { +class PagingWindowProgressTest { private fun assertClose( expected: Double, actual: Double?, @@ -35,8 +35,8 @@ class PagingProgressTest { } @Test - fun `progress is the walked share of the time window`() { - val p = PagingProgress() + fun `progress is the paged share of the time window`() { + val p = PagingWindowProgress() p.begin("a", top = 1_000L, bottom = 0L) assertClose(0.0, p.fraction(), "nothing walked yet") @@ -49,11 +49,11 @@ class PagingProgressTest { } @Test - fun `a page that jumps backwards cannot un-advance the walk`() { + fun `a page that jumps backwards cannot un-advance the pagination`() { // Pages arrive from one relay in order, but nothing in the protocol // guarantees it, and a percentage that goes DOWN is worse than one that // is slightly wrong — it reads as the sync having lost ground. - val p = PagingProgress() + val p = PagingWindowProgress() p.begin("a", top = 1_000L, bottom = 0L) p.mark("a", 200L) @@ -63,10 +63,10 @@ class PagingProgressTest { } @Test - fun `walks average rather than sum`() { + fun `windows average rather than sum`() { // Two relays each walking their own window: one done and one untouched // is half way — not 100% as summing would give. - val p = PagingProgress() + val p = PagingWindowProgress() p.begin("a", top = 1_000L, bottom = 0L) p.begin("b", top = 500L, bottom = 0L) @@ -76,8 +76,8 @@ class PagingProgressTest { } @Test - fun `a finished walk leaves the average`() { - val p = PagingProgress() + fun `a finished window leaves the average`() { + val p = PagingWindowProgress() p.begin("a", top = 1_000L, bottom = 0L) p.begin("b", top = 1_000L, bottom = 0L) p.mark("b", 500L) @@ -86,14 +86,14 @@ class PagingProgressTest { assertClose(0.5, p.fraction(), "only b is still walking") p.finish("b") - assertNull(p.fraction(), "nothing walking means no number to report") + assertNull(p.fraction(), "nothing paging means no number to report") } @Test fun `a group prefix scopes the numbers to its own walks`() { // One instance serves many concurrent walks; without the scope two // streams would print each other's percentages. - val p = PagingProgress() + val p = PagingWindowProgress() p.begin("streamA|wss://r1", top = 1_000L, bottom = 0L) p.begin("streamB|wss://r2", top = 1_000L, bottom = 0L) p.mark("streamA|wss://r1", 0L) @@ -104,10 +104,10 @@ class PagingProgressTest { } @Test - fun `an inverted or empty window is not a walk`() { + fun `an inverted or empty window is not a pagination`() { // A leg whose since is above its until asks for a range nothing can be // in. Dividing by that span would produce infinities on the status line. - val p = PagingProgress() + val p = PagingWindowProgress() p.begin("a", top = 100L, bottom = 900L) @@ -116,7 +116,7 @@ class PagingProgressTest { @Test fun `no ETA before the estimate means anything`() { - val p = PagingProgress() + val p = PagingWindowProgress() p.begin("a", top = 1_000_000L, bottom = 0L) p.mark("a", 999_000L) @@ -129,7 +129,7 @@ class PagingProgressTest { @Test fun `ETA extrapolates from the rate achieved so far`() { var clock = 1_000_000L - val p = PagingProgress(nowMillis = { clock }) + val p = PagingWindowProgress(nowMillis = { clock }) p.begin("a", top = 1_000L, bottom = 0L) p.mark("a", 500L) From 42fc73f6cb9949d1ad713b36d9f4bcc382afc0ba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:48:05 +0000 Subject: [PATCH 100/227] nip50Search: per-kind weighted search-field extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SearchableEvent.indexableContent() flattens a kind's searchable text into one blob, so a weighted full-text backend (title above summary above body) had to re-derive the decomposition itself — and drift every time a kind's parsing changed here. SearchFieldExtractor now lives beside the kinds it decomposes: title-like accessors primary, summary/description secondary, body tertiary, kind-0-shaped metadata in profile roles, with an indexableContent() fallback so every searchable kind, current or future, is covered. IndexableFields is a sealed shape — Profile or Tiered — so a kind cannot mix identity fields with content tiers, and each shape declares its own website role (a profile's homepage; a content kind's affiliation URLs). Multi-valued roles are carried UNJOINED, as lists: hashtag and location tags ride raw beside the tiers (filled by the one tiers() funnel every content branch uses, so no branch can forget them and profile shapes never see them), and separator or weighting choices — hashtags at summary weight, "\n" vs " ", arrays vs joined columns — belong to the backend, not the library. Empty extractions always normalize to None. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW --- .../quartz/nip50Search/IndexableFields.kt | 87 ++++ .../nip50Search/SearchFieldExtractor.kt | 486 ++++++++++++++++++ .../nip50Search/SearchFieldExtractorTest.kt | 127 +++++ 3 files changed, 700 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFields.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFields.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFields.kt new file mode 100644 index 0000000000..6227d28a1d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFields.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip50Search + +/** + * An event's searchable text decomposed by ROLE, for full-text backends that + * weight fields instead of indexing one concatenated blob + * ([SearchableEvent.indexableContent]'s flat form). Produced per kind by + * [SearchFieldExtractor]. + * + * A kind is either PROFILE-shaped ([Profile] — kind-0-style identity: kind 0 + * itself, app handlers) or CONTENT-shaped ([Tiered] — title above summary + * above body). The shape is part of the value, so a consumer discriminates on + * the type instead of keeping its own list of profile kinds. Both shapes + * carry a website role — a profile's homepage ([Profile.website]), or a + * content kind's affiliation URLs ([Tiered.websites]: repo, trackers, + * bookmarked page) — declared on each shape rather than the interface, so + * [None] answers no question that doesn't apply to it. + * + * Multi-valued roles are carried UNJOINED, as lists: which separator to use — + * or whether to index the values separately — is the backend's decision, and + * once values are pre-joined a backend can't unmix them. All strings are + * trimmed and non-empty. + */ +sealed interface IndexableFields { + fun isEmpty(): Boolean + + /** No searchable text — the extraction result for non-searchable kinds. */ + data object None : IndexableFields { + override fun isEmpty(): Boolean = true + } + + /** Kind-0-shaped identity, each field in its own role. An all-null value means "profile-shaped kind, nothing indexable". */ + data class Profile( + val name: String? = null, + val displayName: String? = null, + val about: String? = null, + val nip05: String? = null, + val lud16: String? = null, + val website: String? = null, + ) : IndexableFields { + override fun isEmpty(): Boolean = this == EMPTY + + private companion object { + val EMPTY = Profile() + } + } + + /** + * Content decomposed by priority tier: [primary] (title-like values), + * [secondary] (summary/description-like values), [text] (the body — the + * one inherently single-valued role), plus the raw [hashtags] and + * [locations] tag values. + */ + data class Tiered( + val primary: List = emptyList(), + val secondary: List = emptyList(), + val text: String? = null, + val hashtags: List = emptyList(), + val locations: List = emptyList(), + val websites: List = emptyList(), + ) : IndexableFields { + override fun isEmpty(): Boolean = this == EMPTY + + private companion object { + val EMPTY = Tiered() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt new file mode 100644 index 0000000000..c88d966a88 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt @@ -0,0 +1,486 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip50Search + +import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent +import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent +import com.vitorpamplona.quartz.buzz.managedAgents.ManagedAgentEvent +import com.vitorpamplona.quartz.buzz.teams.TeamEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent +import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.feedDefinition.FeedDefinitionEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent +import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent +import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletSnapshotEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent +import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +/** + * Decomposes every [SearchableEvent] into [IndexableFields] by priority tier: + * title-like accessors primary, summary/description secondary, body tertiary, + * with hashtag and location tags carried raw beside the tiers. Each explicit + * branch splits exactly the accessors that kind's `indexableContent()` + * concatenates — keep the two in sync when a kind's parsing changes. Kinds + * without an explicit branch fall back to the [SearchableEvent] branch (whole + * `indexableContent()` in the tertiary tier), so EVERY searchable kind, + * current or future, is extracted. + * + * A kind may also fill the profile roles when it carries that shape: kind + * 31990 goes through the kind-0 fields wholesale, and any kind with a + * homepage/site URL fills [IndexableFields.websites]. Hashtags and `location` + * tags are filled SYSTEMICALLY by the [tiers] funnel every content branch + * uses, so recall never depends on a branch remembering them. + * + * Non-searchable kinds return [IndexableFields.None]. The extraction is + * derived data baked into the build: stores should re-derive after upgrades + * (see [com.vitorpamplona.quartz.nip01Core.store.IEventStore.reindexFullTextSearch]). + */ +object SearchFieldExtractor { + /** Empty extractions always come back as [IndexableFields.None], whatever shape produced them. */ + fun extract(event: Event): IndexableFields = base(event).let { if (it.isEmpty()) IndexableFields.None else it } + + private fun base(event: Event): IndexableFields = + when (event) { + // kind 0 -> the profile fields, each in its own role. + is MetadataEvent -> { + val md = event.contactMetaData() + if (md == null) { + IndexableFields.Profile() + } else { + IndexableFields.Profile( + name = clean(md.name), + displayName = clean(md.displayName), + about = clean(md.about), + nip05 = clean(md.nip05), + lud16 = clean(md.lud16), + website = clean(md.website), + ) + } + } + + is LongTextNoteEvent -> { + tiers(event, event.title(), event.summary(), event.content) + } + + is WikiNoteEvent -> { + tiers(event, event.title(), event.summary(), event.content) + } + + is ClassifiedsEvent -> { + tiers(event, event.title(), event.summary(), event.content) + } + + is GitRepositoryEvent -> { + tiers(event, listOf(event.name()), listOf(event.description()), event.content, websites = event.webs()) + } + + is GitIssueEvent -> { + tiers(event, event.subject(), null, event.content) + } + + is GitPullRequestEvent -> { + tiers(event, event.subject(), null, event.content) + } + + is CommunityDefinitionEvent -> { + tiers(event, listOf(event.name()), listOf(event.description(), event.rules()), event.content) + } + + is EmojiPackEvent -> { + tiers(event, event.titleOrName(), event.description(), event.content) + } + + is ChannelCreateEvent -> { + event.channelInfo().let { tiers(event, it.name, it.about, null) } + } + + is ChannelMetadataEvent -> { + event.channelInfo().let { tiers(event, it.name, it.about, null) } + } + + is PictureEvent -> { + tiers(event, event.title(), null, event.content) + } + + is RegularVideoEvent -> { + tiers(event, event.title(), null, event.content) + } + + is AddressableVideoEvent -> { + tiers(event, event.title(), null, event.content) + } + + // Torrents are searched by FILE NAME above all — index the file + // list into the secondary tier, trackers as the affiliation URL. + is TorrentEvent -> { + tiers(event, listOf(event.title()), event.files().map { it.fileName }, event.content, websites = event.trackers()) + } + + is ThreadEvent -> { + tiers(event, event.title(), null, event.content) + } + + is FundraiserEvent -> { + tiers(event, event.title(), null, event.content) + } + + is NipTextEvent -> { + tiers(event, event.title(), null, event.content) + } + + is ExerciseTemplateEvent -> { + tiers(event, event.title(), null, event.content) + } + + is WorkoutRecordEvent -> { + tiers(event, event.title(), null, event.content) + } + + is CalendarEvent -> { + tiers(event, event.title(), null, event.content) + } + + is LiveActivitiesClipEvent -> { + tiers(event, event.title(), null, event.content) + } + + is CalendarDateSlotEvent -> { + tiers(event, event.title(), event.summary(), event.content) + } + + is CalendarTimeSlotEvent -> { + tiers(event, event.title(), event.summary(), event.content) + } + + is LiveActivitiesEvent -> { + tiers(event, event.title(), event.summary(), event.content, website = event.streaming()) + } + + is InteractiveStoryBaseEvent -> { + tiers(event, event.title(), event.summary(), event.content) + } + + is MeetingSpaceEvent -> { + tiers(event, event.room(), event.summary(), event.content) + } + + is MeetingRoomEvent -> { + tiers(event, event.title(), event.summary(), null) + } + + // Code snippets are searched by language/runtime as much as name — + // fold those keywords into the secondary tier, repo as affiliation. + is CodeSnippetEvent -> { + tiers( + event, + listOf(event.snippetName()), + listOf(event.snippetDescription(), event.language(), event.extension(), event.runtime()), + event.content, + websites = listOf(event.repo()), + ) + } + + is BadgeDefinitionEvent -> { + tiers(event, event.name(), event.description(), event.content) + } + + is MusicPlaylistEvent -> { + tiers(event, event.title(), event.description(), event.content) + } + + is MusicTrackEvent -> { + tiers(event, listOf(event.title()), listOf(event.artist(), event.album()), event.content) + } + + is SoftwareApplicationEvent -> { + tiers(event, listOf(event.name()), listOf(event.summary()), event.content, websites = listOf(event.url(), event.repository())) + } + + is PodcastEpisodeEvent -> { + tiers(event, event.title(), event.description(), event.content) + } + + is PodcastMetadataEvent -> { + tiers(event, listOf(event.title()), listOf(event.description()), null, websites = event.websites()) + } + + is GroupMetadataEvent -> { + tiers(event, event.name(), event.about(), null) + } + + is InterestSetEvent -> { + tiers(event, event.title(), event.description(), null) + } + + is FollowListEvent -> { + tiers(event, event.title(), event.description(), null) + } + + is MediaStarterPackEvent -> { + tiers(event, event.title(), event.description(), null) + } + + is PictureCurationSetEvent -> { + tiers(event, event.title(), event.description(), null) + } + + is ArticleCurationSetEvent -> { + tiers(event, event.title(), event.description(), null) + } + + is VideoCurationSetEvent -> { + tiers(event, event.title(), event.description(), null) + } + + is ReleaseArtifactSetEvent -> { + tiers(event, event.title(), event.description(), null) + } + + is AppCurationSetEvent -> { + tiers(event, event.title(), event.description(), null) + } + + is RelaySetEvent -> { + tiers(event, event.title(), event.description(), null) + } + + // A web bookmark IS its URL — route it to the affiliation website + // field so the bookmark is findable by its domain. + is WebBookmarkEvent -> { + tiers(event, event.title(), event.description(), null, website = event.url()) + } + + is NamedSiteEvent -> { + tiers(event, event.title(), event.description(), null) + } + + is RootSiteEvent -> { + tiers(event, event.title(), event.description(), null) + } + + is RootNappletEvent -> { + tiers(event, event.title(), event.description(), null) + } + + is NappletSnapshotEvent -> { + tiers(event, event.title(), event.description(), null) + } + + is NamedNappletEvent -> { + tiers(event, event.title(), event.description(), null) + } + + is FeedDefinitionEvent -> { + tiers(event, event.title(), null, null) + } + + is LabeledBookmarkListEvent -> { + tiers(event, event.titleOrName(), event.description(), null) + } + + is PeopleListEvent -> { + tiers(event, event.titleOrName(), event.description(), null) + } + + is BookmarkListEvent -> { + tiers(event, event.title(), null, null) + } + + is OldBookmarkListEvent -> { + tiers(event, event.title(), null, null) + } + + is GoalEvent -> { + tiers(event, null, event.summary(), event.content) + } + + is HighlightEvent -> { + tiers(event, emptyList(), listOf(event.comment(), event.context()), event.content) + } + + is FileHeaderEvent -> { + tiers(event, null, event.summary(), event.content) + } + + is AudioTrackEvent -> { + tiers(event, event.subject(), null, null) + } + + // Buzz agent/workspace kinds carry their metadata as JSON in `content`; + // split each decoded object the way its indexableContent() concatenates it. + is AgentProfileEvent -> { + event.profileOrNull()?.let { tiers(event, listOf(it.name, it.displayName), emptyList(), null) } ?: tiers(event, null, null, null) + } + + is PersonaEvent -> { + event.personaOrNull()?.let { tiers(event, it.displayName, null, it.systemPrompt) } ?: tiers(event, null, null, null) + } + + is ManagedAgentEvent -> { + event.agentOrNull()?.let { tiers(event, it.name, null, it.systemPrompt) } ?: tiers(event, null, null, null) + } + + is TeamEvent -> { + event.teamOrNull()?.let { tiers(event, it.name, it.description, it.instructions) } ?: tiers(event, null, null, null) + } + + is WorkflowDefEvent -> { + tiers(event, event.name(), null, event.content) + } + + // kind 31990 — the app handler's metadata IS a UserMetadata clone, + // so route it through the kind-0 profile fields: an app's + // @-handle and site get the same treatment a person's do. + is AppDefinitionEvent -> { + val md = event.appMetaData() + if (md == null) { + IndexableFields.Profile() + } else { + IndexableFields.Profile( + // Per NIP-24 the deprecated `username` folds into `name`. + name = clean(md.name ?: md.username), + displayName = clean(md.displayName), + about = clean(md.about), + nip05 = clean(md.nip05), + lud16 = clean(md.lud16), + website = clean(md.website), + ) + } + } + + // kind 1 LAST among the explicit branches: several kinds extend the + // text-note base, and their own branches above must win. + is TextNoteEvent -> { + tiers(event, event.subject(), null, event.content) + } + + // Everything else Quartz can search, current or future: the whole + // indexableContent lands in the tertiary tier. + is SearchableEvent -> { + tiers(event, null, null, event.indexableContent()) + } + + else -> { + IndexableFields.None + } + } + + /** Single-value convenience over the list funnel — most kinds carry one title, one summary, one body. */ + private fun tiers( + event: Event, + primary: String?, + secondary: String?, + text: String?, + website: String? = null, + ) = tiers(event, listOf(primary), listOf(secondary), text, listOf(website)) + + /** + * The one funnel every content branch uses — [IndexableFields.Tiered.hashtags] + * and [IndexableFields.Tiered.locations] are filled here, so no branch can + * forget them. Values stay UNJOINED: separator choices belong to the backend. + */ + private fun tiers( + event: Event, + primary: List, + secondary: List, + text: String?, + websites: List = emptyList(), + ) = IndexableFields.Tiered( + primary = cleanAll(primary), + secondary = cleanAll(secondary), + text = clean(text), + hashtags = cleanAll(event.tags.hashtags()), + locations = locationValues(event), + websites = cleanAll(websites), + ) + + /** Trim and drop empties at the single funnel every derived string passes through. */ + private fun clean(s: String?): String? = s?.trim()?.ifEmpty { null } + + private fun cleanAll(parts: List): List = parts.mapNotNull { clean(it) } + + /** + * Every `location` tag value, on ANY kind. Deliberately a raw scan, not a + * typed accessor: Quartz's LocationTag classes are per-NIP (calendar, + * picture, classifieds) and only those kinds expose locations(), while + * this funnel must also catch location tags on kinds whose class doesn't + * model them. + */ + private fun locationValues(event: Event): List = event.tags.mapNotNull { tag -> tag.getOrNull(1)?.trim()?.takeIf { tag.getOrNull(0) == "location" && it.isNotEmpty() } } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt new file mode 100644 index 0000000000..3c543a9442 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip50Search + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import kotlin.test.Test +import kotlin.test.assertEquals + +class SearchFieldExtractorTest { + private val alice = "a1".repeat(32) + + @Test + fun kind0DecomposesIntoTheProfileRoles() { + val content = """{"name":"vitor","display_name":"Vitor P","about":"builds nostr","nip05":"vitor@vitorpamplona.com","lud16":"me@wallet.com","website":"https://vitorpamplona.com","picture":"https://x/y.jpg"}""" + val fields = SearchFieldExtractor.extract(MetadataEvent("1".repeat(64), alice, 1L, emptyArray(), content, "")) + assertEquals( + IndexableFields.Profile( + name = "vitor", + displayName = "Vitor P", + about = "builds nostr", + nip05 = "vitor@vitorpamplona.com", + lud16 = "me@wallet.com", + website = "https://vitorpamplona.com", + ), + fields, + ) + } + + @Test + fun longFormDecomposesIntoTitleSummaryHashtagsContent() { + val tags = arrayOf(arrayOf("d", "post"), arrayOf("title", "My Post"), arrayOf("summary", "tl;dr"), arrayOf("t", "nostr"), arrayOf("t", "search")) + val fields = SearchFieldExtractor.extract(LongTextNoteEvent("2".repeat(64), alice, 1L, tags, "the whole article", "")) + assertEquals(IndexableFields.Tiered(primary = listOf("My Post"), secondary = listOf("tl;dr"), text = "the whole article", hashtags = listOf("nostr", "search")), fields) + } + + @Test + fun notesUseTheSubjectAndHashtags() { + val tags = arrayOf(arrayOf("subject", "meetup"), arrayOf("t", "brazil")) + val fields = SearchFieldExtractor.extract(TextNoteEvent("3".repeat(64), alice, 1L, tags, "see you there", "")) + assertEquals(IndexableFields.Tiered(primary = listOf("meetup"), text = "see you there", hashtags = listOf("brazil")), fields) + } + + @Test + fun locationTagsAreCarriedRawLikeHashtags() { + val tags = arrayOf(arrayOf("location", "Rio de Janeiro")) + val fields = SearchFieldExtractor.extract(TextNoteEvent("4".repeat(64), alice, 1L, tags, "gm", "")) + assertEquals(IndexableFields.Tiered(text = "gm", locations = listOf("Rio de Janeiro")), fields) + } + + @Test + fun torrentsIndexFileNamesAndTrackers() { + val tags = + arrayOf( + arrayOf("title", "Great Torrent"), + arrayOf("file", "episode1.mkv"), + arrayOf("file", "episode2.mkv"), + arrayOf("tracker", "https://tracker.example.com"), + ) + val fields = SearchFieldExtractor.extract(TorrentEvent("5".repeat(64), alice, 1L, tags, "a series", "")) + assertEquals( + IndexableFields.Tiered( + primary = listOf("Great Torrent"), + // Unjoined: the backend decides how file names are indexed. + secondary = listOf("episode1.mkv", "episode2.mkv"), + text = "a series", + websites = listOf("https://tracker.example.com"), + ), + fields, + ) + } + + @Test + fun webBookmarksAreFindableByTheirUrl() { + // NIP-B0: the d tag carries the URL scheme-less; url() re-adds https://. + val tags = arrayOf(arrayOf("d", "vitorpamplona.com/post"), arrayOf("title", "A Post")) + val fields = SearchFieldExtractor.extract(WebBookmarkEvent("6".repeat(64), alice, 1L, tags, "", "")) + assertEquals(IndexableFields.Tiered(primary = listOf("A Post"), websites = listOf("https://vitorpamplona.com/post")), fields) + } + + @Test + fun appHandlerMetadataReusesTheProfileRoles() { + val content = """{"name":"CoolApp","about":"an app","website":"https://coolapp.example"}""" + val fields = SearchFieldExtractor.extract(AppDefinitionEvent("7".repeat(64), alice, 1L, arrayOf(arrayOf("d", "x")), content, "")) + assertEquals(IndexableFields.Profile(name = "CoolApp", about = "an app", website = "https://coolapp.example"), fields) + } + + @Test + fun nonSearchableKindsExtractNothing() { + // Kind 7 reactions are not SearchableEvent. + val reaction = Event("8".repeat(64), alice, 1L, 7, emptyArray(), "+", "") + assertEquals(IndexableFields.None, SearchFieldExtractor.extract(reaction)) + } + + @Test + fun profileShapesNeverGetHashtagFolding() { + // Hashtags/locations are filled only by the tiers() funnel, which + // profile branches never use: a kind-0 with t-tags stays a pure + // profile (and an empty one normalizes to None). + val tags = arrayOf(arrayOf("t", "nostr")) + val fields = SearchFieldExtractor.extract(MetadataEvent("9".repeat(64), alice, 1L, tags, "{}", "")) + assertEquals(IndexableFields.None, fields) + } +} From 804b850095f04707c48f568621807346389caa23 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 05:00:40 +0000 Subject: [PATCH 101/227] Audit fixes: empty exclusions, missed fallback, one vocabulary An all-dash search token ("--") stripped to an empty exclusion that toSearchString/stripExtensions round-tripped into a REQUIRED "-" term reaching SQLite FTS; it is now dropped at parse. IngestQueue's batchInsert fallback was the one site still hand-writing "insert failed" (unprefixed) while every other path emits RejectionReason.INSERT_FAILED. RejectionReason no longer duplicates the NIP-01 prefixes MachineReadablePrefix already owns, and the expiration trigger now rejects with the same words as the Kotlin pre-check instead of its own spelling. Tests pin the "--" drop, consecutive quoted spans, text after a closing quote, the extractor's fallback tier, blank-content normalization, and the unparseable-buzz-content hashtag seam; extractor KDoc now states where the trimmed/non-empty and never-empty-Profile guarantees actually live. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW --- .../relay/server/backend/IngestQueue.kt | 3 +- .../quartz/nip01Core/store/RejectionReason.kt | 28 ++++++++----------- .../store/sqlite/ExpirationModule.kt | 3 +- .../quartz/nip50Search/IndexableFields.kt | 13 +++++++-- .../nip50Search/SearchFieldExtractor.kt | 6 ++-- .../quartz/nip50Search/SearchQuery.kt | 5 +++- .../nip50Search/SearchFieldExtractorTest.kt | 23 +++++++++++++++ .../quartz/nip50Search/SearchQueryTest.kt | 27 ++++++++++++++++++ 8 files changed, 83 insertions(+), 25 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt index 39f1191882..0a578630f1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.server.backend import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.RejectionReason import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -292,7 +293,7 @@ class IngestQueue( store.batchInsert(toInsert) } catch (e: Throwable) { Log.w("IngestQueue") { "batchInsert failed for ${toInsert.size} events: ${e.message}" } - val reason = e.message ?: e::class.simpleName ?: "insert failed" + val reason = e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED List(toInsert.size) { IEventStore.InsertOutcome.Rejected(reason) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt index a9262e6482..4e14cb2550 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt @@ -28,24 +28,20 @@ package com.vitorpamplona.quartz.nip01Core.store * "duplicate" differently. * * NIP-01: an `OK false` message SHOULD begin with a single-word - * machine-readable prefix followed by `:`. The `PREFIX_*` constants are that - * vocabulary; the full-sentence constants are the standard reasons the - * built-in stores emit. `replaced:` is not in NIP-01 but is the de-facto - * prefix (strfry and others) for a replaceable event that lost to a stored - * newer version — distinct from `duplicate:` (the exact event is already - * held). + * machine-readable prefix followed by `:`. The full-sentence constants here + * are the standard reasons the built-in stores emit. `replaced:` is not in + * NIP-01 but is the de-facto prefix (strfry and others) for a replaceable + * event that lost to a stored newer version — distinct from `duplicate:` + * (the exact event is already held). */ object RejectionReason { - // The NIP-01 machine-readable prefixes. - const val PREFIX_DUPLICATE = "duplicate:" - const val PREFIX_POW = "pow:" - const val PREFIX_BLOCKED = "blocked:" - const val PREFIX_RATE_LIMITED = "rate-limited:" - const val PREFIX_INVALID = "invalid:" - const val PREFIX_RESTRICTED = "restricted:" - const val PREFIX_ERROR = "error:" - - /** De-facto prefix (not in NIP-01) for a stale version of a replaceable/addressable event. */ + /** + * The NIP-01 prefix vocabulary itself lives in + * [com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix] + * — use its `format`/`parse` instead of hand-writing prefixes. The one + * prefix that enum lacks is the de-facto (not in NIP-01) word for a stale + * version of a replaceable/addressable event: + */ const val PREFIX_REPLACED = "replaced:" // The standard store reasons. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt index 99baf1cace..38c6703fb2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import androidx.sqlite.SQLiteConnection import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.store.RejectionReason import com.vitorpamplona.quartz.nip40Expiration.expiration class ExpirationModule : IModule { @@ -44,7 +45,7 @@ class ExpirationModule : IModule { FOR EACH ROW BEGIN -- Check for existing newer record - SELECT RAISE(ABORT, 'blocked: this event is expired') + SELECT RAISE(ABORT, '${RejectionReason.EXPIRED}') WHERE NEW.expiration <= unixepoch(); END; """.trimIndent(), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFields.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFields.kt index 6227d28a1d..d0e8720804 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFields.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableFields.kt @@ -37,8 +37,9 @@ package com.vitorpamplona.quartz.nip50Search * * Multi-valued roles are carried UNJOINED, as lists: which separator to use — * or whether to index the values separately — is the backend's decision, and - * once values are pre-joined a backend can't unmix them. All strings are - * trimmed and non-empty. + * once values are pre-joined a backend can't unmix them. Values produced by + * [SearchFieldExtractor] are trimmed and non-empty; the types themselves do + * not enforce it. */ sealed interface IndexableFields { fun isEmpty(): Boolean @@ -48,7 +49,13 @@ sealed interface IndexableFields { override fun isEmpty(): Boolean = true } - /** Kind-0-shaped identity, each field in its own role. An all-null value means "profile-shaped kind, nothing indexable". */ + /** + * Kind-0-shaped identity, each field in its own role. [SearchFieldExtractor] + * never RETURNS an empty Profile — extract() normalizes every empty shape + * to [None] — so an all-null value only exists mid-extraction or when + * hand-built. Note the equality trap for hand-built values: an empty + * shape isEmpty() but is not equal to [None]. + */ data class Profile( val name: String? = null, val displayName: String? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt index c88d966a88..39cfd48ce3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt @@ -424,8 +424,8 @@ object SearchFieldExtractor { } } - // kind 1 LAST among the explicit branches: several kinds extend the - // text-note base, and their own branches above must win. + // kind 1 LAST among the explicit branches, defensively: a future + // kind extending the text-note base must hit its own branch first. is TextNoteEvent -> { tiers(event, event.subject(), null, event.content) } @@ -482,5 +482,5 @@ object SearchFieldExtractor { * this funnel must also catch location tags on kinds whose class doesn't * model them. */ - private fun locationValues(event: Event): List = event.tags.mapNotNull { tag -> tag.getOrNull(1)?.trim()?.takeIf { tag.getOrNull(0) == "location" && it.isNotEmpty() } } + private fun locationValues(event: Event): List = event.tags.mapNotNull { tag -> if (tag.getOrNull(0) != "location") null else clean(tag.getOrNull(1)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt index fbcb4c35d1..9361d58e25 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt @@ -210,7 +210,10 @@ class SearchQuery( } } if (token.length > 1 && token[0] == '-') { - notTerms += token.trimStart('-') + // All-dash tokens ("--") strip to nothing: an + // exclude-nothing token is dropped, not surfaced — an + // empty exclusion would round-trip into a required "-". + token.trimStart('-').takeIf { it.isNotEmpty() }?.let { notTerms += it } continue } if (terms.isNotEmpty()) terms.append(' ') diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt index 3c543a9442..daa1ce232d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.quartz.nip50Search +import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -115,6 +117,27 @@ class SearchFieldExtractorTest { assertEquals(IndexableFields.None, SearchFieldExtractor.extract(reaction)) } + @Test + fun unmappedSearchableKindsFallBackToTheTextTier() { + val fields = SearchFieldExtractor.extract(ChatMessageEvent("a".repeat(64), alice, 1L, emptyArray(), "hello group", "")) + assertEquals(IndexableFields.Tiered(text = "hello group"), fields) + } + + @Test + fun blankContentKindsNormalizeToNone() { + val fields = SearchFieldExtractor.extract(TextNoteEvent("b".repeat(64), alice, 1L, emptyArray(), " ", "")) + assertEquals(IndexableFields.None, fields) + } + + @Test + fun unparseableBuzzContentStillIndexesItsHashtags() { + // The branch finds no text, but the tiers() funnel still carries the + // event's raw tags — the one subtle reachability seam of the port. + val tags = arrayOf(arrayOf("t", "agents")) + val fields = SearchFieldExtractor.extract(AgentProfileEvent("c".repeat(64), alice, 1L, tags, "not json", "")) + assertEquals(IndexableFields.Tiered(hashtags = listOf("agents")), fields) + } + @Test fun profileShapesNeverGetHashtagFolding() { // Hashtags/locations are filled only by the tiers() funnel, which diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQueryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQueryTest.kt index 956c73fee6..2e5169a0eb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQueryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQueryTest.kt @@ -307,6 +307,33 @@ class SearchQueryTest { assertEquals("best \"nostr apps\" -spam -\"bad phrase\" domain:example.com", q.toSearchString()) } + @Test + fun allDashTokensAreDroppedNotEmptied() { + // "--" strips to nothing; surfacing an empty exclusion would + // round-trip into a required "-" term. + val q = SearchQuery.parse("a --") + assertEquals("a", q.terms) + assertTrue(q.notTerms.isEmpty()) + assertEquals("a", SearchQuery.parse(q.toSearchString()).terms) + assertEquals("", SearchQuery.stripExtensions("-- include:spam")) + } + + @Test + fun consecutiveSpansEachLift() { + val q = SearchQuery.parse("\"a\"\"b\" -\"c\"") + assertEquals(listOf("a", "b"), q.phrases) + assertEquals(listOf("c"), q.notPhrases) + assertEquals("", q.terms) + } + + @Test + fun textAfterClosingQuoteIsItsOwnTerm() { + // The lifted span's place stays a token boundary for what follows. + val q = SearchQuery.parse("\"a b\"c") + assertEquals(listOf("a b"), q.phrases) + assertEquals("c", q.terms) + } + @Test fun stripExtensionsKeepsPhrasesAndExclusions() { assertEquals( From 4081ef16814ddbe7334c2e57414ceb0131d98a7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 05:00:40 +0000 Subject: [PATCH 102/227] nip01Core: one owner rule, one supersession rule, one tag-name rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three semantics rules each existed as multiple independent copies: - Event.owner() (gift-wrap recipient controls the wrap, else the author — NIP-09/62 authority) was derived inline in EventIndexesModule and again in EventStoreProjection.ownerOf. - The NIP-01 replaceable tiebreak (newest created_at, ties to the lexically smallest id) lived in EventStoreProjection.supersedes, in SQL, and downstream. - "Indexable tag name" was spelled `length == 1` in four places, which admits "5" and "#" — names the NIP-01 #x filter space (single a-zA-Z letters) cannot address, letting stores disagree about which tags filters reach. isIndexableTagName encodes the NIP-01 rule; converging FilterIndex and the SQLite IndexingStrategy on it deliberately tightens single-char non-letter tag names out of the index. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW --- .../cache/projection/EventStoreProjection.kt | 12 +++---- .../nip01Core/core/ReplaceableSupersession.kt | 35 +++++++++++++++++++ .../nip01Core/relay/filters/FilterIndex.kt | 7 ++-- .../quartz/nip01Core/store/EventOwner.kt | 35 +++++++++++++++++++ .../store/sqlite/EventIndexesModule.kt | 10 ++---- .../store/sqlite/IndexingStrategy.kt | 3 +- .../quartz/nip01Core/tags/IndexableTagName.kt | 30 ++++++++++++++++ 7 files changed, 113 insertions(+), 19 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/ReplaceableSupersession.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventOwner.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/IndexableTagName.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt index 3c45f8af17..58c3ecc6ff 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt @@ -25,12 +25,13 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isReplaceable +import com.vitorpamplona.quartz.nip01Core.core.supersedes import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore.StoreChange +import com.vitorpamplona.quartz.nip01Core.store.owner import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.utils.SortedList import com.vitorpamplona.quartz.utils.TimeUtils @@ -342,19 +343,14 @@ class EventStoreProjection( private fun supersedes( new: Event, existing: Event, - ): Boolean = - when { - new.createdAt > existing.createdAt -> true - new.createdAt < existing.createdAt -> false - else -> new.id < existing.id - } + ): Boolean = new.supersedes(existing) /** * Owner pubkey for ownership checks (NIP-09 author match, * NIP-62 vanish target). For GiftWrap the owner is the p-tag * recipient; for everything else it's `event.pubKey`. */ - private fun ownerOf(event: Event): HexKey = (event as? GiftWrapEvent)?.recipientPubKey() ?: event.pubKey + private fun ownerOf(event: Event): HexKey = event.owner() /** * created_at DESC, id ASC. Sort keys are frozen at slot diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/ReplaceableSupersession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/ReplaceableSupersession.kt new file mode 100644 index 0000000000..0719ea24f5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/ReplaceableSupersession.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.core + +/** + * The NIP-01 replaceable/addressable supersession rule: the winner of an + * address is the highest `created_at`, with ties broken by the LEXICALLY + * SMALLEST id. True when THIS event beats [existing] — equal events (same id) + * do not supersede themselves. One rule, shared by every store; the SQLite + * triggers encode the same comparison in SQL. + */ +fun Event.supersedes(existing: Event): Boolean = + when { + createdAt > existing.createdAt -> true + createdAt < existing.createdAt -> false + else -> id < existing.id + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterIndex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterIndex.kt index 4cd54f7594..da083e9dd7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterIndex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterIndex.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.tags.isIndexableTagName import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.PersistentSet import kotlinx.collections.immutable.persistentHashMapOf @@ -238,7 +239,7 @@ class FilterIndex { s.kinds[event.kind]?.let { result.addAll(it) } if (s.tags.isNotEmpty()) { for (tag in event.tags) { - if (tag.size >= 2 && tag[0].length == 1) { + if (tag.size >= 2 && isIndexableTagName(tag[0])) { s.tags[tag[0]]?.get(tag[1])?.let { result.addAll(it) } } } @@ -348,14 +349,14 @@ class FilterIndex { if (!filter.tags.isNullOrEmpty()) { val first = filter.tags.entries.firstOrNull { - it.key.length == 1 && it.value.isNotEmpty() + isIndexableTagName(it.key) && it.value.isNotEmpty() } if (first != null) return first.value.map { TagKey(first.key, it) } } if (!filter.tagsAll.isNullOrEmpty()) { val first = filter.tagsAll.entries.firstOrNull { - it.key.length == 1 && it.value.isNotEmpty() + isIndexableTagName(it.key) && it.value.isNotEmpty() } if (first != null) return first.value.map { TagKey(first.key, it) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventOwner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventOwner.kt new file mode 100644 index 0000000000..10126099bc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventOwner.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * The pubkey that CONTROLS this event for ownership checks — NIP-09 deletion + * authority and NIP-62 vanish targeting. A gift wrap (kind 1059) is signed by + * a random one-time key, so control belongs to its p-tag RECIPIENT (the + * recipient deletes the wraps addressed to them); every other event is + * controlled by its author. One rule, shared by every store — do not re-derive + * it inline. + */ +fun Event.owner(): HexKey = (this as? GiftWrapEvent)?.recipientPubKey() ?: pubKey diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt index dba6e2f9f3..f5347a4372 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip01Core.store.owner class EventIndexesModule( val hasher: (db: SQLiteConnection) -> TagNameValueHasher, @@ -206,12 +206,8 @@ class EventIndexesModule( val kindLong = event.kind.toLong() val pubkeyHash = hasher.hash(event.pubKey) - val eventOwnerHash = - if (event is GiftWrapEvent) { - event.recipientPubKey()?.let { hasher.hash(it) } ?: pubkeyHash - } else { - pubkeyHash - } + val ownerKey = event.owner() + val eventOwnerHash = if (ownerKey == event.pubKey) pubkeyHash else hasher.hash(ownerKey) val eTagHash = hasher.hashETag(event.id) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt index 01362e03fb..eb704e1781 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.tags.isIndexableTagName interface IndexingStrategy { /** @@ -165,5 +166,5 @@ class DefaultIndexingStrategy( override fun shouldIndex( kind: Int, tag: Tag, - ) = tag.size >= 2 && tag[0].length == 1 + ) = tag.size >= 2 && isIndexableTagName(tag[0]) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/IndexableTagName.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/IndexableTagName.kt new file mode 100644 index 0000000000..e3a682efd5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/IndexableTagName.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags + +/** + * Whether [name] is a tag name the NIP-01 `#x` filter space can address: a + * single ASCII LETTER (`a`-`z` / `A`-`Z`), per NIP-01's "single-letter + * (a-zA-Z) English-alphabet letters". Deliberately stricter than + * `length == 1`: a `"5"` or `"#"` tag name is not filterable and indexing + * it would let stores disagree about which tags `#x` filters reach. + */ +fun isIndexableTagName(name: String): Boolean = name.length == 1 && (name[0] in 'a'..'z' || name[0] in 'A'..'Z') From cdbd550405b5249b1af0a52bd2246338b492e72c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 05:22:40 +0000 Subject: [PATCH 103/227] Fix audit findings across the sync accessories and their consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quartz: - SQLiteEventStore: classify per-row savepoint errors — policy refusals (blocked:/constraint/not allowed) stay Rejected, everything else is now Failed, so disk-full no longer masquerades as 2M duplicate rejections - IEventStore.batchInsert default: rethrow CancellationException and map unknown throws to Failed (re-offering a duplicate is idempotent; dropping a good event on a transient store error is not) - IngestQueue: rethrow CancellationException instead of stamping a cancelled batch Failed and continuing - HostStrikes: make the eviction verdict exactly-once under concurrency (deadHosts.add is the atomic gate) and re-check produced before publishing - SyncCoverage: bound the identity fingerprint cache (a caller minting fresh Filter instances per cycle could grow it forever); legs() gains a floor parameter so a complete band re-opens its older span when the caller's window deepens; coveringWindow no longer treats a fully covered relay as needing the whole filter - PagingWindowProgress: accept single-second windows (a band's re-read edge leg is exactly that shape) geode: - MirrorWorker: cap reconciledThrough at the leg's own ceiling — the older leg of a resumed catch-up no longer stamps the band complete through 'now' before the newer leg has run (silent event loss for up to fullResyncSeconds if that leg failed) - MirrorWorker: run negentropy and the paged fallback by hand instead of negentropySyncOrFetch: drops the O(delivered-ids) dedup set from the mirror path, and a fallback resets the observed span so a band never claims interior ranges only a half-finished reconcile scattered over - MirrorWorker: clamp a paged band's ceiling to the snapshot instant so one future-dated event cannot suppress the next boot's newer leg - MirrorWorker.close(): join the workers (bounded) so the final coverage flush carries the last records - Main: gate the coverage file on the store actually being persistent — database.file with in_memory=true (the default) persisted bands over a volatile store, and the next boot skipped the backfill over an empty database; honor --db overrides - SyncCoverageFile: request ATOMIC_MOVE explicitly; fix the restore/dirty comment - Import summary now prints the failed count; document mirror_sync_state_file in config.example.toml --- geode/config.example.toml | 9 + .../kotlin/com/vitorpamplona/geode/Main.kt | 25 ++- .../geode/mirror/MirrorWorker.kt | 155 ++++++++++++------ .../geode/mirror/SyncCoverageFile.kt | 15 +- .../relay/client/accessories/SyncCoverage.kt | 45 ++++- .../client/paging/PagingWindowProgress.kt | 15 +- .../relay/server/backend/IngestQueue.kt | 6 + .../quartz/nip01Core/store/IEventStore.kt | 11 +- .../nip01Core/store/ObservableEventStore.kt | 2 +- .../store/sqlite/SQLiteEventStore.kt | 24 ++- .../reachability/HostStrikes.kt | 8 +- .../client/accessories/SyncCoverageTest.kt | 30 ++++ .../client/paging/PagingWindowProgressTest.kt | 27 ++- .../prodbench/ConcurrentIngestLossTest.kt | 5 +- 14 files changed, 296 insertions(+), 81 deletions(-) diff --git a/geode/config.example.toml b/geode/config.example.toml index 2ba85e0ad4..4651105623 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -112,6 +112,15 @@ require_auth = false # the future. Enforced by RejectFutureEventsPolicy. # reject_future_seconds = 1800 +# Path for the JSON file that remembers what the [[mirror]] catch-up +# has already synced (per upstream, per scope), so a restart resumes +# instead of re-downloading each upstream's whole backfill window. +# Defaults to ".sync-coverage.json" next to the event +# store; only written when the store itself is file-backed (an +# in-memory store keeps no resume state — saved coverage would +# describe events that no longer exist). +# mirror_sync_state_file = "/var/lib/geode/events.db.sync-coverage.json" + [authorization] # Allow / deny lists. Allow is a permissive ceiling; deny still # removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy. diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 2026a7f3ba..1ed17bbcdb 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.NdjsonImportExport import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -224,7 +225,8 @@ private fun runImport(args: Array) { } System.err.println( "geode import: read=${stats.read} imported=${stats.imported} " + - "rejected=${stats.rejected} invalid-sig=${stats.invalid} malformed=${stats.malformed} " + + "rejected=${stats.rejected} failed=${stats.failed} " + + "invalid-sig=${stats.invalid} malformed=${stats.malformed} " + "→ ${ctx.dbFile ?: "(in-memory — not persisted; pass --db)"}", ) } finally { @@ -413,14 +415,25 @@ private fun serve(args: Array) { "[[mirror]] must not list this relay's own URL ($advertisedUrl)" } // Resume state for the mirror catch-up, following the admin state-file - // convention: next to the event database unless configured. An in-memory - // store keeps none — bands only pay off across restarts. + // convention: next to the event database unless configured. Keyed to the + // store's ACTUAL persistence, not to `database.file` being set: a + // volatile store with a persistent coverage file would claim, on the + // next boot, that an empty database already holds the backfill window — + // and the mirror would never fetch it. + val sqliteBackend = + config.database.backend + .trim() + .lowercase() in StoreFactory.SQLITE_BACKEND_KEYWORDS + val persistentLocation = + a.opt("--db") ?: config.database.file?.takeUnless { sqliteBackend && config.database.in_memory } val syncCoverage = - if (upstreams.isEmpty()) { + if (upstreams.isEmpty() || persistentLocation == null) { + if (upstreams.isNotEmpty() && config.options.mirror_sync_state_file != null) { + Log.w("Main") { "mirror_sync_state_file ignored: the event store is in-memory, so saved coverage would outlive the events it describes" } + } null } else { - (config.options.mirror_sync_state_file ?: config.database.file?.let { "$it.sync-coverage.json" }) - ?.let { SyncCoverageFile(File(it)) } + SyncCoverageFile(File(config.options.mirror_sync_state_file ?: "$persistentLocation.sync-coverage.json")) } val mirror = if (upstreams.isEmpty()) { diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt index 5916a5f794..c98e050e37 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt @@ -23,9 +23,11 @@ package com.vitorpamplona.geode.mirror import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.SyncCoverage +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcile -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySyncOrFetch +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySync import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd @@ -41,12 +43,15 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import okhttp3.OkHttpClient import java.time.Duration import java.util.concurrent.atomic.AtomicLong @@ -155,9 +160,9 @@ class MirrorWorker( * historical window before the live REQ tail takes over. The geode binary * turns this on (see `Main`); it defaults **off** so the many existing * MirrorWorker tests keep exercising the pure live-REQ path unchanged. - * When on, `negentropySyncOrFetch` automatically falls back to paged REQ - * against an upstream that doesn't speak NIP-77 — so "either mode" is - * transparent and needs no separate toggle. + * When on, the catch-up automatically falls back to paged REQ against an + * upstream that doesn't speak NIP-77 — so "either mode" is transparent + * and needs no separate toggle. */ private val negentropyBackfill: Boolean = false, /** @@ -422,9 +427,9 @@ class MirrorWorker( * **client-paced** so a fast upstream can't overrun the sink: a plain REQ * backfill of a large set dies here (strfry kills a slow REQ client once its * unsent-outbound buffer crosses `maxPendingOutboundBytes`), which is exactly - * why this uses negentropy. [INostrClient.negentropySyncOrFetch] falls back - * to paged REQ automatically when the upstream doesn't speak NIP-77, so the - * mirror is compatible with either kind of upstream with no config. + * why this uses negentropy. When the upstream doesn't speak NIP-77 the + * catch-up falls back to paged REQ, so the mirror is compatible with + * either kind of upstream with no config. * * A failure here is non-fatal: the live subscription keeps the mirror current * and the reconnect watermark narrows any residual gap. @@ -437,13 +442,12 @@ class MirrorWorker( ) { // Resume memory: coverage is keyed on the STABLE scoped filter, never // on the boot window — whose since/until change every start and would - // never match a stored band. The window only slides forward - // (initialSince = boot − backfillSeconds), so a band recorded against - // an earlier boot's lower floor never licenses skipping a range this - // boot can ask about but the last one could not. + // never match a stored band. The window itself rides along as the + // floor argument, so a band recorded against a shallower window + // re-opens the older span when the operator deepens the backfill. val legs = coverage - ?.legs(up.url, scopedBase) + ?.legs(up.url, scopedBase, initialSince) ?.mapNotNull { clampToWindow(it, initialSince, until) } ?: listOf(scopedBase.copy(since = initialSince, until = until)) if (legs.isEmpty()) { @@ -452,9 +456,10 @@ class MirrorWorker( } // Bounded hand-off → one ingest consumer. `onEvent` can't suspend, so it - // blocks here when the sink falls behind; because negentropySyncOrFetch's - // own delivery pipeline is bounded, that backpressure reaches all the way - // to the upstream — no unbounded buffering (unlike the live-tail path). + // blocks here when the sink falls behind; because negentropySync's own + // delivery pipeline is bounded (and a paged REQ is paced by its pages), + // that backpressure reaches all the way to the upstream — no unbounded + // buffering (unlike the live-tail path). val handoff = Channel(capacity = CATCHUP_HANDOFF) val consumer = scope.launch { @@ -493,41 +498,81 @@ class MirrorWorker( val syncStartedAt = TimeUtils.now() var seenMin: Long? = null var seenMax: Long? = null - val result = - client.negentropySyncOrFetch( - relay = up.url, - filter = leg, - localEntries = localEntries, - onEvent = { event -> - // Same containment as the live path: even a trusted - // upstream may only inject events inside the declared scope. - if (up.filter == null || up.filter.match(event)) { - // Only plausible stamps widen a band — one - // misdated event must not discard the rest. - if (SyncCoverage.isPlausible(event.createdAt)) { - seenMin = minOf(seenMin ?: event.createdAt, event.createdAt) - seenMax = maxOf(seenMax ?: event.createdAt, event.createdAt) - } - handoff.trySendBlocking(event) - } else { - filtered.incrementAndGet() - } - }, - ) - downloaded += result.downloaded - paged = paged || result.pagedFallback + + fun observe(event: Event) { + // Same containment as the live path: even a trusted + // upstream may only inject events inside the declared scope. + if (up.filter == null || up.filter.match(event)) { + // Only plausible stamps widen a band — one + // misdated event must not discard the rest. + if (SyncCoverage.isPlausible(event.createdAt)) { + seenMin = minOf(seenMin ?: event.createdAt, event.createdAt) + seenMax = maxOf(seenMax ?: event.createdAt, event.createdAt) + } + handoff.trySendBlocking(event) + } else { + filtered.incrementAndGet() + } + } + + // The two phases run by hand rather than through + // negentropySyncOrFetch, for two reasons. The combinator keeps + // every delivered id for cross-phase dedup — a multi-million- + // event catch-up cannot afford that heap, and the store's + // unique-id constraint dedups anyway. And the fallback must + // reset the observed span: a half-finished reconcile delivers + // events scattered across the whole leg, and a band built from + // that scatter would claim interior ranges nobody walked. + val legPaged = + try { + downloaded += + client + .negentropySync( + relay = up.url, + filter = leg, + localEntries = localEntries, + onEvent = ::observe, + ).downloaded + false + } catch (e: NegentropySyncException) { + seenMin = null + seenMax = null + // The watchdog matches negentropySync's default rather + // than fetchAllPages' shorter one: a paged catch-up + // sits behind the same slow upstreams. + downloaded += client.fetchAllPages(up.url, listOf(leg), idleTimeoutMs = 120_000L) { observe(it) } + true + } + paged = paged || legPaged // Recorded per leg, so a failure between legs keeps the ground - // the first one gained. A clean reconcile is complete through - // the instant its snapshot was read; a paged fallback earns - // only the span it actually saw. - coverage?.record( - up.url, - scopedBase, - seenMin, - seenMax, - paged = result.pagedFallback, - reconciledThrough = if (result.pagedFallback) null else syncStartedAt, - ) + // the first one gained — and no more: a reconcile compared only + // its own leg, so completeness reaches the leg's ceiling, never + // "now" while a later leg is still pending. A paged fallback + // earns only the span it actually saw, capped at the snapshot + // instant so one future-dated event cannot lift the band's + // ceiling past what was asked. + if (legPaged) { + coverage?.record( + up.url, + scopedBase, + seenMin, + seenMax?.coerceAtMost(syncStartedAt), + paged = true, + ) + } else { + val legFloor = leg.since ?: initialSince + coverage?.record( + up.url, + scopedBase, + // The compared range starts at the leg's floor whether + // or not anything was observed there — that is what a + // clean reconcile proves. + observedMin = minOf(seenMin ?: legFloor, legFloor), + observedMax = null, + paged = false, + reconciledThrough = minOf(leg.until ?: syncStartedAt, syncStartedAt), + ) + } } Log.i("MirrorWorker") { val how = if (paged) "paged REQ (upstream has no NIP-77)" else "negentropy" @@ -743,11 +788,21 @@ class MirrorWorker( runCatching { client.close() } inbound.close() scope.cancel() + // Wait (bounded) for the workers to land: a coverage.record racing + // past the state file's final flush would be recorded and lost. + // Bounded because a worker parked in a blocking hand-off does not + // feel the cancel, and shutdown must not hang on it. + runBlocking { + withTimeoutOrNull(CLOSE_JOIN_MS) { scope.coroutineContext[Job]?.join() } + } okhttp?.dispatcher?.executorService?.shutdown() okhttp?.connectionPool?.evictAll() } private companion object { + /** How long [close] waits for the worker coroutines to land. */ + const val CLOSE_JOIN_MS = 5_000L + /** Matches the Android app's relay-pool WebSocket ping interval. */ const val PING_INTERVAL_SECS = 120L @@ -773,7 +828,7 @@ class MirrorWorker( /** * Depth of the catch-up hand-off between the negentropy download and the - * ingest consumer. Small: negentropySyncOrFetch is already internally + * ingest consumer. Small: the negentropy download is already internally * backpressured, so this only smooths the seam — the bounded IngestQueue * behind `server.ingest` is the real limiter. */ diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt index f02d8f56f5..b31c51a60d 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt @@ -31,6 +31,7 @@ import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.long import kotlinx.serialization.json.put import java.io.File +import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption @@ -58,7 +59,8 @@ class SyncCoverageFile( init { load() - // Loading marks every restored band dirty; the file already has them. + // restore() bypasses onChange, but stay defensive: reopening a file + // must never count as a change, or every boot rewrites it. dirty = false flusher = Thread { @@ -130,7 +132,16 @@ class SyncCoverageFile( file.parentFile?.mkdirs() val tmp = File(file.parentFile ?: File("."), "${file.name}.tmp") tmp.writeText(json.encodeToString(JsonObject.serializer(), doc)) - Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING) + // ATOMIC_MOVE requested explicitly: without it the JVM may + // legally fall back to copy+delete, and a reader could see a + // half map. Same-directory rename, so support is the norm; a + // filesystem that truly can't gets the plain move (and the + // corrupt-file recovery absorbs the residual risk). + try { + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING) + } }.onFailure { Log.w("SyncCoverageFile") { "could not write ${file.path}: ${it.message}" } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt index c6ddff4dce..35ba9b330a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt @@ -87,8 +87,10 @@ class SyncCoverage( // filter -> its canonical json. Filter.toJson() runs to tens of thousands // of characters for author-scoped filters, and a fan-out keys once per // relay per cycle over the SAME handful of filter instances. Filter - // compares by identity, so this map is an identity cache; an - // equal-but-distinct filter still keys correctly, just without the cache. + // compares by identity, so this map is an identity cache — and an + // identity cache retains every distinct instance it is handed. A caller + // that rebuilds its filter each cycle would grow it forever, so past + // MAX_FINGERPRINTS new instances key correctly but are not cached. private val fingerprints = ConcurrentMap() /** @@ -106,6 +108,7 @@ class SyncCoverage( fun legs( url: NormalizedRelayUrl, filter: Filter, + floor: Long? = null, ): List { val band = bands[key(url, filter)] ?: return listOf(filter) // Time for another full pass: relays gain old events, and without @@ -114,9 +117,20 @@ class SyncCoverage( val legs = mutableListOf() // Older: up to and including the band's floor, but not past the - // filter's. A complete band has no older leg at all — the reconcile - // already compared the whole range. - if (!band.complete && (filter.since == null || band.minCreatedAt >= filter.since)) { + // filter's (or, when the filter has no `since`, the caller's + // [floor] — a sync window the filter itself must not carry, or it + // would change the band's key every run). A complete band compared + // its whole range already, but only down to the floor it ran + // against: a caller now reaching deeper — a raised backfill window + // — re-opens the span below the band. + val since = filter.since ?: floor + val wantsOlder = + if (band.complete) { + since != null && since < band.minCreatedAt + } else { + since == null || band.minCreatedAt >= since + } + if (wantsOlder) { legs.add(filter.copy(until = minOf(band.minCreatedAt, filter.until ?: Long.MAX_VALUE))) } @@ -211,12 +225,17 @@ class SyncCoverage( var since = Long.MAX_VALUE for (url in urls) { val legs = legs(url, filter) + // Nothing outside its band: this relay asks nothing of the + // snapshot at all — the best case must not widen the window. + if (legs.isEmpty()) continue // More than one leg means an older gap this relay still wants, so // the snapshot cannot start above the filter's own floor. val only = legs.singleOrNull() ?: return filter val legSince = only.since ?: return filter since = minOf(since, legSince) } + // Every relay fully covered: any window would do; the unnarrowed + // filter is merely safe, and callers usually skip the sync entirely. return if (since == Long.MAX_VALUE) filter else filter.copy(since = since) } @@ -245,9 +264,23 @@ class SyncCoverage( private fun key( url: NormalizedRelayUrl, filter: Filter, - ): String = "${url.url} ${fingerprints.getOrPut(filter) { filter.toJson() }}" + ): String { + val fingerprint = + fingerprints[filter] + ?: filter.toJson().also { + // Bounded: stable callers hit the cache at any size a real + // config produces; a caller minting fresh instances just + // pays the toJson each time instead of growing the heap. + if (fingerprints.size() < MAX_FINGERPRINTS) fingerprints[filter] = it + } + return "${url.url} $fingerprint" + } companion object { + // More filter instances than any deliberate configuration holds; only + // a caller rebuilding filters per cycle ever reaches it. + private const val MAX_FINGERPRINTS = 1_000 + /** * A week. Long enough that the narrow path is the normal one, short * enough that anything a band is wrong about is wrong for days, not diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgress.kt index 0f515d0ca5..348a27544d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgress.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgress.kt @@ -62,16 +62,25 @@ class PagingWindowProgress( private val windows = ConcurrentMap() - /** Begin a pagination over `[bottom, top]` seconds. An inverted window is not one. */ + /** + * Begin a pagination over `[bottom, top]` seconds. An inverted window is + * not one; a single-second window (`top == bottom`) is — coverage legs + * that re-read a band's edge second are exactly that shape. + */ fun begin( key: String, top: Long, bottom: Long, ) { - if (top > bottom) windows[key] = Window(top, bottom, nowMillis(), top) + if (top >= bottom) windows[key] = Window(top, bottom, nowMillis(), top) } - /** The pagination reached [until]; monotonic, so a page that jumps back cannot un-advance it. */ + /** + * The pagination reached [until]; monotonic, so a page that jumps back + * cannot un-advance it. The check-then-set is unsynchronized on purpose: + * one pagination is one coroutine, and a display racing a mark can only + * ever read a value one page stale. + */ fun mark( key: String, until: Long, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt index d955ed927c..18c0dfa34e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/backend/IngestQueue.kt @@ -36,6 +36,7 @@ import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.cancellation.CancellationException /** * Group-commit writer for incoming EVENT publishes. @@ -290,6 +291,11 @@ class IngestQueue( } else { try { store.batchInsert(toInsert) + } catch (e: CancellationException) { + // Shutdown, not a store failure: rethrow so the loop + // stops instead of stamping the batch Failed and + // carrying on while cancelled. + throw e } catch (e: Throwable) { Log.w("IngestQueue") { "batchInsert failed for ${toInsert.size} events: ${e.message}" } val reason = e.message ?: e::class.simpleName ?: "insert failed" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index 16b82132e9..ae49e25749 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlin.coroutines.cancellation.CancellationException /** * Storage contract for Nostr events: insert, filter-query, count, delete, @@ -136,16 +137,20 @@ interface IEventStore : AutoCloseable { * * Default impl runs each insert in its own transaction — correct * but loses the group-commit win — and cannot classify a throw from - * [insert], so it reports `Rejected`. Implementations that can tell - * a refusal from a write error should override and say which. + * [insert], so it reports `Failed`: re-offering a duplicate is + * idempotent, while dropping a good event on a transient store + * error is not. Implementations that can tell a refusal from a + * write error should override and say which. */ suspend fun batchInsert(events: List): List = events.map { event -> try { insert(event) InsertOutcome.Accepted + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { - InsertOutcome.Rejected(e.message ?: e::class.simpleName ?: "insert failed") + InsertOutcome.Failed(e.message ?: e::class.simpleName ?: "insert failed") } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt index c314c4e3b2..71a6e3c292 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt @@ -103,7 +103,7 @@ class ObservableEventStore( // order. Already-expired ephemerals are dropped (matching // [insert]). Accepted events are emitted on [_changes] only // after the inner batch returns, so a commit failure that - // converts everything to Rejected suppresses the emits. + // converts everything to Failed suppresses the emits. if (events.isEmpty()) return emptyList() val outcomes = arrayOfNulls(events.size) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 93f3bb5bb6..3db879925d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -510,7 +510,29 @@ class SQLiteEventStore( // ROLLBACK shouldn't mask the original cause. runCatching { db.execSQL("ROLLBACK TRANSACTION TO SAVEPOINT $sp") } runCatching { db.execSQL("RELEASE SAVEPOINT $sp") } - IEventStore.InsertOutcome.Rejected(e.message ?: e::class.simpleName ?: "insert failed") + classifyRowError(e) + } + } + + /** + * Which side failed decides whether the caller may drop the event. + * Policy refusals are recognizable — every schema trigger RAISEs with + * a `blocked:` prefix, the immutability guards say "not allowed", and + * a duplicate id is a constraint violation. Anything else (disk full, + * I/O error, schema drift) is the store failing to write an acceptable + * event: `Failed`, so a rising count is loud instead of blending into + * the duplicate tally. + */ + private fun classifyRowError(e: Throwable): IEventStore.InsertOutcome { + val message = e.message ?: e::class.simpleName ?: "insert failed" + val refusal = + message.contains("blocked:") || + message.contains("not allowed") || + message.contains("constraint", ignoreCase = true) + return if (refusal) { + IEventStore.InsertOutcome.Rejected(message) + } else { + IEventStore.InsertOutcome.Failed(message) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/HostStrikes.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/HostStrikes.kt index 11a2c2d51d..1b846eac93 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/HostStrikes.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/HostStrikes.kt @@ -82,7 +82,13 @@ class HostStrikes( val authority = authorityOf(url.url) if (authority in producedHosts || authority in deadHosts) return null if (strikes.merge(authority, 1) { old, new -> old + new } < strikeLimit) return null - deadHosts.add(authority) + // Concurrent strikers can cross the threshold together; add() is the + // atomic exactly-once gate on who publishes. Re-check produced after + // winning it: a delivery that landed while this strike was in flight + // outranks the verdict, and a verdict for a host that just answered + // would be a false public record. + if (!deadHosts.add(authority)) return null + if (authority in producedHosts) return null return Evicted(authority, strikeLimit) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt index 78abe61682..53120624a4 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt @@ -159,6 +159,22 @@ class SyncCoverageTest { assertEquals(2, walked.legs(relay, profiles).size, "a paged walk says nothing about what it never asked for") } + @Test + fun `a deeper floor re-opens history below a complete band`() { + // A reconcile only compared down to the window it ran against. When + // the operator raises the backfill window, the span below the band's + // recorded floor is ground nobody ever asked for. + val c = SyncCoverage() + c.record(relay, profiles, 1_700_000_000L, null, paged = false, reconciledThrough = 1_700_002_000L) + + assertEquals(1, c.legs(relay, profiles, floor = 1_700_000_000L).size, "same floor: nothing older to ask") + + val legs = c.legs(relay, profiles, floor = 1_600_000_000L) + assertEquals(2, legs.size, "a deeper floor re-opens the older span") + assertEquals(1_700_000_000L, legs[0].until, "up to the floor the reconcile actually compared") + assertEquals(1_700_002_000L, legs[1].since) + } + // ---- the periodic full re-walk ----------------------------------------- @Test @@ -224,6 +240,20 @@ class SyncCoverageTest { assertEquals(1_700_003_000L, c.coveringWindow(listOf(relay, other, third), profiles).since) } + @Test + fun `a fully covered relay does not widen the shared window`() { + // A complete band past a bounded filter's ceiling needs no legs at + // all. The best case must not force the snapshot back to the whole + // filter — that would make full coverage cost the most. + val window = Filter(kinds = listOf(0), since = 1_700_000_000L, until = 1_700_005_000L) + val c = SyncCoverage() + c.record(relay, window, 1_700_000_000L, null, paged = false, reconciledThrough = 1_700_009_000L) + c.record(other, window, 1_700_000_000L, null, paged = false, reconciledThrough = 1_700_003_000L) + + assertEquals(0, c.legs(relay, window).size, "covered past the ceiling: nothing to ask") + assertEquals(1_700_003_000L, c.coveringWindow(listOf(relay, other), window).since) + } + @Test fun `a relay with an older gap also widens the shared window`() { val c = SyncCoverage() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgressTest.kt index 2c1c0ed433..35c5ecfdab 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgressTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PagingWindowProgressTest.kt @@ -39,7 +39,7 @@ class PagingWindowProgressTest { val p = PagingWindowProgress() p.begin("a", top = 1_000L, bottom = 0L) - assertClose(0.0, p.fraction(), "nothing walked yet") + assertClose(0.0, p.fraction(), "nothing paged yet") p.mark("a", 750L) assertClose(0.25, p.fraction()) @@ -64,7 +64,7 @@ class PagingWindowProgressTest { @Test fun `windows average rather than sum`() { - // Two relays each walking their own window: one done and one untouched + // Two relays each paging their own window: one done and one untouched // is half way — not 100% as summing would give. val p = PagingWindowProgress() p.begin("a", top = 1_000L, bottom = 0L) @@ -84,14 +84,14 @@ class PagingWindowProgressTest { p.finish("a") - assertClose(0.5, p.fraction(), "only b is still walking") + assertClose(0.5, p.fraction(), "only b is still paging") p.finish("b") assertNull(p.fraction(), "nothing paging means no number to report") } @Test - fun `a group prefix scopes the numbers to its own walks`() { - // One instance serves many concurrent walks; without the scope two + fun `a group prefix scopes the numbers to its own paginations`() { + // One instance serves many concurrent paginations; without the scope two // streams would print each other's percentages. val p = PagingWindowProgress() p.begin("streamA|wss://r1", top = 1_000L, bottom = 0L) @@ -104,7 +104,7 @@ class PagingWindowProgressTest { } @Test - fun `an inverted or empty window is not a pagination`() { + fun `an inverted window is not a pagination`() { // A leg whose since is above its until asks for a range nothing can be // in. Dividing by that span would produce infinities on the status line. val p = PagingWindowProgress() @@ -114,6 +114,21 @@ class PagingWindowProgressTest { assertNull(p.fraction()) } + @Test + fun `a single-second window is a pagination`() { + // Coverage legs re-read a band's edge second: since == until is a + // real, one-second range, not an inverted one. + val p = PagingWindowProgress() + + p.begin("a", top = 500L, bottom = 500L) + + assertClose(0.0, p.fraction(), "tracked from its start") + p.mark("a", 500L) + assertClose(0.0, p.fraction(), "still at its only second") + p.finish("a") + assertNull(p.fraction()) + } + @Test fun `no ETA before the estimate means anything`() { val p = PagingWindowProgress() diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ConcurrentIngestLossTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ConcurrentIngestLossTest.kt index 335d587cc6..3d87b2f457 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ConcurrentIngestLossTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ConcurrentIngestLossTest.kt @@ -120,6 +120,7 @@ class ConcurrentIngestLossTest { val accepted = ConcurrentHashMap.newKeySet() val rejected = AtomicInteger() + val failed = AtomicInteger() val window = Semaphore(200) val done = CompletableDeferred() val remaining = AtomicInteger(events.size) @@ -130,7 +131,7 @@ class ConcurrentIngestLossTest { when (outcome) { is IEventStore.InsertOutcome.Accepted -> accepted.add(e.id) is IEventStore.InsertOutcome.Rejected -> rejected.incrementAndGet() - is IEventStore.InsertOutcome.Failed -> rejected.incrementAndGet() + is IEventStore.InsertOutcome.Failed -> failed.incrementAndGet() } window.release() if (remaining.decrementAndGet() == 0) done.complete(Unit) @@ -155,7 +156,7 @@ class ConcurrentIngestLossTest { } } val stored = store.count(Filter()) - println(" submitted=${events.size} accepted=${accepted.size} rejected=${rejected.get()} stored=$stored lostAcceptedRegular=$lost") + println(" submitted=${events.size} accepted=${accepted.size} rejected=${rejected.get()} failed=${failed.get()} stored=$stored lostAcceptedRegular=$lost") ingest.close() queueJob.cancel() From 156176f5fe8c72df074beb6438f85d6722d0797f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:22:55 +0000 Subject: [PATCH 104/227] fix(quartz): stop RelayUrlNormalizer from accepting urls that can never be relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated against a 45k-entry corpus of relay-url hints exported from real events (317k tag occurrences). The normalizer was converting ~30k distinct https:// urls with paths (Mastodon/bridge actor urls from proxy tags, web pages, images) into wss:// addresses that can never answer, wasting connection attempts and relay-pool slots. - http(s) → ws(s) scheme swap now only applies to bare hosts (host[:port] plus optional trailing slash); an http url with a path, query or fragment is a web resource, not a mistyped relay. - Authority validation for all schemes: rejects empty hosts, userinfo (@), percent-encoding and commas in the host, and paths that start with // (the signature of a second pasted url, e.g. wss://https//host). - Interior whitespace and backslashes reject the whole string (multiple urls or prose in one field). - Zero-width characters (U+200B..D, U+2060, BOM) are stripped instead of corrupting the parse (wss://\u200Bnos.lol previously normalized to the scheme-less //nos.lol/). - Schemeless candidates must look like host[:port] (single colon, numeric port), rejecting addressable pointers (31990:pubkey:dtag) and bare scheme leftovers (wss:) before the expensive RFC 3986 parse. - Protocol-relative //host/ inputs normalize as wss:// instead of resolving to https://. - normalizeOrNull now double-checks the parser output still starts with ws(s):// and rejects otherwise. Corpus impact: 30,014 garbage urls (30,333 events) now rejected, 0 real relays lost (all 15,162 kept urls normalize byte-identically), 6 broken outputs fixed. fix() itself stays allocation-free on the happy path (~357ns vs ~318ns per call on the garbage-heavy corpus). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK --- .../relay/normalizer/RelayUrlNormalizer.kt | 147 ++++++++++++++++-- .../nip01Core/relay/RelayUrlFormatterTest.kt | 81 ++++++++++ 2 files changed, 216 insertions(+), 12 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt index 04f8c9cc06..8949a0b82c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -84,6 +84,97 @@ class RelayUrlNormalizer { private fun norm(url: String) = NormalizedRelayUrl(Rfc3986.normalize(url)) + private fun isInvisible(c: Char) = c == '\u200B' || c == '\u200C' || c == '\u200D' || c == '\u2060' || c == '\uFEFF' + + /** + * Scans the authority (host[:port]) that starts at [start] and ends at the first + * `/`, `?` or `#`. Returns the end index, or -1 when the authority is empty or + * contains characters that never appear in a real relay host (`@` userinfo, + * percent-encoding, commas). + */ + private fun authorityEnd( + url: String, + start: Int, + ): Int { + if (start >= url.length) return -1 + var i = start + if (url[i] == '[') { + // IPv6 literal: defer validation to the RFC 3986 parser + while (i < url.length && url[i] != '/' && url[i] != '?' && url[i] != '#') i++ + return i + } + while (i < url.length) { + val c = url[i] + if (c == '/' || c == '?' || c == '#') break + if (c == '@' || c == '%' || c == ',') return -1 + i++ + } + return if (i == start) -1 else i + } + + /** + * Accepts a ws/wss url whose host starts at [hostStart] if the authority is sane + * and the path does not start with `//` (the signature of a second URL or a broken + * `https//` pasted after the scheme, e.g. `wss://https//nostr.watch/relay/x`). + */ + private fun fixWs( + url: String, + hostStart: Int, + ): String? { + val end = authorityEnd(url, hostStart) + if (end < 0) return null + if (end + 1 < url.length && url[end] == '/' && url[end + 1] == '/') return null + return url + } + + /** + * Converts an http(s) url to ws(s) only when it is a bare host — nothing after + * `host[:port]` but an optional trailing `/`. An http url with a path, query or + * fragment (Mastodon actor urls from bridge `proxy` tags, web pages, images) is + * a web resource, not a relay: converting it creates a wss:// url that can never + * answer and only wastes connection attempts. + */ + private fun fixHttp( + url: String, + hostStart: Int, + newScheme: String, + ): String? { + val end = authorityEnd(url, hostStart) + if (end < 0) return null + val bareHost = end == url.length || (end == url.length - 1 && url[end] == '/') + if (!bareHost) return null + return "$newScheme${url.substring(hostStart)}" + } + + /** + * Validates a schemeless candidate: the part before the first `/` must look like + * `host` or `host:port` — letters, digits, `.`, `-`, `_`, plus at most one `:` + * followed by digits only. Rejects addressable-event pointers (`31990:hex:dtag`), + * bare scheme leftovers (`wss:`) and anything else that would otherwise be blindly + * prefixed with `wss://`. + */ + private fun isBareHostAndPath(url: String): Boolean { + if (url[0] == '[') return true // IPv6 literal: defer to the RFC 3986 parser + var i = 0 + var portStart = -1 + while (i < url.length) { + val c = url[i] + if (c == '/') break + if (c == ':') { + if (portStart >= 0) return false + portStart = i + 1 + } else if (portStart >= 0) { + if (c < '0' || c > '9') return false + } else if (!c.isLetterOrDigit() && c != '.' && c != '-' && c != '_') { + return false + } + i++ + } + if (i == 0) return false + if (portStart >= 0 && portStart == i) return false + return true + } + @OptIn(ExperimentalContracts::class) fun fix(rawUrl: String): String? { if (rawUrl.length < 4) return null @@ -109,17 +200,33 @@ class RelayUrlNormalizer { } } - val trimmed = + var trimmed = if (url[0].isWhitespace() || url[url.length - 1].isWhitespace()) { url.trim() } else { url } + // Single pass: interior whitespace means multiple urls or prose in one field, + // backslashes never appear in a real relay url; both are garbage. Invisible + // characters (zero-width spaces, BOM) are copy-paste artifacts — strip them. + var hasInvisible = false + for (c in trimmed) { + if (c == '\\') return null + if (c.isWhitespace()) return null + if (isInvisible(c)) hasInvisible = true + } + if (hasInvisible) { + trimmed = buildString(trimmed.length) { for (c in trimmed) if (!isInvisible(c)) append(c) } + if (trimmed.length < 4) return null + } + // fast for good wss:// urls if (isRelaySchemePrefix(trimmed)) { - if (isRelaySchemePrefixSecure(trimmed) || isRelaySchemePrefixInsecure(trimmed)) { - return trimmed + if (isRelaySchemePrefixSecure(trimmed)) { + return fixWs(trimmed, 6) + } else if (isRelaySchemePrefixInsecure(trimmed)) { + return fixWs(trimmed, 5) } } @@ -127,31 +234,31 @@ class RelayUrlNormalizer { if (isHttpPrefix(trimmed)) { if (isHttpSSuffix(trimmed)) { // https:// - return "wss://${trimmed.drop(8)}" + return fixHttp(trimmed, 8, "wss://") } else if (isHttpSuffix(trimmed)) { // http:// - return "ws://${trimmed.drop(7)}" + return fixHttp(trimmed, 7, "ws://") } } // fast for good ww:// urls if (trimmed.startsWith("ww://")) { - return "wss://${trimmed.drop(5)}" + return fixWs("wss://${trimmed.drop(5)}", 6) } // fast for good ww:// urls if (trimmed.startsWith("was://")) { - return "wss://${trimmed.drop(6)}" + return fixWs("wss://${trimmed.drop(6)}", 6) } // fast for good ww:// urls if (trimmed.startsWith("Wws://")) { - return "wss://${trimmed.drop(6)}" + return fixWs("wss://${trimmed.drop(6)}", 6) } // fast for good ww:// urls if (trimmed.startsWith("Wss://")) { - return "wss://${trimmed.drop(6)}" + return fixWs("wss://${trimmed.drop(6)}", 6) } if (trimmed.contains("://")) { @@ -160,10 +267,19 @@ class RelayUrlNormalizer { return null } - return if (isOnion(trimmed) || isLocalHost(trimmed)) { - "ws://$trimmed" + // protocol-relative urls (`//host/`) are just missing the scheme + val bare = if (trimmed.startsWith("//")) trimmed.drop(2) else trimmed + if (bare.length < 4) return null + + if (!isBareHostAndPath(bare)) { + Log.d("RelayUrlNormalizer") { "Rejected $url" } + return null + } + + return if (isOnion(bare) || isLocalHost(bare)) { + "ws://$bare" } else { - "wss://$trimmed" + "wss://$bare" } } @@ -186,6 +302,13 @@ class RelayUrlNormalizer { val fixed = fix(url) if (fixed != null) { val normalized = norm(fixed) + // the RFC 3986 parser can drop or replace the scheme on odd inputs; + // anything that is not ws(s):// at this point cannot be connected to. + if (!isRelayUrl(normalized.url)) { + Log.d("NormalizedRelayUrl") { "Rejected $url" } + normalizedUrls.put(url, NormalizationResult.Error) + return null + } normalizedUrls.put(url, NormalizationResult.Success(normalized)) normalized } else { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt index 82287dcbad..fc708eddba 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt @@ -52,4 +52,85 @@ class RelayUrlFormatterTest { fun weirdRelay() { assertNull(RelayUrlNormalizer.normalizeOrNull("wss://relay%20list%20to%20discover%20the%20user's%20content")) } + + @Test + fun httpWithPathIsNotARelay() { + // Mastodon/bridge actor urls from `proxy` tags: web resources, not relays + assertNull(RelayUrlNormalizer.normalizeOrNull("https://mastodon.social/users/amanita_muscaria")) + assertNull(RelayUrlNormalizer.normalizeOrNull("https://fosstodon.org/ap/users/115532410310000993")) + assertNull(RelayUrlNormalizer.normalizeOrNull("http://example.com/relay")) + assertNull(RelayUrlNormalizer.normalizeOrNull("https://nostr.mom/?author=0")) + assertNull(RelayUrlNormalizer.normalizeOrNull("https://nostr.mom/#section")) + + // but bare hosts still convert, with or without port and trailing slash + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("https://nostr.mom")?.url) + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("https://nostr.mom/")?.url) + assertEquals("wss://nostr.mom:4443/", RelayUrlNormalizer.normalizeOrNull("https://nostr.mom:4443/")?.url) + assertEquals("ws://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("http://nostr.mom")?.url) + } + + @Test + fun wsWithPathIsStillARelay() { + assertEquals("wss://relay.nostr.band/all", RelayUrlNormalizer.normalizeOrNull("wss://relay.nostr.band/all")?.url) + assertEquals( + "wss://bostr.lecturify.net/?accept=0,1", + RelayUrlNormalizer.normalizeOrNull("wss://bostr.lecturify.net/?accept=0,1")?.url, + ) + } + + @Test + fun brokenSchemeGarbage() { + assertNull(RelayUrlNormalizer.normalizeOrNull("wss:")) + assertNull(RelayUrlNormalizer.normalizeOrNull("wss://https//nostr.watch/relay/nostr.21crypto.ch")) + assertNull(RelayUrlNormalizer.normalizeOrNull("wss://https://lockbox.fiatjaf.com")) + assertNull(RelayUrlNormalizer.normalizeOrNull("ws://http//nos.lol")) + assertNull(RelayUrlNormalizer.normalizeOrNull("wss://://plebstr.com")) + } + + @Test + fun nostrUriIsNotARelay() { + assertNull(RelayUrlNormalizer.normalizeOrNull("nostr://nrelay1qqxhwumn8ghj77tpvf6jumt9e2ckgn/")) + assertNull(RelayUrlNormalizer.normalizeOrNull("nostr://npub1dwy079xmpz7mk02kvz6wan49h02635umk32aa4ufek8t8mjxv58qy2nr22/")) + assertNull(RelayUrlNormalizer.normalizeOrNull("nostr:nrelay1qq8k2cnfwejhyum99eek7cmfv9kqsm7sdm")) + } + + @Test + fun addressablePointerIsNotARelay() { + assertNull(RelayUrlNormalizer.normalizeOrNull("31990:6be38f8c63df7dbf84db7ec4a6e6fbbd8d19dca3b980efad18585c46f04b26f9:mostr")) + } + + @Test + fun authorityGarbage() { + // userinfo, percent-encoding and commas never appear in a real relay host + assertNull(RelayUrlNormalizer.normalizeOrNull("wss://catuaba@plebs.place/")) + assertNull(RelayUrlNormalizer.normalizeOrNull("wss://africa.nostr.joburg%0A/")) + assertNull(RelayUrlNormalizer.normalizeOrNull("wss://bitcoiner,social/")) + assertNull(RelayUrlNormalizer.normalizeOrNull("wss://#web3/")) + assertNull(RelayUrlNormalizer.normalizeOrNull("name@domain.com")) + } + + @Test + fun interiorWhitespaceAndBackslashes() { + assertNull(RelayUrlNormalizer.normalizeOrNull("wss://nos lol")) + assertNull(RelayUrlNormalizer.normalizeOrNull("wss://nos.lol/ wss:/nostr.land/ avatar wss:/nostr.wine/")) + assertNull(RelayUrlNormalizer.normalizeOrNull("wss://\\\\relay.damus.io/")) + } + + @Test + fun invisibleCharactersAreStripped() { + assertEquals("wss://nos.lol/", RelayUrlNormalizer.normalizeOrNull("wss://\u200Bnos.lol")?.url) + assertEquals("wss://nos.lol/", RelayUrlNormalizer.normalizeOrNull("\uFEFFwss://nos.lol")?.url) + } + + @Test + fun protocolRelativeUrls() { + assertEquals("wss://relay.most.pub/", RelayUrlNormalizer.normalizeOrNull("//relay.most.pub/")?.url) + assertEquals("wss://nos.lol/", RelayUrlNormalizer.normalizeOrNull("//nos.lol/")?.url) + } + + @Test + fun ipv6AndLanHostsStillWork() { + assertEquals("ws://[31b:6f20:c7f2:3ddf::3221]/", RelayUrlNormalizer.normalizeOrNull("ws://[31b:6f20:c7f2:3ddf::3221]/")?.url) + assertEquals("ws://geyser-relay:7777/", RelayUrlNormalizer.normalizeOrNull("ws://geyser-relay:7777/")?.url) + } } From ba7cc72dd28bdffee33fba3cc459e1e6be8b0381 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:22:55 +0000 Subject: [PATCH 105/227] feat(cli): amy relay probe --file to census external relay-url candidates Feeds a file of raw candidate urls (one per line) through the same RelayUrlNormalizer the app uses, then probes the surviving clearnet set alongside the store's known universe. Rejected and onion counts are reported (file_urls/file_normalized/file_rejected in the JSON output), and results land in the NIP-66 kind:30166 reachability cache keyed by the normalized url as d-tag, as usual. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK --- .../amethyst/cli/commands/RelayCommands.kt | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index 64084a9356..8734ba1b02 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation @@ -50,6 +51,7 @@ import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayProber import okhttp3.OkHttpClient import okhttp3.Request +import java.io.File /** * `amy relay …` — manage every relay list this account maintains, mirroring @@ -112,10 +114,12 @@ object RelayCommands { | relay info URL fetch + print a relay's NIP-11 info document (stateless) | relay probe [--timeout SECS] relay census: mass-connect every relay the store | [--concurrency N] knows and record live/dead + measured rtt-open - | into the reachability cache (NIP-66 kind:30166), + | [--file PATH] into the reachability cache (NIP-66 kind:30166), | so reachability-aware commands (graperank crawl/ | refresh) skip dead relays and wait once - | (--timeout: per wave, default 15s) + | (--timeout: per wave, default 15s; --file: also + | probe candidate urls, one per line, each run + | through the relay url normalizer first) """.trimMargin() // ------------------------------------------------------------------ @@ -337,12 +341,36 @@ object RelayCommands { // Relays dialed at once; --relay-concurrency accepted as the alias the // graperank verbs spell it with. val waveSize = args.intFlag("concurrency", args.intFlag("relay-concurrency", Context.defaultPreconnectCap)) + // Optional external candidate list: one raw url per line, run through the + // same RelayUrlNormalizer the app uses, so a probe doubles as a census of + // how a corpus of relay hints normalizes (rejects are counted, not dialed). + val fromFile = args.flag("file") args.rejectUnknown() + var fileRaw = 0 + var fileRejected = 0 + val fileRelays = HashSet() + if (fromFile != null) { + File(fromFile).forEachLine { line -> + if (line.isBlank()) return@forEachLine + fileRaw++ + val normalized = line.normalizeRelayUrlOrNull() + if (normalized == null) { + fileRejected++ + } else if (!RelayUrlNormalizer.isOnion(normalized.url)) { + fileRelays.add(normalized) + } + } + System.err.println( + "[relay-probe] $fromFile: $fileRaw urls → ${fileRelays.size} unique clearnet relays " + + "($fileRejected rejected by the normalizer)", + ) + } + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val cached = ctx.reachability.snapshot() - val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead + val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead + fileRelays if (universe.isEmpty()) { Output.emit( linkedMapOf( @@ -381,6 +409,9 @@ object RelayCommands { Output.emit( linkedMapOf( "probed" to result.verdicts.size, + "file_urls" to (if (fromFile != null) fileRaw else null), + "file_normalized" to (if (fromFile != null) fileRelays.size else null), + "file_rejected" to (if (fromFile != null) fileRejected else null), "reachable" to result.reachable.size, "dead" to result.dead.size, "closed_by_policy" to authWalled, From d9a58950ecc24a566d3626948e106de6675c1be8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:57:00 +0000 Subject: [PATCH 106/227] feat(quartz): stream NIP-66 probe verdicts and expose them as signable 30166 templates RelayProber.probeFlow(urls) is a cold Flow that emits each relay's Verdict the moment the relay resolves (EOSE, CLOSED or connect failure) instead of at the end of the whole census; only silent relays wait for their wave's deadline. probeWave now resolves verdicts per-terminal, so the batch probe() shares the same path. Verdict.toDiscoveryEventTemplate() renders a verdict as an UNSIGNED kind:30166 template (d = normalized url, n network type, rtt-open when reachable, R auth when the probe hit a NIP-42 auth-required CLOSED) so an external consumer signs with its own monitor key: prober.probeFlow(urls).map { it.toDiscoveryEventTemplate() } .collect { publish(signer.sign(it)) } rtt-eose is deliberately never published as rtt-read: it is measured from the wave start (dial + TLS + queueing + read), and aggregators rank on rtt values. The RelayObserver/RelayMonitor path supplies honest rtt-read/rtt-write from real traffic. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK --- .../reachability/RelayProber.kt | 101 ++++++-- .../reachability/RelayProberFlowTest.kt | 219 ++++++++++++++++++ 2 files changed, 301 insertions(+), 19 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt index a95da3f70a..364f4e1bd0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt @@ -29,10 +29,19 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.networkType +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.requirement +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.rtt +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType +import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withTimeoutOrNull import kotlin.time.TimeSource @@ -102,7 +111,7 @@ class RelayProber( val distinct = relays.toSet() var done = 0 for (wave in distinct.chunked(waveSize.coerceAtLeast(1))) { - all += probeWave(wave, timeoutMs) + probeWave(wave, timeoutMs) { all += it } done += wave.size if (distinct.size > wave.size) { val liveSoFar = all.count { it.reachable } @@ -112,10 +121,33 @@ class RelayProber( return Result(all, mark.elapsedNow().inWholeMilliseconds) } + /** + * Streaming variant of [probe]: a cold [Flow] that emits each relay's [Verdict] + * the moment that relay resolves — an answering relay's verdict arrives as soon + * as its EOSE/CLOSED/connect-failure lands, not when the whole census ends. Only + * relays that stay silent wait for their wave's [timeoutMs] deadline. + * + * Probing starts when the flow is collected and pauses between waves while the + * collector is busy (emission is sequential). Pair each verdict with + * [toDiscoveryEventTemplate] to turn the stream into signable NIP-66 kind:30166 + * records for another process to sign and publish. + */ + fun probeFlow( + relays: Collection, + timeoutMs: Long = 15_000, + waveSize: Int = 1000, + ): Flow = + flow { + for (wave in relays.toSet().chunked(waveSize.coerceAtLeast(1))) { + probeWave(wave, timeoutMs) { emit(it) } + } + } + private suspend fun probeWave( wave: List, timeoutMs: Long, - ): List { + onVerdict: suspend (Verdict) -> Unit, + ) { val mark = TimeSource.Monotonic.markNow() val waveSet = wave.toHashSet() val openRtt = ConcurrentMap() @@ -178,22 +210,7 @@ class RelayProber( } } - client.addConnectionListener(connListener) - try { - client.subscribe(subId, wave.associateWith { PROBE_FILTERS }, subListener) - val remaining = wave.toMutableSet() - withTimeoutOrNull(timeoutMs) { - while (remaining.isNotEmpty()) { - remaining.remove(terminals.receive()) - } - } - } finally { - client.unsubscribe(subId) - client.removeConnectionListener(connListener) - terminals.close() - } - - return wave.map { relay -> + fun verdictOf(relay: NormalizedRelayUrl): Verdict { val opened = openRtt[relay] val answered = eoseMs[relay] val error = errors[relay] @@ -202,7 +219,7 @@ class RelayProber( // Only a connect failure, or silence with no socket, is dead. val cannot = error?.startsWith("cannot:") == true val reachable = !cannot && (opened != null || answered != null || error != null) - Verdict( + return Verdict( relay = relay, reachable = reachable, rttOpenMs = opened ?: -1, @@ -210,6 +227,27 @@ class RelayProber( error = error, ) } + + client.addConnectionListener(connListener) + try { + client.subscribe(subId, wave.associateWith { PROBE_FILTERS }, subListener) + val remaining = wave.toMutableSet() + while (remaining.isNotEmpty()) { + val left = timeoutMs - mark.elapsedNow().inWholeMilliseconds + if (left <= 0) break + val relay = withTimeoutOrNull(left) { terminals.receive() } ?: break + // The emission happens OUTSIDE the timeout window so a collector that + // suspends on a verdict can never be cancelled mid-emission and lose it. + if (remaining.remove(relay)) onVerdict(verdictOf(relay)) + } + // Whatever is left resolved nothing by the deadline: dead if the socket + // never opened, reachable-but-slow if it did. + for (relay in remaining) onVerdict(verdictOf(relay)) + } finally { + client.unsubscribe(subId) + client.removeConnectionListener(connListener) + terminals.close() + } } companion object { @@ -263,3 +301,28 @@ class RelayProber( } } } + +/** + * This verdict as an UNSIGNED NIP-66 kind:30166 Relay Discovery template — the d-tag + * is the normalized relay url; sign it with the consumer's own monitor key (per + * NIP-66 a monitor is its own identity, so the prober never signs on its own). + * + * Only facts this probe actually observed are tagged: + * - `n` network type inferred from the url (clearnet/tor/i2p); + * - `rtt-open` when the relay was reachable — the measured WS-upgrade round trip, + * or 0 for "reachable, latency not observed" (liveness is the tag's PRESENCE); + * - `R auth` when the relay answered the probe REQ with a NIP-42 `auth-required` + * CLOSED — an observed auth wall, not a NIP-11 claim. + * + * [Verdict.rttEoseMs] is deliberately NOT written as `rtt-read`: it is measured from + * the wave start, so it bundles dial, TLS and any handshake queueing with the read — + * publishing it as a read round trip would hand aggregators an inflated latency. + * The [RelayObserver]/[RelayMonitor] path supplies honest `rtt-read`/`rtt-write` + * from real traffic instead. + */ +fun RelayProber.Verdict.toDiscoveryEventTemplate(createdAt: Long = TimeUtils.now()): EventTemplate = + RelayDiscoveryEvent.build(relay, createdAt = createdAt) { + networkType(RelayReachabilityStore.networkTypeOf(relay)) + if (reachable) rtt(RttType.OPEN, rttOpenMs.coerceAtLeast(0)) + if (error?.startsWith("closed:auth-required") == true) requirement("auth") + } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt new file mode 100644 index 0000000000..34b1ce15fc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip66RelayMonitor.reachability + +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Pins [RelayProber.probeFlow]'s streaming contract: an answering relay's verdict + * is emitted the moment its terminal arrives, while silent relays only resolve at + * the wave deadline — and pins the observed-facts-only tag set of + * [toDiscoveryEventTemplate]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class RelayProberFlowTest { + /** Captures the probe subscription so the test can play the relays. */ + private class ScriptedClient : INostrClient by EmptyNostrClient() { + var listener: SubscriptionListener? = null + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + this.listener = listener + } + } + + private val fast = RelayUrlNormalizer.normalize("wss://fast.example.com") + private val silent = RelayUrlNormalizer.normalize("wss://silent.example.com") + private val walled = RelayUrlNormalizer.normalize("wss://walled.example.com") + + @Test + fun answeringRelayStreamsBeforeTheWaveDeadline() = + runTest { + val client = ScriptedClient() + val arrivals = mutableListOf>() + + val collector = + launch { + RelayProber(client) + .probeFlow(listOf(fast, silent), timeoutMs = 10_000) + .collect { arrivals += it to currentTime } + } + launch { + delay(200) + client.listener!!.onEose(fast, null) + } + collector.join() + + assertEquals(listOf(fast, silent), arrivals.map { it.first.relay }) + // The EOSE'd relay resolved when it answered, not at the deadline … + val (fastVerdict, fastAt) = arrivals[0] + assertTrue(fastVerdict.reachable) + assertTrue(fastAt < 1_000, "verdict should stream at answer time, arrived at ${fastAt}ms") + // … while the silent one waited out the wave and is dead (socket never opened). + val (silentVerdict, silentAt) = arrivals[1] + assertFalse(silentVerdict.reachable) + assertTrue(silentAt >= 10_000, "silent relay must wait for the deadline, arrived at ${silentAt}ms") + } + + @Test + fun authWalledRelayIsReachableWithTheWallRecorded() = + runTest { + val client = ScriptedClient() + val arrivals = mutableListOf() + + val collector = + launch { + RelayProber(client) + .probeFlow(listOf(walled), timeoutMs = 10_000) + .collect { arrivals += it } + } + launch { + delay(100) + client.listener!!.onClosed("auth-required: sign in first", walled, null) + } + collector.join() + + assertEquals(1, arrivals.size) + assertTrue(arrivals[0].reachable, "an auth wall is an app-level answer: the relay works") + assertEquals("closed:auth-required: sign in first", arrivals[0].error) + } + + @Test + fun connectFailureStreamsImmediatelyAsDead() = + runTest { + val client = ScriptedClient() + val arrivals = mutableListOf>() + + val collector = + launch { + RelayProber(client) + .probeFlow(listOf(fast), timeoutMs = 10_000) + .collect { arrivals += it to currentTime } + } + launch { + delay(50) + client.listener!!.onCannotConnect(fast, "dns failure", null) + } + collector.join() + + val (verdict, at) = arrivals.single() + assertFalse(verdict.reachable) + assertEquals("cannot:dns failure", verdict.error) + assertTrue(at < 1_000, "a failed dial must not wait for the deadline, arrived at ${at}ms") + } + + // ------------------------------------------------------------------ + // toDiscoveryEventTemplate — only observed facts become tags + // ------------------------------------------------------------------ + + private fun tagsOf(template: EventTemplate<*>) = template.tags.map { it.toList() } + + @Test + fun reachableVerdictTemplateCarriesLivenessAndNetwork() { + val template = + RelayProber + .Verdict(fast, reachable = true, rttOpenMs = 150, rttEoseMs = 480, error = null) + .toDiscoveryEventTemplate(createdAt = 1000) + + val tags = tagsOf(template) + assertEquals(30166, template.kind) + assertEquals(1000, template.createdAt) + assertTrue(listOf("d", fast.url) in tags) + assertTrue(listOf("n", "clearnet") in tags) + assertTrue(listOf("rtt-open", "150") in tags) + // rtt-eose is wave-relative (dial + queue + read) — never published as rtt-read. + assertNull(tags.firstOrNull { it[0] == "rtt-read" }) + } + + @Test + fun deadVerdictTemplateHasNoRttOpen() { + val template = + RelayProber + .Verdict(silent, reachable = false, rttOpenMs = -1, rttEoseMs = -1, error = "cannot:timeout") + .toDiscoveryEventTemplate() + + val tags = tagsOf(template) + assertTrue(listOf("d", silent.url) in tags) + // Liveness is the PRESENCE of rtt-open; a dead record must not carry one. + assertNull(tags.firstOrNull { it[0] == "rtt-open" }) + } + + @Test + fun reachableWithoutMeasuredLatencyWritesZeroFlag() { + val template = + RelayProber + .Verdict(fast, reachable = true, rttOpenMs = -1, rttEoseMs = 300, error = null) + .toDiscoveryEventTemplate() + + // 0 = "reachable, latency not observed": the flag form, never an invented number. + assertTrue(listOf("rtt-open", "0") in tagsOf(template)) + } + + @Test + fun observedAuthWallBecomesARequirementTag() { + val template = + RelayProber + .Verdict(walled, reachable = true, rttOpenMs = 90, rttEoseMs = -1, error = "closed:auth-required: sign in") + .toDiscoveryEventTemplate() + + assertTrue(listOf("R", "auth") in tagsOf(template)) + } + + @Test + fun policyClosedIsNotAnAuthRequirement() { + val template = + RelayProber + .Verdict(walled, reachable = true, rttOpenMs = 90, rttEoseMs = -1, error = "closed:blocked: not welcome") + .toDiscoveryEventTemplate() + + assertNull(tagsOf(template).firstOrNull { it[0] == "R" }) + } + + @Test + fun onionRelayIsTaggedTor() { + val onion = RelayUrlNormalizer.normalize("ws://someonionaddressabcdefghijklmnop.onion") + val template = + RelayProber + .Verdict(onion, reachable = true, rttOpenMs = 900, rttEoseMs = -1, error = null) + .toDiscoveryEventTemplate() + + assertTrue(listOf("n", "tor") in tagsOf(template)) + } +} From a7eec1d605728ca931b6e596e6f9832091d4c358 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 14:18:05 +0000 Subject: [PATCH 107/227] =?UTF-8?q?feat(quartz):=20NIP-66=20check=20option?= =?UTF-8?q?s=20=E2=80=94=20read-test=20filters,=20write-test=20event,=20ca?= =?UTF-8?q?ched=20NIP-11=20fetcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RelayProber.probe()/probeFlow() take a filters option choosing the check: LIVENESS_FILTERS (default, impossible-id REQ — EOSE proves liveness with no payload) or readTestFilter(limit = 1) — a REQ the relay must actually work for, querying and streaming real events, making Verdict.rttEoseMs a genuine read test. RelayProbeWriteTest.build() creates the write-check event: ephemeral kind 20166 (never stored by compliant relays) carrying a NIP-40 expiration tag 60s out as belt-and-braces for relays that store unknown ephemeral kinds. Publish it under the monitor key, time the OK for rtt-write, map rejection prefixes to R requirement tags — an OK false still proves the write path. Nip11Fetcher is the missing fetch seam for relay information documents, mirroring Nip05Fetcher: the interface lives in commonMain, OkHttpNip11Fetcher (jvmAndroid) does the Accept: application/nostr+json GET, and CachedNip11Fetcher wraps any implementation with a TTL cache — successes trusted for a day, failures remembered for five minutes so a census doesn't hammer hosts that just refused. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK --- .../nip11RelayInfo/CachedNip11Fetcher.kt | 97 ++++++++++++++ .../quartz/nip11RelayInfo/Nip11Fetcher.kt | 44 ++++++ .../reachability/RelayProbeWriteTest.kt | 59 +++++++++ .../reachability/RelayProber.kt | 36 ++++- .../nip11RelayInfo/CachedNip11FetcherTest.kt | 125 ++++++++++++++++++ .../reachability/RelayProberFlowTest.kt | 51 +++++++ .../nip11RelayInfo/OkHttpNip11Fetcher.kt | 60 +++++++++ 7 files changed, 465 insertions(+), 7 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11Fetcher.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11Fetcher.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProbeWriteTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11FetcherTest.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/OkHttpNip11Fetcher.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11Fetcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11Fetcher.kt new file mode 100644 index 0000000000..0805118b6b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11Fetcher.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip11RelayInfo + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException + +/** + * TTL cache around any [Nip11Fetcher]. NIP-11 documents change rarely, so a + * successful fetch is served from memory for [ttlSeconds]; a FAILED fetch is + * also remembered — for the shorter [errorTtlSeconds] — so a mass census does + * not hammer a host that just refused, while still retrying it soon. + * + * Concurrent first fetches of the same relay are not deduplicated: both hit the + * network and the second result wins the cache slot. That is harmless (the + * document is idempotent) and keeps this class lock-free. + */ +class CachedNip11Fetcher( + private val delegate: Nip11Fetcher, + private val ttlSeconds: Long = DEFAULT_TTL_SECONDS, + private val errorTtlSeconds: Long = DEFAULT_ERROR_TTL_SECONDS, + maxEntries: Int = 1000, + private val now: () -> Long = { TimeUtils.now() }, +) : Nip11Fetcher { + private sealed interface Cached { + val at: Long + } + + private class Hit( + val info: Nip11RelayInformation, + override val at: Long, + ) : Cached + + private class Miss( + val message: String?, + override val at: Long, + ) : Cached + + private val cache = LruCache(maxEntries) + + /** The cached document if present and fresh; null otherwise. Never touches the network. */ + fun cachedOrNull(relay: NormalizedRelayUrl): Nip11RelayInformation? { + val hit = cache[relay] as? Hit ?: return null + return if (now() - hit.at < ttlSeconds) hit.info else null + } + + /** Drops the cache entry (success or failure) so the next [fetch] is fresh. */ + fun invalidate(relay: NormalizedRelayUrl) { + cache.remove(relay) + } + + override suspend fun fetch(relay: NormalizedRelayUrl): Nip11RelayInformation { + when (val cached = cache[relay]) { + is Hit -> if (now() - cached.at < ttlSeconds) return cached.info + is Miss -> + if (now() - cached.at < errorTtlSeconds) { + throw Nip11FetchException(cached.message ?: "cached NIP-11 failure for ${relay.url}") + } + null -> {} + } + return try { + delegate.fetch(relay).also { cache.put(relay, Hit(it, now())) } + } catch (e: Exception) { + if (e is CancellationException) throw e + cache.put(relay, Miss(e.message, now())) + throw e + } + } + + companion object { + /** Documents are near-static: trust a success for a day. */ + const val DEFAULT_TTL_SECONDS = 24L * 60 * 60 + + /** Failures are often transient: retry after five minutes. */ + const val DEFAULT_ERROR_TTL_SECONDS = 5L * 60 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11Fetcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11Fetcher.kt new file mode 100644 index 0000000000..cc97860576 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11Fetcher.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip11RelayInfo + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Fetches and parses a relay's NIP-11 information document (the + * `application/nostr+json` answer on the relay's https url). Mirrors the + * [com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Fetcher] seam: the HTTP + * transport lives in a platform implementation (OkHttpNip11Fetcher on + * JVM/Android), so common code — probes, monitors, the CLI — depends only on + * this interface. Wrap any implementation in [CachedNip11Fetcher] to add a TTL + * cache. + * + * Throws [Nip11FetchException] (or a transport exception) when the document is + * unavailable or unparseable. + */ +interface Nip11Fetcher { + suspend fun fetch(relay: NormalizedRelayUrl): Nip11RelayInformation +} + +/** The relay answered, but not with a usable NIP-11 document (bad status, not JSON). */ +class Nip11FetchException( + message: String, +) : Exception(message) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProbeWriteTest.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProbeWriteTest.kt new file mode 100644 index 0000000000..12bc086047 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProbeWriteTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip66RelayMonitor.reachability + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The event a NIP-66 monitor publishes to measure a relay's WRITE path: publish + * one of these (signed with the monitor key), time the `OK`, and read the + * rejection prefix when refused (`auth-required:`/`restricted:`/`pow:` map to + * the discovery record's `R` requirement tags; a timed acceptance is `rtt-write`). + * + * The kind is EPHEMERAL (20000–29999 per NIP-01), so a compliant relay serves it + * to current subscribers and never stores it — the probe leaves nothing behind. + * [KIND] 20166 is this library's convention (30166 discovery minus the + * addressable range), not something NIP-66 standardizes; any ephemeral kind + * works. Belt-and-braces, the template also carries a NIP-40 `expiration` tag + * [EXPIRATION_SECONDS] out, so a relay that stores unknown ephemeral kinds + * anyway purges it promptly. + * + * A rejection is still a MEASUREMENT: an `OK false` proves the write path works + * and documents the relay's policy. Only silence is a failed write test. + */ +object RelayProbeWriteTest { + const val KIND = 20166 + + /** Storage-window ceiling for non-compliant relays that store ephemeral events. */ + const val EXPIRATION_SECONDS = 60L + + fun build( + content: String = "NIP-66 write probe", + createdAt: Long = TimeUtils.now(), + ): EventTemplate = + eventTemplate(KIND, content, createdAt) { + add(ExpirationTag.assemble(createdAt + EXPIRATION_SECONDS)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt index 364f4e1bd0..58663c2728 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt @@ -100,18 +100,24 @@ class RelayProber( /** * Probe every relay in [relays], [waveSize] at a time, giving each wave up to * [timeoutMs] to reach terminals. Returns one [Verdict] per input relay. + * + * [filters] is the REQ each relay is asked to answer. The default + * [LIVENESS_FILTERS] matches nothing, so an EOSE proves liveness without + * streaming a payload; pass [readTestFilter] to make [Verdict.rttEoseMs] a + * real read test instead (the relay must query and stream an actual event). */ suspend fun probe( relays: Collection, timeoutMs: Long = 15_000, waveSize: Int = 1000, + filters: List = LIVENESS_FILTERS, ): Result { val mark = TimeSource.Monotonic.markNow() val all = ArrayList(relays.size) val distinct = relays.toSet() var done = 0 for (wave in distinct.chunked(waveSize.coerceAtLeast(1))) { - probeWave(wave, timeoutMs) { all += it } + probeWave(wave, timeoutMs, filters) { all += it } done += wave.size if (distinct.size > wave.size) { val liveSoFar = all.count { it.reachable } @@ -127,6 +133,9 @@ class RelayProber( * as its EOSE/CLOSED/connect-failure lands, not when the whole census ends. Only * relays that stay silent wait for their wave's [timeoutMs] deadline. * + * [filters] picks the check, as in [probe]: [LIVENESS_FILTERS] (default) or + * [readTestFilter]. + * * Probing starts when the flow is collected and pauses between waves while the * collector is busy (emission is sequential). Pair each verdict with * [toDiscoveryEventTemplate] to turn the stream into signable NIP-66 kind:30166 @@ -136,16 +145,18 @@ class RelayProber( relays: Collection, timeoutMs: Long = 15_000, waveSize: Int = 1000, + filters: List = LIVENESS_FILTERS, ): Flow = flow { for (wave in relays.toSet().chunked(waveSize.coerceAtLeast(1))) { - probeWave(wave, timeoutMs) { emit(it) } + probeWave(wave, timeoutMs, filters) { emit(it) } } } private suspend fun probeWave( wave: List, timeoutMs: Long, + filters: List, onVerdict: suspend (Verdict) -> Unit, ) { val mark = TimeSource.Monotonic.markNow() @@ -230,7 +241,7 @@ class RelayProber( client.addConnectionListener(connListener) try { - client.subscribe(subId, wave.associateWith { PROBE_FILTERS }, subListener) + client.subscribe(subId, wave.associateWith { filters }, subListener) val remaining = wave.toMutableSet() while (remaining.isNotEmpty()) { val left = timeoutMs - mark.elapsedNow().inWholeMilliseconds @@ -251,10 +262,21 @@ class RelayProber( } companion object { - // A filter no event can match (ids are 64-hex of a hash): the relay answers - // with an immediate EOSE and never streams a payload. Same trick as the - // crawler's warm pool. - private val PROBE_FILTERS = listOf(Filter(ids = listOf("0".repeat(64)))) + /** + * A filter no event can match (ids are 64-hex of a hash): the relay answers + * with an immediate EOSE and never streams a payload. Same trick as the + * crawler's warm pool. This is the default check — pure liveness. + */ + val LIVENESS_FILTERS = listOf(Filter(ids = listOf("0".repeat(64)))) + + /** + * A REQ the relay must actually WORK for: query its store and stream up to + * [limit] real events before the EOSE. Pass to [probe]/[probeFlow] as + * [filters] to turn [Verdict.rttEoseMs] into a genuine read test rather + * than a liveness ping — the time still counts from the wave start (dial + * included), so compare it against [Verdict.rttOpenMs], not across waves. + */ + fun readTestFilter(limit: Int = 1) = listOf(Filter(limit = limit)) /** * The relay universe the local store knows: every read/write relay advertised diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11FetcherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11FetcherTest.kt new file mode 100644 index 0000000000..95bd844de0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11FetcherTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip11RelayInfo + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class CachedNip11FetcherTest { + private val relay = RelayUrlNormalizer.normalize("wss://nostr.example.com") + + /** Counts network hits; serves [doc] or throws when [failing]. */ + private class FakeFetcher : Nip11Fetcher { + var calls = 0 + var failing = false + var doc = Nip11RelayInformation(name = "v1") + + override suspend fun fetch(relay: NormalizedRelayUrl): Nip11RelayInformation { + calls++ + if (failing) throw Nip11FetchException("boom") + return doc + } + } + + private fun cached( + delegate: FakeFetcher, + clock: () -> Long, + ) = CachedNip11Fetcher(delegate, ttlSeconds = 100, errorTtlSeconds = 10, now = clock) + + @Test + fun freshSuccessIsServedFromCache() = + runBlocking { + val net = FakeFetcher() + var now = 0L + val fetcher = cached(net) { now } + + assertEquals("v1", fetcher.fetch(relay).name) + now = 99 + assertEquals("v1", fetcher.fetch(relay).name) + assertEquals(1, net.calls, "second fetch inside the TTL must not touch the network") + } + + @Test + fun successExpiresAfterTtl() = + runBlocking { + val net = FakeFetcher() + var now = 0L + val fetcher = cached(net) { now } + + fetcher.fetch(relay) + net.doc = Nip11RelayInformation(name = "v2") + now = 100 + assertEquals("v2", fetcher.fetch(relay).name) + assertEquals(2, net.calls) + } + + @Test + fun failureIsCachedForItsOwnShorterTtl() = + runBlocking { + val net = FakeFetcher().apply { failing = true } + var now = 0L + val fetcher = cached(net) { now } + + assertFailsWith { fetcher.fetch(relay) } + now = 9 + assertFailsWith { fetcher.fetch(relay) } + assertEquals(1, net.calls, "a fresh failure must be served from cache, not re-fetched") + + now = 10 + net.failing = false + assertEquals("v1", fetcher.fetch(relay).name) + assertEquals(2, net.calls, "an expired failure must be retried") + } + + @Test + fun invalidateForcesAFreshFetch() = + runBlocking { + val net = FakeFetcher() + val fetcher = cached(net) { 0 } + + fetcher.fetch(relay) + fetcher.invalidate(relay) + fetcher.fetch(relay) + assertEquals(2, net.calls) + } + + @Test + fun cachedOrNullNeverTouchesTheNetwork() = + runBlocking { + val net = FakeFetcher() + var now = 0L + val fetcher = cached(net) { now } + + assertNull(fetcher.cachedOrNull(relay)) + assertEquals(0, net.calls) + + fetcher.fetch(relay) + assertEquals("v1", fetcher.cachedOrNull(relay)?.name) + now = 100 + assertNull(fetcher.cachedOrNull(relay), "a stale hit must not be served") + assertEquals(1, net.calls) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt index 34b1ce15fc..2efc801324 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt @@ -49,6 +49,7 @@ class RelayProberFlowTest { /** Captures the probe subscription so the test can play the relays. */ private class ScriptedClient : INostrClient by EmptyNostrClient() { var listener: SubscriptionListener? = null + var sentFilters: Map>? = null override fun subscribe( subId: String, @@ -56,6 +57,7 @@ class RelayProberFlowTest { listener: SubscriptionListener?, ) { this.listener = listener + this.sentFilters = filters } } @@ -139,6 +141,55 @@ class RelayProberFlowTest { assertTrue(at < 1_000, "a failed dial must not wait for the deadline, arrived at ${at}ms") } + // ------------------------------------------------------------------ + // Check options — liveness default, read-test override, write-test event + // ------------------------------------------------------------------ + + @Test + fun livenessFilterIsTheDefaultCheck() = + runTest { + val client = ScriptedClient() + val collector = + launch { + RelayProber(client).probeFlow(listOf(fast), timeoutMs = 1_000).collect {} + } + launch { + delay(10) + assertEquals(RelayProber.LIVENESS_FILTERS, client.sentFilters!![fast]) + client.listener!!.onEose(fast, null) + } + collector.join() + } + + @Test + fun readTestFilterIsSentWhenChosen() = + runTest { + val client = ScriptedClient() + val collector = + launch { + RelayProber(client) + .probeFlow(listOf(fast), timeoutMs = 1_000, filters = RelayProber.readTestFilter()) + .collect {} + } + launch { + delay(10) + val sent = client.sentFilters!![fast]!!.single() + assertEquals(1, sent.limit, "read test defaults to limit 1") + assertNull(sent.ids, "read test must query real events, not the impossible id") + client.listener!!.onEose(fast, null) + } + collector.join() + } + + @Test + fun writeTestEventIsEphemeralAndSelfExpiring() { + val template = RelayProbeWriteTest.build(createdAt = 5000) + + assertEquals(20166, template.kind) + assertTrue(template.kind in 20000..29999, "the write probe must be an ephemeral kind") + assertTrue(listOf("expiration", "5060") in template.tags.map { it.toList() }) + } + // ------------------------------------------------------------------ // toDiscoveryEventTemplate — only observed facts become tags // ------------------------------------------------------------------ diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/OkHttpNip11Fetcher.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/OkHttpNip11Fetcher.kt new file mode 100644 index 0000000000..a28033c0d1 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/OkHttpNip11Fetcher.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip11RelayInfo + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync + +/** + * OkHttp-backed [Nip11Fetcher]: GETs the relay's https url with + * `Accept: application/nostr+json` and parses the document. The client is + * resolved per relay so callers can route Tor/proxy relays through a different + * OkHttp instance (the same seam Amethyst's Nip11Retriever uses). + */ +class OkHttpNip11Fetcher( + private val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient, +) : Nip11Fetcher { + override suspend fun fetch(relay: NormalizedRelayUrl): Nip11RelayInformation = + withContext(Dispatchers.IO) { + val request = + Request + .Builder() + .header("Accept", "application/nostr+json") + .url(relay.toHttp()) + .build() + + okHttpClient(relay).newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) { + throw Nip11FetchException("HTTP ${response.code} fetching NIP-11 from ${relay.url}") + } + val body = response.body.string() + if (!body.startsWith("{")) { + throw Nip11FetchException("Not a NIP-11 document from ${relay.url}") + } + Nip11RelayInformation.fromJson(body) + } + } +} From fd5bd994a1df5e43408423c4979a24a12cc73d8f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 14:41:15 +0000 Subject: [PATCH 108/227] =?UTF-8?q?feat(quartz):=20read+write=20relay=20ch?= =?UTF-8?q?ecks=20=E2=80=94=20observed=20facts=20only,=20no=20NIP-11=20cla?= =?UTF-8?q?ims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RelayProber.readWriteCheck(relays, signer) is the deeper check pair for relays already proven live (warm sockets from a probe that just ran): - READ: a real limit-1 REQ the relay must query its store for, timed REQ→first answer (honest rtt-read on an open socket). - WRITE: one ephemeral RelayProbeWriteTest event signed by the monitor key, timed publish→OK (honest rtt-write). An OK false is a measured policy answer, kept with its NIP-01 machine-readable reason; only silence leaves the write side unobserved (writeAccepted = null). publishAndCollectResults now stamps each OK with its elapsedMs (a rejection is still a round trip; -1 when the relay never answered), so any caller gets write latency for free. toDiscoveryEventTemplate(readWrite = ...) folds the pair into the 30166 template: rtt-read/rtt-write when measured, R auth / R pow when the write was refused with auth-required:/pow:. NIP-11-derived tags (N supported NIPs, k kinds, T type) are deliberately NOT emitted — those are relay self-claims, and publishing them under a monitor signature without per-NIP compliance tests would launder claims into measurements. Per-NIP/per-kind compliance suites can come later as opt-in checks; open/read/write is the default surface. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK --- .../accessories/NostrClientPublishExt.kt | 13 +- .../reachability/RelayProber.kt | 100 +++++++++-- .../reachability/RelayProberFlowTest.kt | 164 +++++++++++++++++- 3 files changed, 264 insertions(+), 13 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt index 9cbec380b4..112345d123 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.TimeSource /** * One relay's verdict on a published event: [accepted] plus the reason the @@ -45,6 +46,12 @@ import kotlinx.coroutines.withTimeoutOrNull class PublishResult( val accepted: Boolean, val message: String, + /** + * Milliseconds from the publish to this relay's OK (true or false — a rejection + * is still a measured round trip), or -1 when the relay never answered with an + * OK. On an already-open socket this is an honest NIP-66 `rtt-write`. + */ + val elapsedMs: Long = -1, ) { /** * True when this failure came from the transport (never connected, @@ -102,6 +109,7 @@ suspend fun INostrClient.publishAndCollectResults( timeoutInSeconds: Long = 15, ): Map { val resultChannel = Channel(UNLIMITED) + val mark = TimeSource.Monotonic.markNow() Log.d("publishAndConfirm") { "Waiting for ${relayList.size} responses" } @@ -134,7 +142,7 @@ suspend fun INostrClient.publishAndCollectResults( when (msg) { is OkMessage -> { if (msg.eventId == event.id) { - resultChannel.trySend(DetailedResult(relay.url, msg.success, msg.message)) + resultChannel.trySend(DetailedResult(relay.url, msg.success, msg.message, mark.elapsedNow().inWholeMilliseconds)) Log.d("publishAndConfirm") { "onSendResponse Received response for ${msg.eventId} from relay ${relay.url} message ${msg.message} success ${msg.success}" } } } @@ -160,7 +168,7 @@ suspend fun INostrClient.publishAndCollectResults( val currentResult = receivedResults[result.relay] // do not override a successful result. if (currentResult == null || !currentResult.accepted) { - receivedResults[result.relay] = PublishResult(result.success, result.message) + receivedResults[result.relay] = PublishResult(result.success, result.message, result.elapsedMs) } } } @@ -191,4 +199,5 @@ private class DetailedResult( val relay: NormalizedRelayUrl, val success: Boolean, val message: String, + val elapsedMs: Long = -1, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt index 58663c2728..86e57c7e58 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.nip66RelayMonitor.reachability import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient @@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent @@ -83,6 +86,19 @@ class RelayProber( val error: String?, ) + /** + * One relay's read+write check outcome (see [readWriteCheck]). Latencies are + * -1 when unobserved; [writeAccepted] is null when the relay never answered + * the write with an OK (transport failure or silence). + */ + class ReadWriteVerdict( + val relay: NormalizedRelayUrl, + val rttReadMs: Long, + val rttWriteMs: Long, + val writeAccepted: Boolean?, + val writeMessage: String?, + ) + class Result( val verdicts: List, val elapsedMs: Long, @@ -153,6 +169,55 @@ class RelayProber( } } + /** + * The deeper, still-honest check pair: READ (a real limit-[readLimit] REQ the + * relay must query its store for) and WRITE (one ephemeral [RelayProbeWriteTest] + * event signed by [signer], the monitor key, timed to its OK). Everything is a + * direct observation — nothing is copied from the relay's NIP-11 self-claims. + * + * Run it against relays ALREADY PROVEN LIVE — typically [Result.reachable] of a + * [probe] that just ran, while the pool's sockets are still open. On a warm + * socket both numbers are honest NIP-66 rtts (`rtt-read`, `rtt-write`); against + * a cold relay they silently include the dial, so don't. + * + * A write REJECTION is still a measurement: `OK false` proves the write path + * works and documents policy ([ReadWriteVerdict.writeMessage] keeps the NIP-01 + * machine-readable reason; [toDiscoveryEventTemplate] maps `auth-required:` and + * `pow:` to `R` tags). Only silence leaves [ReadWriteVerdict.writeAccepted] null. + */ + suspend fun readWriteCheck( + relays: Collection, + signer: NostrSigner, + timeoutMs: Long = 15_000, + waveSize: Int = 1000, + readLimit: Int = 1, + ): Map { + val out = HashMap() + val distinct = relays.toSet() + for (wave in distinct.chunked(waveSize.coerceAtLeast(1))) { + val reads = HashMap() + probeWave(wave, timeoutMs, readTestFilter(readLimit)) { reads[it.relay] = it.rttEoseMs } + + val event = signer.sign(RelayProbeWriteTest.build()) + val writes = client.publishAndCollectResults(event, wave.toSet(), (timeoutMs / 1000).coerceAtLeast(1)) + + for (relay in wave) { + // Only a real OK (true or false) counts as an answer; transport + // failures and silence leave the write side unobserved. + val answered = writes[relay]?.takeUnless { it.isTransportFailure || it.message == PublishResult.NO_RESPONSE } + out[relay] = + ReadWriteVerdict( + relay = relay, + rttReadMs = reads[relay] ?: -1, + rttWriteMs = answered?.elapsedMs ?: -1, + writeAccepted = answered?.accepted, + writeMessage = answered?.message, + ) + } + } + return out + } + private suspend fun probeWave( wave: List, timeoutMs: Long, @@ -329,22 +394,37 @@ class RelayProber( * is the normalized relay url; sign it with the consumer's own monitor key (per * NIP-66 a monitor is its own identity, so the prober never signs on its own). * - * Only facts this probe actually observed are tagged: + * Only facts a probe actually observed are tagged: * - `n` network type inferred from the url (clearnet/tor/i2p); * - `rtt-open` when the relay was reachable — the measured WS-upgrade round trip, * or 0 for "reachable, latency not observed" (liveness is the tag's PRESENCE); - * - `R auth` when the relay answered the probe REQ with a NIP-42 `auth-required` - * CLOSED — an observed auth wall, not a NIP-11 claim. + * - `rtt-read`/`rtt-write` when a [RelayProber.readWriteCheck] result is passed + * as [readWrite] and actually measured that side; + * - `R auth` when the relay answered a probe with a NIP-42 `auth-required` + * (CLOSED on the REQ, or OK-false on the write) and `R pow` when the write + * was refused with a `pow:` reason — observed walls, not NIP-11 claims. * - * [Verdict.rttEoseMs] is deliberately NOT written as `rtt-read`: it is measured from - * the wave start, so it bundles dial, TLS and any handshake queueing with the read — - * publishing it as a read round trip would hand aggregators an inflated latency. - * The [RelayObserver]/[RelayMonitor] path supplies honest `rtt-read`/`rtt-write` - * from real traffic instead. + * NIP-11-derived tags (`N` supported NIPs, `k` kinds, `T` type) are deliberately + * absent: those are the relay's self-claims, and asserting them under a monitor + * signature without a per-NIP compliance test would launder claims into + * measurements. [Verdict.rttEoseMs] is likewise never written as `rtt-read` — it + * is wave-relative (dial + TLS + queueing), so the honest read number only comes + * from [readWrite] (or the [RelayObserver]/[RelayMonitor] real-traffic path). */ -fun RelayProber.Verdict.toDiscoveryEventTemplate(createdAt: Long = TimeUtils.now()): EventTemplate = +fun RelayProber.Verdict.toDiscoveryEventTemplate( + createdAt: Long = TimeUtils.now(), + readWrite: RelayProber.ReadWriteVerdict? = null, +): EventTemplate = RelayDiscoveryEvent.build(relay, createdAt = createdAt) { networkType(RelayReachabilityStore.networkTypeOf(relay)) if (reachable) rtt(RttType.OPEN, rttOpenMs.coerceAtLeast(0)) - if (error?.startsWith("closed:auth-required") == true) requirement("auth") + if (readWrite != null) { + if (readWrite.rttReadMs >= 0) rtt(RttType.READ, readWrite.rttReadMs) + if (readWrite.rttWriteMs >= 0) rtt(RttType.WRITE, readWrite.rttWriteMs) + } + val authWalled = + error?.startsWith("closed:auth-required") == true || + readWrite?.writeMessage?.startsWith("auth-required") == true + if (authWalled) requirement("auth") + if (readWrite?.writeMessage?.startsWith("pow:") == true) requirement("pow") } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt index 2efc801324..0195e52b53 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt @@ -20,13 +20,20 @@ */ package com.vitorpamplona.quartz.nip66RelayMonitor.reachability +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -46,10 +53,12 @@ import kotlin.test.assertTrue */ @OptIn(ExperimentalCoroutinesApi::class) class RelayProberFlowTest { - /** Captures the probe subscription so the test can play the relays. */ + /** Captures the probe subscription and publish so the test can play the relays. */ private class ScriptedClient : INostrClient by EmptyNostrClient() { var listener: SubscriptionListener? = null var sentFilters: Map>? = null + var published: Event? = null + val connListeners = mutableListOf() override fun subscribe( subId: String, @@ -59,6 +68,49 @@ class RelayProberFlowTest { this.listener = listener this.sentFilters = filters } + + override fun publish( + event: Event, + relayList: Set, + ) { + published = event + } + + override fun addConnectionListener(listener: RelayConnectionListener) { + connListeners += listener + } + + override fun removeConnectionListener(listener: RelayConnectionListener) { + connListeners -= listener + } + + /** Plays a relay's OK answer for the published event to every armed listener. */ + fun answerOk( + relay: NormalizedRelayUrl, + success: Boolean, + message: String, + ) { + val ok = OkMessage(published!!.id, success, message) + connListeners.toList().forEach { it.onIncomingMessage(FakeRelayClient(relay), "", ok) } + } + } + + private class FakeRelayClient( + override val url: NormalizedRelayUrl, + ) : IRelayClient { + override fun connect() = Unit + + override fun needsToReconnect() = false + + override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit + + override fun isConnected() = true + + override fun sendOrConnectAndSync(cmd: Command) = Unit + + override fun sendIfConnected(cmd: Command) = Unit + + override fun disconnect() = Unit } private val fast = RelayUrlNormalizer.normalize("wss://fast.example.com") @@ -190,6 +242,82 @@ class RelayProberFlowTest { assertTrue(listOf("expiration", "5060") in template.tags.map { it.toList() }) } + // ------------------------------------------------------------------ + // readWriteCheck — honest read + write measurements, nothing claimed + // ------------------------------------------------------------------ + + private suspend fun ScriptedClient.playReadThenWrite( + relay: NormalizedRelayUrl, + ok: Boolean?, + okMessage: String = "", + ) { + delay(50) + listener!!.onEose(relay, null) // read phase answers + while (published == null) delay(10) // write phase begins + if (ok != null) answerOk(relay, ok, okMessage) + } + + @Test + fun readWriteCheckMeasuresBothSides() = + runTest { + val client = ScriptedClient() + val signer = NostrSignerInternal(KeyPair()) + var result: Map? = null + + val check = + launch { + result = RelayProber(client).readWriteCheck(listOf(fast), signer, timeoutMs = 5_000) + } + launch { client.playReadThenWrite(fast, ok = true) } + check.join() + + val verdict = result!![fast]!! + assertTrue(verdict.rttReadMs >= 0, "an answered read must be measured") + assertTrue(verdict.rttWriteMs >= 0, "an answered write must be measured") + assertEquals(true, verdict.writeAccepted) + assertEquals(20166, client.published!!.kind, "the write test must use the ephemeral probe event") + } + + @Test + fun writeRejectionIsAnAnswerNotAFailure() = + runTest { + val client = ScriptedClient() + val signer = NostrSignerInternal(KeyPair()) + var result: Map? = null + + val check = + launch { + result = RelayProber(client).readWriteCheck(listOf(walled), signer, timeoutMs = 5_000) + } + launch { client.playReadThenWrite(walled, ok = false, okMessage = "pow: 28 bits needed") } + check.join() + + val verdict = result!![walled]!! + assertEquals(false, verdict.writeAccepted, "OK false is a measured policy answer") + assertEquals("pow: 28 bits needed", verdict.writeMessage) + assertTrue(verdict.rttWriteMs >= 0, "a rejection is still a round trip") + } + + @Test + fun silentWriteLeavesTheWriteSideUnobserved() = + runTest { + val client = ScriptedClient() + val signer = NostrSignerInternal(KeyPair()) + var result: Map? = null + + val check = + launch { + result = RelayProber(client).readWriteCheck(listOf(fast), signer, timeoutMs = 2_000) + } + launch { client.playReadThenWrite(fast, ok = null) } + check.join() + + val verdict = result!![fast]!! + assertTrue(verdict.rttReadMs >= 0) + assertNull(verdict.writeAccepted, "silence is not evidence about the write path") + assertEquals(-1, verdict.rttWriteMs) + } + // ------------------------------------------------------------------ // toDiscoveryEventTemplate — only observed facts become tags // ------------------------------------------------------------------ @@ -257,6 +385,40 @@ class RelayProberFlowTest { assertNull(tagsOf(template).firstOrNull { it[0] == "R" }) } + @Test + fun readWriteResultsBecomeRttTags() { + val verdict = RelayProber.Verdict(fast, reachable = true, rttOpenMs = 100, rttEoseMs = 300, error = null) + val readWrite = RelayProber.ReadWriteVerdict(fast, rttReadMs = 40, rttWriteMs = 55, writeAccepted = true, writeMessage = "") + + val tags = tagsOf(verdict.toDiscoveryEventTemplate(readWrite = readWrite)) + assertTrue(listOf("rtt-read", "40") in tags) + assertTrue(listOf("rtt-write", "55") in tags) + } + + @Test + fun unobservedReadWriteSidesStayUntagged() { + val verdict = RelayProber.Verdict(fast, reachable = true, rttOpenMs = 100, rttEoseMs = -1, error = null) + val readWrite = RelayProber.ReadWriteVerdict(fast, rttReadMs = -1, rttWriteMs = -1, writeAccepted = null, writeMessage = null) + + val tags = tagsOf(verdict.toDiscoveryEventTemplate(readWrite = readWrite)) + assertNull(tags.firstOrNull { it[0] == "rtt-read" }) + assertNull(tags.firstOrNull { it[0] == "rtt-write" }) + } + + @Test + fun writeRejectionReasonsBecomeRequirementTags() { + val verdict = RelayProber.Verdict(walled, reachable = true, rttOpenMs = 100, rttEoseMs = -1, error = null) + + val pow = RelayProber.ReadWriteVerdict(walled, -1, 30, writeAccepted = false, writeMessage = "pow: 28 bits needed") + assertTrue(listOf("R", "pow") in tagsOf(verdict.toDiscoveryEventTemplate(readWrite = pow))) + + val auth = RelayProber.ReadWriteVerdict(walled, -1, 30, writeAccepted = false, writeMessage = "auth-required: sign in") + assertTrue(listOf("R", "auth") in tagsOf(verdict.toDiscoveryEventTemplate(readWrite = auth))) + + val blocked = RelayProber.ReadWriteVerdict(walled, -1, 30, writeAccepted = false, writeMessage = "blocked: not welcome") + assertNull(tagsOf(verdict.toDiscoveryEventTemplate(readWrite = blocked)).firstOrNull { it[0] == "R" }) + } + @Test fun onionRelayIsTaggedTor() { val onion = RelayUrlNormalizer.normalize("ws://someonionaddressabcdefghijklmnop.onion") From 0d0117061a8c897b8bcfd50b8f837e757982d73d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 4 Aug 2026 11:05:45 -0400 Subject: [PATCH 109/227] fix(relay): compare every filter in FiltersChanged, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `needsToResendRequest(List, List)` used a non-local `return` inside `forEachIndexed`, so the loop always returned on iteration 0 and only `filters[0]` was ever compared. A subscription whose first filter happened to be unchanged reported "no resend needed" however much the rest had changed, leaving the relay serving a stale filter set and the app silently missing events. Only the size check offered any protection, so the bug was invisible whenever the filter count stayed constant. Replaces the loop with an indexed scan over all filters, which also drops the lambda allocation and matches the hot-path style in this package. Adds FiltersChangedTest. 3 of its 9 cases fail on the unfixed code — all of them changes beyond index 0 — while the other 6 pass both before and after, pinning the blast radius to exactly the buggy behaviour. Coverage includes the deliberate `since`-moves-forward exemption, which must not trigger a resend on any index. Note for reviewers: PoolRequests.kt:490 and :528 use this inverted as a "same as last" refusal check, so those become stricter — filter sets that differ only beyond index 0 were previously treated as identical and will now correctly be treated as changed. Co-Authored-By: Claude Opus 5 (1M context) --- .../relay/client/pool/FiltersChanged.kt | 12 ++- .../relay/client/pool/FiltersChangedTest.kt | 93 +++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChangedTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChanged.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChanged.kt index bd038c11f3..2d1638f48a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChanged.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChanged.kt @@ -29,10 +29,14 @@ object FiltersChanged { ): Boolean { if (oldFilters.size != newFilters.size) return true - oldFilters.forEachIndexed { index, oldFilter -> - val newFilter = newFilters.getOrNull(index) ?: return true - - return needsToResendRequest(oldFilter, newFilter) + // Every filter must be compared. This used to be a forEachIndexed whose body + // `return`ed on the first iteration — a non-local return from this function — so + // only filters[0] was ever checked and a subscription whose first filter happened + // to be unchanged reported "no resend needed" however much the rest had changed, + // leaving the relay serving a stale filter set. + // Indexing is safe: the sizes were just proven equal. + for (i in oldFilters.indices) { + if (needsToResendRequest(oldFilters[i], newFilters[i])) return true } return false } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChangedTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChangedTest.kt new file mode 100644 index 0000000000..4d6af28c2b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChangedTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FiltersChangedTest { + private fun authors(vararg a: String) = Filter(authors = a.toList()) + + @Test + fun emptyListsAreUnchanged() { + assertFalse(FiltersChanged.needsToResendRequest(emptyList(), emptyList())) + } + + @Test + fun differentSizesNeedResend() { + assertTrue(FiltersChanged.needsToResendRequest(listOf(authors("a")), listOf(authors("a"), authors("b")))) + } + + @Test + fun identicalSingleFilterDoesNotNeedResend() { + assertFalse(FiltersChanged.needsToResendRequest(listOf(authors("a")), listOf(authors("a")))) + } + + @Test + fun changedFirstFilterNeedsResend() { + assertTrue(FiltersChanged.needsToResendRequest(listOf(authors("a")), listOf(authors("b")))) + } + + /** + * Regression: the loop used a non-local `return` on the first iteration, so only + * filters[0] was ever compared. A subscription whose first filter was unchanged + * reported "no resend needed" no matter what happened to the rest, and silently + * went stale — the relay kept serving the old filter set. + */ + @Test + fun changedSecondFilterNeedsResend() { + val old = listOf(authors("a"), authors("b")) + val new = listOf(authors("a"), authors("CHANGED")) + assertTrue(FiltersChanged.needsToResendRequest(old, new)) + } + + @Test + fun changedLastOfManyNeedsResend() { + val old = listOf(authors("a"), authors("b"), authors("c"), authors("d")) + val new = listOf(authors("a"), authors("b"), authors("c"), authors("CHANGED")) + assertTrue(FiltersChanged.needsToResendRequest(old, new)) + } + + @Test + fun allIdenticalOfManyDoesNotNeedResend() { + val old = listOf(authors("a"), authors("b"), authors("c")) + val new = listOf(authors("a"), authors("b"), authors("c")) + assertFalse(FiltersChanged.needsToResendRequest(old, new)) + } + + /** `since` moving forward is deliberately NOT a resend trigger, on any index. */ + @Test + fun sinceMovingForwardOnLaterFilterDoesNotNeedResend() { + val old = listOf(Filter(authors = listOf("a"), since = 100), Filter(authors = listOf("b"), since = 100)) + val new = listOf(Filter(authors = listOf("a"), since = 100), Filter(authors = listOf("b"), since = 200)) + assertFalse(FiltersChanged.needsToResendRequest(old, new)) + } + + /** ...but moving backwards in time is, including on a later filter. */ + @Test + fun sinceMovingBackwardsOnLaterFilterNeedsResend() { + val old = listOf(Filter(authors = listOf("a"), since = 100), Filter(authors = listOf("b"), since = 200)) + val new = listOf(Filter(authors = listOf("a"), since = 100), Filter(authors = listOf("b"), since = 100)) + assertTrue(FiltersChanged.needsToResendRequest(old, new)) + } +} From c767298368a5b8495ca80a201218cbb9462eb39c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:14:17 +0000 Subject: [PATCH 110/227] =?UTF-8?q?fix(quartz):=20audit=20fixes=20?= =?UTF-8?q?=E2=80=94=20foreign-OK=20confirmation=20bug,=20normalizer=20hot?= =?UTF-8?q?-path=20allocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit findings across the branch, each verified with a failing test or measurement before the fix: - publishAndCollectResults counted an OK from a relay OUTSIDE relayList (same event id — a probe-wave straggler, or any republish of the same event to a different relay set) toward its confirmation window, ending the wait loop early and misreporting still-pending listed relays as NO_RESPONSE. The OK branch now carries the same relayList guard the onCannotConnect/onDisconnected branches always had. Regression test proves the failure without the guard. readWriteCheck additionally varies the probe event content per wave so wave N's confirmation window can never match wave N-1's event id at all. - RelayUrlNormalizer.fix() called trimEnd('%','2','0') unconditionally, allocating a full string copy for ANY url merely ending in '%', '2' or '0' — which includes every relay port ending in zero (wss://host:3030). Now gated on endsWith("%20"), keeping the hot path allocation-free; semantics unchanged (test pins both the trim and the untouched-port cases). - amy relay probe --file: unreadable file is now a clean bad_args error instead of a stack trace, and skipped onion urls are counted and reported (file_onion_skipped) instead of vanishing from the tally. - probeFlow KDoc now states that a slow collector eats into the current wave's absolute deadline (answers are still recorded; silent relays get less listening time), not just that it delays the next wave. Verified non-issues: androidx.collection LruCache is internally locked (safe for CachedNip11Fetcher/normalizer concurrency); probeWave's per-terminal emission cannot lose or double-emit verdicts (remaining-set guard, data maps read at emission time); existing publish callers all benefit from the OK guard rather than depending on the old behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK --- .../amethyst/cli/commands/RelayCommands.kt | 12 +++++-- .../accessories/NostrClientPublishExt.kt | 7 +++- .../relay/normalizer/RelayUrlNormalizer.kt | 11 +++---- .../reachability/RelayProber.kt | 14 +++++--- .../nip01Core/relay/RelayUrlFormatterTest.kt | 8 +++++ .../reachability/RelayProberFlowTest.kt | 32 +++++++++++++++++++ 6 files changed, 70 insertions(+), 14 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index 8734ba1b02..292a19b6b0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -349,21 +349,26 @@ object RelayCommands { var fileRaw = 0 var fileRejected = 0 + var fileOnion = 0 val fileRelays = HashSet() if (fromFile != null) { - File(fromFile).forEachLine { line -> + val candidates = File(fromFile) + if (!candidates.canRead()) return Output.error("bad_args", "cannot read --file $fromFile") + candidates.forEachLine { line -> if (line.isBlank()) return@forEachLine fileRaw++ val normalized = line.normalizeRelayUrlOrNull() if (normalized == null) { fileRejected++ - } else if (!RelayUrlNormalizer.isOnion(normalized.url)) { + } else if (RelayUrlNormalizer.isOnion(normalized.url)) { + fileOnion++ + } else { fileRelays.add(normalized) } } System.err.println( "[relay-probe] $fromFile: $fileRaw urls → ${fileRelays.size} unique clearnet relays " + - "($fileRejected rejected by the normalizer)", + "($fileRejected rejected by the normalizer, $fileOnion onion skipped)", ) } @@ -412,6 +417,7 @@ object RelayCommands { "file_urls" to (if (fromFile != null) fileRaw else null), "file_normalized" to (if (fromFile != null) fileRelays.size else null), "file_rejected" to (if (fromFile != null) fileRejected else null), + "file_onion_skipped" to (if (fromFile != null) fileOnion else null), "reachable" to result.reachable.size, "dead" to result.dead.size, "closed_by_policy" to authWalled, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt index 112345d123..c1aa8c14a2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt @@ -141,7 +141,12 @@ suspend fun INostrClient.publishAndCollectResults( when (msg) { is OkMessage -> { - if (msg.eventId == event.id) { + // The relayList guard matters, not just the id: the same event may + // have been published to OTHER relays by an earlier call (probe + // waves, republish), and counting their late OKs here would inflate + // receivedResults and end the wait loop before every listed relay + // answered — misreporting the missing ones as NO_RESPONSE. + if (msg.eventId == event.id && relay.url in relayList) { resultChannel.trySend(DetailedResult(relay.url, msg.success, msg.message, mark.elapsedNow().inWholeMilliseconds)) Log.d("publishAndConfirm") { "onSendResponse Received response for ${msg.eventId} from relay ${relay.url} message ${msg.message} success ${msg.success}" } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt index 8949a0b82c..bc5c524482 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -180,12 +180,11 @@ class RelayUrlNormalizer { if (rawUrl.length < 4) return null if (rawUrl.contains("%00")) return null - // Trim trailing %20 (percent-encoded spaces from malformed event data) - val url = - rawUrl.trimEnd('%', '2', '0').let { trimmed -> - // Only accept if we actually removed a trailing %20 pattern - if (trimmed.length < rawUrl.length && rawUrl.endsWith("%20")) trimmed else rawUrl - } + // Trim trailing %20 (percent-encoded spaces from malformed event data). + // The endsWith gate keeps the hot path allocation-free: trimEnd would + // copy the string for ANY url merely ending in '%', '2' or '0' — which + // includes every port ending in zero ("wss://host:3030"). + val url = if (rawUrl.endsWith("%20")) rawUrl.trimEnd('%', '2', '0') else rawUrl if (url.length < 4) return null // Reject URLs with %20 in the middle — these are garbage diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt index 86e57c7e58..5dcf878aad 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt @@ -152,8 +152,11 @@ class RelayProber( * [filters] picks the check, as in [probe]: [LIVENESS_FILTERS] (default) or * [readTestFilter]. * - * Probing starts when the flow is collected and pauses between waves while the - * collector is busy (emission is sequential). Pair each verdict with + * Probing starts when the flow is collected, and emission is sequential — a slow + * collector delays the next wave AND eats into the current wave's [timeoutMs] + * window (the deadline is absolute; answers keep being recorded while the + * collector runs, but silent relays get less listening time). Keep per-verdict + * work light, or buffer, when precise deadlines matter. Pair each verdict with * [toDiscoveryEventTemplate] to turn the stream into signable NIP-66 kind:30166 * records for another process to sign and publish. */ @@ -194,11 +197,14 @@ class RelayProber( ): Map { val out = HashMap() val distinct = relays.toSet() - for (wave in distinct.chunked(waveSize.coerceAtLeast(1))) { + for ((waveIndex, wave) in distinct.chunked(waveSize.coerceAtLeast(1)).withIndex()) { val reads = HashMap() probeWave(wave, timeoutMs, readTestFilter(readLimit)) { reads[it.relay] = it.rttEoseMs } - val event = signer.sign(RelayProbeWriteTest.build()) + // A distinct event id per wave (createdAt has second granularity, so the + // content must vary) keeps a straggler OK from an earlier wave's relays + // from ever matching this wave's confirmation window. + val event = signer.sign(RelayProbeWriteTest.build(content = "NIP-66 write probe $waveIndex")) val writes = client.publishAndCollectResults(event, wave.toSet(), (timeoutMs / 1000).coerceAtLeast(1)) for (relay in wave) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt index fc708eddba..6357c09591 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlFormatterTest.kt @@ -53,6 +53,14 @@ class RelayUrlFormatterTest { assertNull(RelayUrlNormalizer.normalizeOrNull("wss://relay%20list%20to%20discover%20the%20user's%20content")) } + @Test + fun trailingPercentTwentyIsTrimmedButBareTrailingZeroIsNot() { + assertEquals("wss://nostr.mom/", RelayUrlNormalizer.normalizeOrNull("wss://nostr.mom%20")?.url) + // urls merely ending in '%', '2' or '0' (every port ending in zero) must pass untouched + assertEquals("wss://nostr.mom:3030/", RelayUrlNormalizer.normalizeOrNull("wss://nostr.mom:3030")?.url) + assertEquals("wss://nostr.mom:8020/", RelayUrlNormalizer.normalizeOrNull("wss://nostr.mom:8020")?.url) + } + @Test fun httpWithPathIsNotARelay() { // Mastodon/bridge actor urls from `proxy` tags: web resources, not relays diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt index 0195e52b53..153c6e5d3e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt @@ -298,6 +298,38 @@ class RelayProberFlowTest { assertTrue(verdict.rttWriteMs >= 0, "a rejection is still a round trip") } + @Test + fun foreignRelayOkDoesNotEndTheWriteConfirmationEarly() = + runTest { + // A relay OUTSIDE the checked set answering with the same event id (a + // straggler from an earlier wave that got the same probe event) must not + // count toward the confirmation window — before the relayList guard in + // publishAndCollectResults, it ended the wait early and misreported the + // real relay as silent. + val client = ScriptedClient() + val signer = NostrSignerInternal(KeyPair()) + val foreign = RelayUrlNormalizer.normalize("wss://foreign.example.com") + var result: Map? = null + + val check = + launch { + result = RelayProber(client).readWriteCheck(listOf(fast), signer, timeoutMs = 5_000) + } + launch { + delay(50) + client.listener!!.onEose(fast, null) + while (client.published == null) delay(10) + client.answerOk(foreign, true, "") + delay(100) + client.answerOk(fast, true, "") + } + check.join() + + val verdict = result!![fast]!! + assertEquals(true, verdict.writeAccepted, "the listed relay's OK must still be awaited and recorded") + assertNull(result!![foreign], "the foreign relay must not appear in the result") + } + @Test fun silentWriteLeavesTheWriteSideUnobserved() = runTest { From 9a4f6c6cdd96500be8b35fe2e2490fe4e2562968 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:26:54 +0000 Subject: [PATCH 111/227] feat(quartz): optional kinds on the read-test filter (production finding) Verified the new probe surface end-to-end against production relays (probeFlow streaming, readWriteCheck, signed 30166 templates). One compatibility finding: purpose relays like purplepag.es reject any REQ that names no kind ('blocked: filters must specify at least one kind'), leaving their read side unobserved. readTestFilter/readWriteCheck now take an optional kinds list for those; the default stays kind-less because naming kinds also narrows the query on every other relay. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK --- .../nip66RelayMonitor/reachability/RelayProber.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt index 5dcf878aad..d03fb01208 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt @@ -194,12 +194,13 @@ class RelayProber( timeoutMs: Long = 15_000, waveSize: Int = 1000, readLimit: Int = 1, + readKinds: List? = null, ): Map { val out = HashMap() val distinct = relays.toSet() for ((waveIndex, wave) in distinct.chunked(waveSize.coerceAtLeast(1)).withIndex()) { val reads = HashMap() - probeWave(wave, timeoutMs, readTestFilter(readLimit)) { reads[it.relay] = it.rttEoseMs } + probeWave(wave, timeoutMs, readTestFilter(readLimit, readKinds)) { reads[it.relay] = it.rttEoseMs } // A distinct event id per wave (createdAt has second granularity, so the // content must vary) keeps a straggler OK from an earlier wave's relays @@ -346,8 +347,17 @@ class RelayProber( * [filters] to turn [Verdict.rttEoseMs] into a genuine read test rather * than a liveness ping — the time still counts from the wave start (dial * included), so compare it against [Verdict.rttOpenMs], not across waves. + * + * [kinds] widens compatibility with purpose relays: some (purplepag.es) + * reject any REQ that names no kind with `blocked: filters must specify at + * least one kind`, which leaves their read side unobserved. Passing e.g. + * `listOf(0, 1)` satisfies them; the default stays kind-less because a + * kind list also narrows the query on every OTHER relay. */ - fun readTestFilter(limit: Int = 1) = listOf(Filter(limit = limit)) + fun readTestFilter( + limit: Int = 1, + kinds: List? = null, + ) = listOf(Filter(kinds = kinds, limit = limit)) /** * The relay universe the local store knows: every read/write relay advertised From 5ec35c77726c36cf9ee8581e7bc6e0f684ce4557 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:35:22 +0000 Subject: [PATCH 112/227] feat(quartz): default the read test to kind 0, limit 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A kind-0, limit-1 REQ works everywhere: purpose relays (purplepag.es) reject kind-less filters outright, and practically every relay stores some profile. Verified against production — purplepag.es's read side now measures instead of going unobserved. Pass a different kinds list to probe a specific shelf, or null for a kind-less query on relays known to allow one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK --- .../nip66RelayMonitor/reachability/RelayProber.kt | 14 +++++++------- .../reachability/RelayProberFlowTest.kt | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt index d03fb01208..48d33d9185 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt @@ -194,7 +194,7 @@ class RelayProber( timeoutMs: Long = 15_000, waveSize: Int = 1000, readLimit: Int = 1, - readKinds: List? = null, + readKinds: List? = listOf(0), ): Map { val out = HashMap() val distinct = relays.toSet() @@ -348,15 +348,15 @@ class RelayProber( * than a liveness ping — the time still counts from the wave start (dial * included), so compare it against [Verdict.rttOpenMs], not across waves. * - * [kinds] widens compatibility with purpose relays: some (purplepag.es) - * reject any REQ that names no kind with `blocked: filters must specify at - * least one kind`, which leaves their read side unobserved. Passing e.g. - * `listOf(0, 1)` satisfies them; the default stays kind-less because a - * kind list also narrows the query on every OTHER relay. + * [kinds] defaults to kind 0: purpose relays (purplepag.es) reject any REQ + * that names no kind with `blocked: filters must specify at least one kind`, + * and practically every relay stores SOME profile — so a kind-0, limit-1 + * query works everywhere. Pass a different list to probe a specific shelf, + * or null for a kind-less query on relays known to allow one. */ fun readTestFilter( limit: Int = 1, - kinds: List? = null, + kinds: List? = listOf(0), ) = listOf(Filter(kinds = kinds, limit = limit)) /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt index 153c6e5d3e..8593489ab2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt @@ -227,6 +227,7 @@ class RelayProberFlowTest { delay(10) val sent = client.sentFilters!![fast]!!.single() assertEquals(1, sent.limit, "read test defaults to limit 1") + assertEquals(listOf(0), sent.kinds, "read test defaults to kind 0 — accepted by purpose relays too") assertNull(sent.ids, "read test must query real events, not the impossible id") client.listener!!.onEose(fast, null) } From a5d2d153c875904e5d40a6ff27a34d13df56eb4e Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:40:56 +0000 Subject: [PATCH 113/227] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-hi-rIN/strings.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 14c4d09d1d..59eea919c8 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -332,7 +332,7 @@ क्या समुदाय छोडें। क्या %1$s छोडें। इसे हटाया जाएगा इस लेखा की सूची से तथा आपके यन्त्रों पर समचरणीकरण रुक जाएगा। समुदाय को सूचित नहीं किया जाएगा। तथा आपको उसके सदस्य कार्यसूची से हटाया नहीं जाएगा। सन्देश जिनका आप अरहस्यीकरण नहीं कर सकेंगे सम्भाव्यतः पुनःप्राप्तव्य नहीं होंगे। तथा आप केवल नए आमन्त्रण के साथ लौट सकेंगे। आपने इस समुदाय को बनाया। छोड जाने से यह मिटेगा नहीं। किसी अन्य के हाथ सौंपा नहीं जाएगा। परन्तु स्वत्वधारी कुंचिका जो आपकी सूची में हैं वह हटाया जाएगा। आप आगे से इसका प्रबन्धन नहीं कर पाएँगे। - जहाँ इस समुदाय के रहस्यीकृत पत्रों का प्रकाशन तथा पठन किया जाता है। + जहाँ इस समुदाय के रहस्यीकृत समतलों का प्रकाशन तथा पठन किया जाता है। इस समुदाय को विघटित किया गया है तथा अब पठनेवशक्य है। आप इसका इतिहास पढ सकते हैं परन्तु कोई नए सन्देश नहीं भेज सकते। %1$s टंकण मध्य… %1$s तथा %2$s टंकण मध्य… @@ -1233,7 +1233,7 @@ मौन हटाएँ सम्भाव्यतः उन ग्राहकों द्वारा उपेक्षित जो आज्ञा का सम्मान नहीं करते। क्या शाला से निष्कासित करें। - %1$s को ध्वनि तल से हटाए जाएँगे तथा सहभागी सूची से भी। वे पुनः जुड सकते हैं यदि वे शाला योजक प्राप्त कर लें। + %1$s को ध्वनि समतल से हटाए जाएँगे तथा सहभागी सूची से भी। वे पुनः जुड सकते हैं यदि वे शाला योजक प्राप्त कर लें। पदप्रहार क्या वक्ता को मौन करें। %1$s के ग्राहक को अपना ध्वनिग्राहक मौन करने का अनुरोध करता है। कुछ ग्राहक इस आदेश की उपेक्षा कर सकते हैं। @@ -1991,7 +1991,7 @@ आपके आगतपेटिका पुनःप्रसारक तथा कुछ अल्पमात्रा परिभ्रमणवर्ती दृष्टान्त पुनःप्रसारक जिनपर आपके अनुचरित पत्र प्रकाशित करते हैं। यदि कोई उल्लेख अन्यत्र भेजा गया। आपके सीधासन्देश पुनःप्रसारक। जहाँ उपहारकोषयुक्त सन्देश भेजे जाते हैं। मुख्य पुनःप्रसारक प्रत्येक चर्चा का जिन्हें आप खोल रखे हैं अथवा जिनसे आप जुड चुके हैं। - पुनःप्रसारक जिनपर प्रत्येक समुदाय अपने पत्र प्रकाशित करते हैं। + पुनःप्रसारक जिनपर प्रत्येक समुदाय अपने समतलों को प्रकाशित करते हैं। समूह सन्देश तथा कुंचिकापेटलियाँ। प्रत्येक समूह के पुनःप्रसारकों पर। शाला के पुनःप्रसारक। जब वह खुला हो। आपके अपने परिचय तथा स्थापना विकल्प तथा पाण्डुलिपियाँ। आपके मुख्य पुनःप्रसारकों पर। From e56e2269df3fbb965a1040c633f8d28a73cc2db5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 18:39:39 +0000 Subject: [PATCH 114/227] feat: offer Copy Original / Copy Translated when copying translated notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Copy Text menu (note quick-action popup, the shared note-action sections behind the 3-dot menu and chat long-press sheet, and bookmark group item options) now checks whether the rendered note was translated and, if so, pops a chooser offering Copy Original / Copy Translated instead of silently copying the original. Rather than plumbing the translated string from TranslatableRichTextViewer down to the menus, the shared copyNoteTextAction flow re-derives it from the process-wide TranslationsCache keyed by (content, language settings) — rendering the note is what populated that cache, so a hit means the user is looking at a translation. The cache is play-flavor-only, so the lookup goes through a new cachedTranslation() flavor pair (fdroid always null, keeping its Copy Text single-option). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J7wEshKZC5HSgQykQiMQnt --- .../ui/components/CachedTranslation.kt | 32 +++++ .../amethyst/ui/note/CopyNoteText.kt | 133 ++++++++++++++++++ .../amethyst/ui/note/NoteQuickActionMenu.kt | 22 +-- .../ui/note/elements/NoteActionSections.kt | 16 ++- .../display/BookmarkGroupItemOptions.kt | 18 ++- amethyst/src/main/res/values/strings.xml | 2 + .../ui/components/CachedTranslation.kt | 45 ++++++ 7 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt new file mode 100644 index 0000000000..1388e9a5bc --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * No translation service in this flavor, so no note is ever translated and the copy-text + * menus never need to offer a "Copy Translated" option. + */ +fun cachedTranslation( + content: String, + accountViewModel: AccountViewModel, +): String? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt new file mode 100644 index 0000000000..74e23ee3f6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalClipboard +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.components.cachedTranslation +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.launch + +/** Both texts of a translated note, held while the user picks which one to copy. */ +@Immutable +data class CopyTextChoice( + val original: String, + val translated: String, +) + +/** + * The "Copy Text" flow shared by every menu that copies an event's text. + * + * The copy menus sit far from the `TranslatableRichTextViewer` that rendered (and possibly + * translated) the note, so instead of plumbing the translated string down the hierarchy this + * flow re-derives it from [cachedTranslation]: the process-wide translation cache keyed by + * (content, language settings). By the time any copy menu is reachable the note has been + * rendered, which is what populated that cache — so a hit means the user is looking at a + * translation and gets a chooser (Copy Original / Copy Translated); a miss copies directly. + * + * Returns the click handler for the menu entry, taking the note whose text to copy (callers + * pass the latest version of a versioned post). [onCopied] runs after the text lands on the + * clipboard, [onDismiss] when the chooser is cancelled without copying; callers must keep + * their menu in composition until one of the two runs, because the chooser dialog is emitted + * from this composable. + */ +@Composable +fun copyNoteTextAction( + accountViewModel: AccountViewModel, + onCopied: () -> Unit, + onDismiss: () -> Unit, +): (Note) -> Unit { + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + val choice = remember { mutableStateOf(null) } + + val copy: (String) -> Unit = { text -> + scope.launch { + clipboardManager.setText(text) + onCopied() + } + } + + choice.value?.let { options -> + CopyTextChooserDialog( + onCopyOriginal = { + choice.value = null + copy(options.original) + }, + onCopyTranslated = { + choice.value = null + copy(options.translated) + }, + onDismiss = { + choice.value = null + onDismiss() + }, + ) + } + + return { note -> + accountViewModel.decrypt(note) { original -> + val translated = cachedTranslation(original, accountViewModel) + if (translated == null) { + copy(original) + } else { + choice.value = CopyTextChoice(original, translated) + } + } + } +} + +@Composable +fun CopyTextChooserDialog( + onCopyOriginal: () -> Unit, + onCopyTranslated: () -> Unit, + onDismiss: () -> Unit, +) { + M3ActionDialog( + title = stringRes(R.string.copy_text), + onDismiss = onDismiss, + ) { + M3ActionSection { + M3ActionRow( + icon = MaterialSymbols.ContentCopy, + text = stringRes(R.string.copy_text_original), + onClick = onCopyOriginal, + ) + M3ActionRow( + icon = MaterialSymbols.Translate, + text = stringRes(R.string.copy_text_translated), + onClick = onCopyTranslated, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index a1161ab2b5..fc98c537af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -299,20 +299,26 @@ fun CardBody( ) } + // When the rendered note was translated, tapping Copy Text opens a chooser + // (Copy Original / Copy Translated) on top of this popup; the popup stays up + // until the flow resolves so the chooser survives in composition. + val copyNoteText = + copyNoteTextAction( + accountViewModel = accountViewModel, + onCopied = { + showToast(R.string.copied_note_text_to_clipboard) + onDismiss() + }, + onDismiss = onDismiss, + ) + Column(modifier = Modifier.width(IntrinsicSize.Min)) { Row(modifier = Modifier.height(IntrinsicSize.Min)) { NoteQuickActionItem( icon = MaterialSymbols.ContentCopy, label = stringRes(R.string.quick_action_copy_text), ) { - accountViewModel.decrypt(note) { - scope.launch { - clipboardManager.setText(it) - showToast(R.string.copied_note_text_to_clipboard) - } - } - - onDismiss() + copyNoteText(note) } VerticalDivider(color = primaryLight) NoteQuickActionItem( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt index df3f8c5572..9cb33f5778 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialogOneButton +import com.vitorpamplona.amethyst.ui.note.copyNoteTextAction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.LightRedColor @@ -129,14 +130,21 @@ fun noteActionSections( ) } + // When the rendered note was translated, Copy Text opens a chooser (Copy + // Original / Copy Translated) on top of the menu; the menu dismisses only + // after the flow resolves so the chooser survives in composition. + val copyNoteText = + copyNoteTextAction( + accountViewModel = accountViewModel, + onCopied = handlers.onDismiss, + onDismiss = handlers.onDismiss, + ) + val copyAndShare = buildList { add( NoteAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy_text)) { - accountViewModel.decrypt(noteVersionToCopy) { - scope.launch { clipboardManager.setText(it) } - } - handlers.onDismiss() + copyNoteText(noteVersionToCopy) }, ) add( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index 35f9774204..229f21c690 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.note.copyNoteTextAction import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams import com.vitorpamplona.amethyst.ui.note.elements.observeBookmarksFollowsAndAccount import com.vitorpamplona.amethyst.ui.note.externalLinkForNote @@ -186,16 +187,21 @@ fun BookmarkGroupItemOptionsMenu( } } + // When the rendered note was translated, Copy Text opens a chooser (Copy + // Original / Copy Translated) on top of the menu; the menu dismisses only + // after the flow resolves so the chooser survives in composition. + val copyNoteText = + copyNoteTextAction( + accountViewModel = accountViewModel, + onCopied = onDismiss, + onDismiss = onDismiss, + ) + // Copy & Share section M3ActionSection { M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_text)) { val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note - accountViewModel.decrypt(lastNoteVersion) { - scope.launch { - clipboardManager.setText(it) - } - } - onDismiss() + copyNoteText(lastNoteVersion) } M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { note.author?.let { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1843dbfdd4..bdc64269b3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -46,6 +46,8 @@ Violence Unknown Author Copy Text + Copy Original + Copy Translated Copy Author ID Copy Note ID Copy raw JSON diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt new file mode 100644 index 0000000000..cc7229491f --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import com.vitorpamplona.amethyst.service.lang.TranslationsCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * The already-computed translation of [content] under the current language settings, or null + * when no translation occurred (same language, undetected source, blocklisted) or none is + * cached. Cache-only on purpose: this backs the "Copy Translated" option of the copy-text + * menus, which only applies to text the user is looking at — and rendering it through + * [TranslatableRichTextViewer] is what populated the cache. + */ +fun cachedTranslation( + content: String, + accountViewModel: AccountViewModel, +): String? { + val languages = accountViewModel.account.settings.syncedSettings.languages + val config = + TranslationsCache.get(content, languages.translateTo.value, languages.dontTranslateFrom.value) + ?: return null + val source = config.sourceLang ?: return null + val target = config.targetLang ?: return null + if (source == target || config.result == content) return null + return config.result +} From 4f6d6acefd2d340014d7b38be0a2a6274fd5cd04 Mon Sep 17 00:00:00 2001 From: sandwich <299465+dskvr@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:59:22 +0100 Subject: [PATCH 115/227] Align napplet host with current NIP-5D and NAPs --- .../amethyst/favorites/FavoriteAppLauncher.kt | 26 +- .../napplet/DataStoreNappletStorage.kt | 27 +- .../amethyst/napplet/NappletBrokerService.kt | 77 +++-- .../napplet/NappletCapabilityLabels.kt | 2 - .../amethyst/napplet/NappletConsentSummary.kt | 5 +- .../amethyst/napplet/NappletIdentityWatch.kt | 17 +- .../amethyst/napplet/NappletLaunchRegistry.kt | 2 +- .../amethyst/napplet/NappletLauncher.kt | 107 +++++-- .../napplet/NappletLiveSubscriptions.kt | 54 +++- .../amethyst/napplet/NappletRelayCleartext.kt | 75 +++++ .../gateways/AccountNappletGateways.kt | 4 +- .../gateways/NappletResourceFetcher.kt | 302 ++++++++++++++---- .../loggedIn/napplets/NappletCapabilityExt.kt | 1 - .../napplet/NappletProtocolJsonTest.kt | 106 +++++- .../napplet/NappletRelayCleartextTest.kt | 108 +++++++ .../napplet/NappletSdkConformanceTest.kt | 53 +-- .../NappletResourceFetcherPolicyTest.kt | 70 ++++ .../composeResources/files/napplet/shell.html | 26 +- .../composeResources/files/napplet/shim.js | 77 +++-- .../commons/napplet/NappletArtifactPolicy.kt | 42 +++ .../amethyst/commons/napplet/NappletBroker.kt | 74 +++-- .../napplet/NappletBrokerCollaborators.kt | 16 +- .../commons/napplet/NappletCapability.kt | 34 +- .../commons/napplet/NappletIdentity.kt | 8 + .../commons/napplet/NappletWebContract.kt | 102 ++++-- .../napplet/protocol/NappletRequest.kt | 32 +- .../napplet/protocol/NappletResponse.kt | 27 +- .../napplet/NappletArtifactPolicyTest.kt | 54 ++++ .../commons/napplet/NappletBrokerTest.kt | 86 ++++- .../commons/napplet/NappletCapabilityTest.kt | 24 +- .../commons/napplet/NappletWebContractTest.kt | 79 +++++ .../commons/napplet/NappletRequestRouter.kt | 30 +- .../napplet/protocol/NappletProtocolJson.kt | 100 ++++-- .../napplet/NappletRequestRouterTest.kt | 43 +-- .../napplethost/NappletContentServer.kt | 76 +++-- .../napplethost/NappletHostActivity.kt | 18 +- .../napplethost/NappletHostService.kt | 26 +- 37 files changed, 1537 insertions(+), 473 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartextTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcherPolicyTest.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicy.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicyTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index bfdc4466ed..f46e2e7f24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -165,27 +165,17 @@ object FavoriteAppLauncher { return when (event) { is RootNappletEvent -> NappletLauncher.buildLaunchParams( - context, - event.paths(), - event.servers(), - event.pubKey, - "", - event.declaredAggregateHash() ?: event.computeAggregateHash(), - event.title() ?: "Napplet", - event.requires(), - HostProfile.NAPPLET, + context = context, + manifest = event, + authorPubKey = event.pubKey, + identifier = "", ) is NamedNappletEvent -> NappletLauncher.buildLaunchParams( - context, - event.paths(), - event.servers(), - event.pubKey, - event.identifier(), - event.declaredAggregateHash() ?: event.computeAggregateHash(), - event.title() ?: event.identifier(), - event.requires(), - HostProfile.NAPPLET, + context = context, + manifest = event, + authorPubKey = event.pubKey, + identifier = event.identifier(), ) is RootSiteEvent -> NappletLauncher.buildLaunchParams( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt index 893f02ec87..14d3499371 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt @@ -57,7 +57,27 @@ class DataStoreNappletStorage( key: String, value: String, ) { - dataStore.edit { it[keyOf(coordinate, key)] = value } + dataStore.edit { preferences -> + val prefix = prefixOf(coordinate) + val target = keyOf(coordinate, key) + val currentBytes = + preferences + .asMap() + .entries + .asSequence() + .filter { it.key.name.startsWith(prefix) } + .sumOf { (storedKey, storedValue) -> + storedKey.name + .removePrefix(prefix) + .encodeToByteArray() + .size + + ((storedValue as? String)?.encodeToByteArray()?.size ?: 0) + } + val replacedBytes = key.encodeToByteArray().size + (preferences[target]?.encodeToByteArray()?.size ?: 0) + val proposedBytes = currentBytes - replacedBytes + key.encodeToByteArray().size + value.encodeToByteArray().size + require(proposedBytes <= MAX_STORAGE_BYTES) { "Napplet storage quota exceeded." } + preferences[target] = value + } } override suspend fun remove( @@ -87,4 +107,9 @@ class DataStoreNappletStorage( coordinate: String, key: String, ) = stringPreferencesKey(prefixOf(coordinate) + key) + + companion object { + /** NAP-STORAGE's recommended per-napplet UTF-8 quota. */ + const val MAX_STORAGE_BYTES = 512 * 1024 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 37a124c67e..58453f479b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.napplethost.NappletIpc import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -58,6 +59,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** * The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this @@ -93,7 +95,11 @@ class NappletBrokerService : Service() { // Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the // requesting surface's launch token, so a surface's REQs always target the account it acts as. - private val liveSubscriptions = NappletLiveSubscriptions() + private val liveSubscriptions = NappletLiveSubscriptions(scope) + + // NAP-RESOURCE cancellation is keyed by the trusted launch token plus the caller's request id. + // Cancelling removes the job before it can emit a late terminal envelope to the sandbox. + private val resourceRequests = ConcurrentHashMap() // The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes. private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) } @@ -117,7 +123,7 @@ class NappletBrokerService : Service() { override fun onDestroy() { liveSubscriptions.closeAll() - identityWatch.stop() + identityWatch.stopAll() // Every applet/browser surface has unbound, so the "session" the user granted for is over. // The ledger and the broker cache are now app-wide singletons that outlive this service, so // their in-memory session grants have to be dropped explicitly here — that keeps the lifetime @@ -280,38 +286,57 @@ class NappletBrokerService : Service() { // Resolve the launch token to the trusted identity + declared set. The sandbox never states // its own coordinate, so a compromised :napplet process can only ever act as the napplet it // was launched as (it holds only its own token). An unknown token = no session; refuse. - val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN)) + val launchToken = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) + val session = NappletLaunchRegistry.resolve(launchToken) if (session == null) { reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session."))) return true } val identity = session.identity val declared = session.declared + val resourceRequestKey = "$launchToken\u0000$requestId" + if (requestType == "resource.cancel") { + resourceRequests.remove(resourceRequestKey)?.cancel() + return true + } + val tracksResourceRequest = requestType == "resource.bytes" || requestType == "resource.bytesMany" - scope.launch { - // The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply - // decision (it stays wire-identical with the future desktop host). This service only supplies - // the broker, the Messenger transport, and the live relay subscription each Outcome implies. - // The launch token decides whose key signs — not the active account. A surface opened by - // one account can never be handed another's signer, even while it stays open across a switch. - val broker = brokerFor(session.accountPubKey) - if (broker == null) { - reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in."))) - return@launch - } - when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { - is NappletRequestRouter.Outcome.Ignore -> {} - is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload) - is NappletRequestRouter.Outcome.OpenSubscription -> - liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) } - is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId) - is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { push(replyTo, it) } - is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop() - is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) } - is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic) - is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic) - is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw) + val requestJob = + scope.launch(start = if (tracksResourceRequest) CoroutineStart.LAZY else CoroutineStart.DEFAULT) { + // The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply + // decision (it stays wire-identical with the future desktop host). This service only supplies + // the broker, the Messenger transport, and the live relay subscription each Outcome implies. + // The launch token decides whose key signs — not the active account. A surface opened by + // one account can never be handed another's signer, even while it stays open across a switch. + val broker = brokerFor(session.accountPubKey) + if (broker == null) { + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in."))) + return@launch + } + when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { + is NappletRequestRouter.Outcome.Ignore -> {} + is NappletRequestRouter.Outcome.Reply -> { + reply(replyTo, requestId, outcome.payload) + // NAP-IDENTITY has no watch/unwatch request. Once the consent-gated startup + // snapshot succeeds, the runtime owns identity.changed delivery for this + // trusted launch token until the broker service closes. + if (requestType == "identity.getPublicKey" && outcome.payload.contains("\"ok\":true") && launchToken != null) { + identityWatch.start(launchToken, session.accountPubKey) { push(replyTo, it) } + } + } + is NappletRequestRouter.Outcome.OpenSubscription -> + liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) } + is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId) + is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) } + is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic) + is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic) + is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw) + } } + if (tracksResourceRequest) { + resourceRequests.put(resourceRequestKey, requestJob)?.cancel() + requestJob.invokeOnCompletion { resourceRequests.remove(resourceRequestKey, requestJob) } + requestJob.start() } return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt index adff3b46f6..15ab1abd71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability @StringRes fun NappletCapability.labelRes(): Int = when (this) { - NappletCapability.SHELL -> R.string.napplet_cap_shell NappletCapability.IDENTITY -> R.string.napplet_cap_identity NappletCapability.KEYS -> R.string.napplet_cap_keys NappletCapability.RELAY -> R.string.napplet_cap_relay @@ -45,7 +44,6 @@ fun NappletCapability.labelRes(): Int = @StringRes fun NappletCapability.descriptionRes(): Int = when (this) { - NappletCapability.SHELL -> R.string.napplet_cap_shell_desc NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc NappletCapability.KEYS -> R.string.napplet_cap_keys_desc NappletCapability.RELAY -> R.string.napplet_cap_relay_desc diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt index 42f4ec93f5..583cda40b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -294,9 +294,10 @@ class NappletConsentSummary( context.getString(R.string.napplet_consent_pay_no_amount) } } - is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource) + NappletRequest.ResourceInfo, is NappletRequest.ResourceBytes, is NappletRequest.ResourceBytesMany -> + context.getString(R.string.napplet_consent_resource) is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload) // Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown. - is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> "" + is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> "" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt index f94f7bc2cf..bbb6a3aaa2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt @@ -34,21 +34,22 @@ import kotlinx.coroutines.launch * value is dropped — the applet already has it via `getPublicKey`), encodes and pushes the new key * (or `""` when no account is signed in) to the caller-supplied sink. * - * One watch at a time per host binding; [start] replaces any prior one. Reached only after the - * router confirmed the applet declared the IDENTITY capability. + * Watches are keyed by the trusted launch token so concurrent surfaces cannot replace each other's + * streams. A watch starts only after that surface successfully obtains its public-key snapshot. */ class NappletIdentityWatch( private val scope: CoroutineScope, private val pubKey: (boundPubKey: String) -> Flow, ) { - private var job: Job? = null + private val jobs = mutableMapOf() fun start( + watchId: String, boundPubKey: String, push: (String) -> Unit, ) { - stop() - job = + if (jobs.containsKey(watchId)) return + jobs[watchId] = scope.launch { pubKey(boundPubKey) .distinctUntilChanged() @@ -57,8 +58,8 @@ class NappletIdentityWatch( } } - fun stop() { - job?.cancel() - job = null + fun stopAll() { + jobs.values.forEach { it.cancel() } + jobs.clear() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt index b91aabad36..8f6741e564 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt @@ -76,7 +76,7 @@ object NappletLaunchRegistry { accountPubKey: HexKey, ): String { val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey() - sessions[token] = Session(identity, declared, accountPubKey) + sessions[token] = Session(identity.copy(instanceId = token), declared, accountPubKey) return token } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt index 2c98ffb8fe..29c0a2f091 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -24,14 +24,18 @@ import android.content.Context import android.content.Intent import android.content.res.Configuration import android.os.Bundle +import android.util.Log import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.napplethost.HostProfile import com.vitorpamplona.amethyst.napplethost.NappletHostActivity import com.vitorpamplona.amethyst.napplethost.NappletHostContract +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent @@ -49,20 +53,18 @@ object NappletLauncher { manifest: NappletManifest, authorPubKey: HexKey, identifier: String, - ) = launch( - context = context, - paths = manifest.paths(), - servers = manifest.servers(), - authorPubKey = authorPubKey, - identifier = identifier, - aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(), - title = manifest.title() ?: identifier.ifBlank { "Napplet" }, - requires = manifest.requires(), - ) + ) { + val event = manifest as? Event + if (event?.verify() != true || event.pubKey != authorPubKey) { + Log.w(TAG, "Refusing NIP-5D manifest that failed signature/author verification") + return + } + buildLaunchParams(context, manifest, authorPubKey, identifier)?.let { openHost(context, it) } + } /** - * Opens any NIP-5A static site (nsite or napplet). [requires] is empty for a plain nsite — - * the broker then refuses every capability, so the site renders as inert static content. + * Opens a NIP-5A website from its already-resolved path data. NIP-5D napplets use the verified + * manifest overload so raw callers cannot bypass signature/author validation. */ fun launch( context: Context, @@ -73,12 +75,26 @@ object NappletLauncher { aggregateHash: HexKey?, title: String, requires: List, - // nSites open as [HostProfile.WEBSITE]: a NIP-07 window.nostr provider + normal network. The - // broker then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless - // of the (empty) manifest `requires`. Napplets keep the default locked [HostProfile.NAPPLET]. - profile: HostProfile = HostProfile.NAPPLET, + // Raw path data is accepted only for the legacy NIP-5A website profile. NIP-5D callers must + // use the signature-checking manifest overload above. + profile: HostProfile, + ) { + if (profile != HostProfile.WEBSITE) { + Log.w(TAG, "Refusing raw NIP-5D launch without a verified manifest") + return + } + val params = + runCatching { buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) } + .onFailure { Log.w(TAG, "Refusing invalid ${profile.name.lowercase()} launch", it) } + .getOrNull() + ?: return + openHost(context, params) + } + + private fun openHost( + context: Context, + params: Bundle, ) { - val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) val intent = Intent(context, NappletHostActivity::class.java).apply { putExtras(params) @@ -105,6 +121,29 @@ object NappletLauncher { requires: List, profile: HostProfile, ): Bundle { + require(profile == HostProfile.WEBSITE) { "NIP-5D launch parameters require a verified manifest." } + return buildLaunchParamsTrusted(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) + } + + private fun buildLaunchParamsTrusted( + context: Context, + paths: List, + servers: List, + authorPubKey: HexKey, + identifier: String, + aggregateHash: HexKey?, + title: String, + requires: List, + profile: HostProfile, + ): Bundle { + val effectiveAggregateHash = + if (profile == HostProfile.NAPPLET) { + requireNotNull(NappletArtifactPolicy.verifiedAggregateHash(paths, aggregateHash)) { + "NIP-5D requires one self-contained /index.html with a valid blob hash and matching aggregate." + } + } else { + aggregateHash + } val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 // Augment the manifest's servers with the author's published Blossom list (kind:10063), if @@ -118,7 +157,7 @@ object NappletLauncher { // Mint the launch token in the (trusted) main process: the broker resolves the sandbox's // requests back to THIS identity + declared set, regardless of anything the sandbox sends. - val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash) + val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = effectiveAggregateHash) val declared = profile.declaredCapabilities(requires) // Bound to the account launching it, so the surface keeps signing as that account even if the // user switches while it is open (an embedded surface is rebuilt on a switch and re-mints). @@ -156,7 +195,7 @@ object NappletLauncher { putStringArrayList(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers)) putString(NappletHostContract.EXTRA_AUTHOR, authorPubKey) putString(NappletHostContract.EXTRA_IDENTIFIER, identifier) - putString(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash) + putString(NappletHostContract.EXTRA_AGGREGATE_HASH, effectiveAggregateHash) putString(NappletHostContract.EXTRA_TITLE, title) putStringArrayList(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires)) putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels)) @@ -170,4 +209,34 @@ object NappletLauncher { putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current()) } } + + /** Signature-checking entry point for embedded NIP-5D surfaces. */ + fun buildLaunchParams( + context: Context, + manifest: NappletManifest, + authorPubKey: HexKey, + identifier: String, + ): Bundle? { + val event = manifest as? Event + if (event?.verify() != true || event.pubKey != authorPubKey) { + Log.w(TAG, "Refusing embedded NIP-5D manifest that failed signature/author verification") + return null + } + return runCatching { + buildLaunchParamsTrusted( + context = context, + paths = manifest.paths(), + servers = manifest.servers(), + authorPubKey = authorPubKey, + identifier = identifier, + aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(), + title = manifest.title() ?: identifier.ifBlank { "Napplet" }, + requires = manifest.requires(), + profile = HostProfile.NAPPLET, + ) + }.onFailure { Log.w(TAG, "Refusing invalid embedded NIP-5D launch", it) } + .getOrNull() + } + + private const val TAG = "NappletLauncher" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt index 8429a0b721..12d3500cd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt @@ -27,6 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -44,7 +48,9 @@ import java.util.concurrent.atomic.AtomicInteger * signatures still came from the old one. [open] is reached only after the broker authorized the * subscription (RELAY consent). */ -class NappletLiveSubscriptions { +class NappletLiveSubscriptions( + private val scope: CoroutineScope, +) { private val liveSubs = ConcurrentHashMap() private val liveSeq = AtomicInteger(0) @@ -53,6 +59,20 @@ class NappletLiveSubscriptions { val client: INostrClient, ) { val eoseSent = AtomicBoolean(false) + val deliveries = Channel(Channel.UNLIMITED) + var deliveryJob: Job? = null + } + + private sealed interface Delivery { + data class RelayEvent( + val event: Event, + ) : Delivery + + data object Eose : Delivery + + data class Closed( + val reason: String, + ) : Delivery } /** @@ -77,6 +97,20 @@ class NappletLiveSubscriptions { // can't collide with the subscription it's replacing. val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client) liveSubs[nappletSubId] = sub + sub.deliveryJob = + scope.launch { + for (delivery in sub.deliveries) { + if (liveSubs[nappletSubId] !== sub) break + when (delivery) { + is Delivery.RelayEvent -> + NappletRelayCleartext.forDelivery(delivery.event, account.signer)?.let { + push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it)) + } + Delivery.Eose -> push(NappletProtocolJson.encodeRelayEose(nappletSubId)) + is Delivery.Closed -> push(NappletProtocolJson.encodeRelayClosed(nappletSubId, delivery.reason)) + } + } + } val listener = object : SubscriptionListener { @@ -85,7 +119,9 @@ class NappletLiveSubscriptions { isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?, - ) = push(NappletProtocolJson.encodeRelayEvent(nappletSubId, event)) + ) { + sub.deliveries.trySend(Delivery.RelayEvent(event)) + } // A subscription fans out to several relays; collapse their EOSEs into the single // relay.eose the SDK expects (fired when the first relay finishes its stored events). @@ -93,14 +129,16 @@ class NappletLiveSubscriptions { relay: NormalizedRelayUrl, forFilters: List?, ) { - if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId)) + if (sub.eoseSent.compareAndSet(false, true)) sub.deliveries.trySend(Delivery.Eose) } override fun onClosed( message: String, relay: NormalizedRelayUrl, forFilters: List?, - ) = push(NappletProtocolJson.encodeRelayClosed(nappletSubId, message)) + ) { + sub.deliveries.trySend(Delivery.Closed(message)) + } } runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) } @@ -109,12 +147,18 @@ class NappletLiveSubscriptions { /** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */ fun close(nappletSubId: String) { val sub = liveSubs.remove(nappletSubId) ?: return + sub.deliveries.close() + sub.deliveryJob?.cancel() runCatching { sub.client.unsubscribe(sub.clientSubId) } } /** Tears down every open subscription (service teardown). */ fun closeAll() { - liveSubs.values.forEach { sub -> runCatching { sub.client.unsubscribe(sub.clientSubId) } } + liveSubs.values.forEach { sub -> + sub.deliveries.close() + sub.deliveryJob?.cancel() + runCatching { sub.client.unsubscribe(sub.clientSubId) } + } liveSubs.clear() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt new file mode 100644 index 0000000000..3b55dad5cd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 + +/** NAP-RELAY read boundary: encrypted event content is decrypted or withheld, never exposed. */ +internal object NappletRelayCleartext { + suspend fun forDelivery( + event: Event, + signer: NostrSigner, + ): Event? = forDelivery(event, signer.pubKey, signer::decrypt) + + internal suspend fun forDelivery( + event: Event, + userPubKey: HexKey, + decrypt: suspend (String, HexKey) -> String, + ): Event? { + if (!isEncrypted(event)) return event + + val peer = + when { + event.pubKey == userPubKey -> event.recipientPubKey() + event.isAddressedTo(userPubKey) -> event.pubKey + else -> null + } ?: return null + val cleartext = runCatching { decrypt(event.content, peer) }.getOrNull() ?: return null + + // NAP-RELAY defines a decrypted read projection. Retain the relay event's identity and + // signature fields so callers can still correlate it, while making clear that this object + // must never be republished as a signed event after its content projection has changed. + return Event(event.id, event.pubKey, event.createdAt, event.kind, event.tags, cleartext, event.sig) + } + + internal fun isEncrypted(event: Event): Boolean = + event is PrivateDmEvent || + EncryptedInfo.isNIP04(event.content) || + isNip44V2(event.content) + + private fun isNip44V2(content: String): Boolean = + content.length >= MIN_NIP44_V2_LENGTH && + runCatching { Nip44v2.EncryptedInfo.decodePayload(content) }.isSuccess + + private fun Event.recipientPubKey(): HexKey? = + tags.firstNotNullOfOrNull { tag -> + tag.getOrNull(1)?.takeIf { tag.getOrNull(0) == "p" } + } + + private fun Event.isAddressedTo(pubKey: HexKey): Boolean = tags.any { tag -> tag.getOrNull(0) == "p" && tag.getOrNull(1) == pubKey } + + private const val MIN_NIP44_V2_LENGTH = 132 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index 139a935d5f..3222e810c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentSummary import com.vitorpamplona.amethyst.napplet.NappletNotificationStore +import com.vitorpamplona.amethyst.napplet.NappletRelayCleartext import com.vitorpamplona.amethyst.napplet.buildConnectInfo import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader @@ -265,7 +266,8 @@ class AccountNappletGateways( .distinctBy { it.id } .sortedByDescending { it.createdAt } val limit = filters.mapNotNull { it.limit }.maxOrNull() - return limit?.let { merged.take(it) } ?: merged + val limited = limit?.let { merged.take(it) } ?: merged + return limited.mapNotNull { NappletRelayCleartext.forDelivery(it, account.signer) } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index c64fb12eca..9acdf7b690 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet.gateways import android.util.Base64 import com.vitorpamplona.amethyst.commons.napplet.NappletResource +import com.vitorpamplona.amethyst.commons.napplet.NappletResourceResult import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry import com.vitorpamplona.quartz.nip01Core.core.Address @@ -39,9 +40,21 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.Authenticator +import okhttp3.CookieJar +import okhttp3.Dns +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request +import java.io.ByteArrayOutputStream +import java.io.InterruptedIOException +import java.net.InetAddress import java.net.URLDecoder +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.util.concurrent.TimeUnit /** * Fetches a resource URL on an applet's behalf — the applet has no direct network @@ -63,41 +76,100 @@ class NappletResourceFetcher( private val account: Account, private val httpClient: (useProxy: Boolean) -> OkHttpClient, ) { - /** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */ + /** Fetches an https/data/blossom/nostr resource and preserves the NAP-RESOURCE error category. */ suspend fun fetch( url: String, coordinate: String, - ): NappletResource? = + ): NappletResourceResult = withContext(Dispatchers.IO) { - // Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise. - NappletNetworkRegistry.awaitReady() - val client = httpClient(NappletNetworkRegistry.useTor(coordinate)) when { url.startsWith("data:") -> decodeDataUrl(url) - url.startsWith("https://") -> { - runCatching { - client - .newCall( - Request - .Builder() - .url(url) - .get() - .build(), - ).execute() - .use { r -> - if (!r.isSuccessful) return@withContext null - val body = r.body.bytes() - val type = r.header("Content-Type") ?: "application/octet-stream" - NappletResource(body, type) - } - }.getOrNull() + url.startsWith("nostr:") -> + resolveNostr(url)?.let(::success) ?: failure(ERROR_NOT_FOUND, "Nostr resource not found.") + url.startsWith("https://") || url.startsWith("blossom:") -> { + // Route like the applet's own page: locked napplets stay on Tor. The derived + // client removes ambient cookies/auth and validates DNS before every hop. + NappletNetworkRegistry.awaitReady() + val client = hardenedClient(httpClient(NappletNetworkRegistry.useTor(coordinate))) + if (url.startsWith("https://")) fetchHttps(url, client) else fetchBlossom(url, client) } - url.startsWith("blossom:") -> fetchBlossom(url, client) - url.startsWith("nostr:") -> resolveNostr(url) - else -> null + else -> failure(ERROR_UNSUPPORTED_SCHEME, "Unsupported resource URL scheme.") } } + private fun hardenedClient(baseClient: OkHttpClient): OkHttpClient = + baseClient + .newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .cache(null) + .cookieJar(CookieJar.NO_COOKIES) + .authenticator(Authenticator.NONE) + .proxyAuthenticator(Authenticator.NONE) + .callTimeout(FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .dns( + Dns { hostname -> + baseClient.dns.lookup(hostname).also { addresses -> + if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) { + throw BlockedResourceException("Resolved address is not public.") + } + } + }, + ).addNetworkInterceptor { chain -> + chain.proceed( + chain + .request() + .newBuilder() + .removeHeader("Authorization") + .removeHeader("Cookie") + .removeHeader("Proxy-Authorization") + .build(), + ) + }.build() + + private fun fetchHttps( + url: String, + client: OkHttpClient, + ): NappletResourceResult { + var current = safeHttpsUrl(url) ?: return failure(ERROR_BLOCKED, "Only credential-free HTTPS URLs are allowed.") + repeat(MAX_REDIRECTS + 1) { hop -> + try { + client + .newCall( + Request + .Builder() + .url(current) + .get() + .build(), + ).execute() + .use { response -> + if (response.isRedirect) { + if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.") + val location = response.header("Location") ?: return failure(ERROR_NETWORK, "Redirect has no location.") + current = safeHttpsUrl(current.resolve(location)) ?: return failure(ERROR_BLOCKED, "Redirect left credential-free HTTPS.") + return@repeat + } + if (response.code == 404) return failure(ERROR_NOT_FOUND) + if (!response.isSuccessful) return failure(ERROR_NETWORK, "Upstream returned HTTP ${response.code}.") + if (response.body.contentLength() > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE) + val body = readBounded(response.body.byteStream()) ?: return failure(ERROR_TOO_LARGE) + return classify(body) + } + } catch (e: BlockedResourceException) { + return failure(ERROR_BLOCKED, e.message) + } catch (_: InterruptedIOException) { + return failure(ERROR_TIMEOUT) + } catch (_: Exception) { + return failure(ERROR_NETWORK) + } + } + return failure(ERROR_BLOCKED, "Redirect limit exceeded.") + } + + private fun safeHttpsUrl(url: String): HttpUrl? = url.toHttpUrlOrNull()?.takeIf { isSafeHttpsResourceUrl(url) } + + private fun safeHttpsUrl(url: HttpUrl?): HttpUrl? = url?.takeIf { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() } + /** * Resolves a `nostr:` URI (NIP-19) to the referenced event and returns its JSON. An `nembed` * carries the event inline; `note`/`nevent`/`naddr` resolve from the local cache, falling back to @@ -158,62 +230,176 @@ class NappletResourceFetcher( private fun fetchBlossom( url: String, client: OkHttpClient, - ): NappletResource? { - val hash = - url - .removePrefix("blossom://") - .removePrefix("blossom:") - .substringBefore('/') - .substringBefore('?') - .trim() - .lowercase() - if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null + ): NappletResourceResult { + if (!url.startsWith(BLOSSOM_SHA256_PREFIX)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.") + val hash = url.removePrefix(BLOSSOM_SHA256_PREFIX).lowercase() + if (!hash.matches(SHA256)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.") val servers = account.blossomServers .getBlossomServersList() ?.servers() .orEmpty() + var sawHashMismatch = false for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) { - val bytes = - runCatching { - client - .newCall( - Request - .Builder() - .url(candidate) - .get() - .build(), - ).execute() - .use { r -> - if (r.isSuccessful) r.body.bytes() else null - } - }.getOrNull() ?: continue - if (StaticSiteResolver.verify(bytes, hash)) { - return NappletResource(bytes, sniffContentType(bytes) ?: "application/octet-stream") + when (val fetched = fetchHttps(candidate, client)) { + is NappletResourceResult.Success -> { + if (!StaticSiteResolver.verify(fetched.resource.bytes, hash)) { + sawHashMismatch = true + continue + } + return fetched + } + is NappletResourceResult.Failure -> if (fetched.error == ERROR_BLOCKED) return fetched } } - return null + if (sawHashMismatch) return failure(ERROR_DECODE_FAILED, "Blossom SHA-256 verification failed.") + return failure(ERROR_NOT_FOUND, "No Blossom server returned the verified blob.") } /** Parses a `data:[][;base64],` URL into bytes + content type. */ - private fun decodeDataUrl(url: String): NappletResource? { + private fun decodeDataUrl(url: String): NappletResourceResult { val comma = url.indexOf(',') - if (comma < 0) return null + if (comma < 0) return failure(ERROR_INVALID_REQUEST, "Malformed data URL.") val meta = url.substring("data:".length, comma) val data = url.substring(comma + 1) + if (data.length > MAX_DATA_URL_CHARS) return failure(ERROR_TOO_LARGE) val isBase64 = meta.endsWith(";base64") - val contentType = meta.removeSuffix(";base64").ifEmpty { "text/plain" } + val declaredType = + meta + .removeSuffix(";base64") + .substringBefore(';') + .ifEmpty { "text/plain" } + .lowercase() val bytes = if (isBase64) { - runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() ?: return null + runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() + ?: return failure(ERROR_DECODE_FAILED, "Invalid base64 data URL.") } else { - URLDecoder.decode(data, "UTF-8").encodeToByteArray() + runCatching { URLDecoder.decode(data, "UTF-8").encodeToByteArray() }.getOrNull() + ?: return failure(ERROR_DECODE_FAILED, "Invalid escaped data URL.") } - return NappletResource(bytes, contentType) + if (bytes.size > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE) + return classify(bytes, declaredType) + } + + private fun classify( + bytes: ByteArray, + declaredType: String? = null, + ): NappletResourceResult { + if (looksLikeSvg(bytes)) return failure(ERROR_BLOCKED, "Raw SVG is not delivered by this runtime.") + val sniffed = sniffContentType(bytes) + val type = + when { + sniffed in ALLOWED_SNIFFED_TYPES -> sniffed + declaredType == "application/json" && isJson(bytes) -> "application/json" + declaredType == "text/plain" && isPlainText(bytes) -> "text/plain" + else -> null + } ?: return failure(ERROR_DECODE_FAILED, "Resource MIME is not in the runtime allowlist.") + return success(NappletResource(bytes, type)) + } + + private fun looksLikeSvg(bytes: ByteArray): Boolean { + val prefix = bytes.copyOfRange(0, minOf(bytes.size, MIME_PREFIX_BYTES)).decodeToString().lowercase() + return prefix.contains(" + val output = ByteArrayOutputStream() + val buffer = ByteArray(8 * 1024) + var total = 0 + while (true) { + val read = source.read(buffer) + if (read < 0) break + total += read + if (total > MAX_RESOURCE_BYTES) return null + output.write(buffer, 0, read) + } + return output.toByteArray() + } } companion object { + internal fun isSafeHttpsResourceUrl(url: String): Boolean = url.toHttpUrlOrNull()?.let { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() } == true + + internal fun isPublicAddress(address: InetAddress): Boolean { + if (address.isAnyLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress || address.isSiteLocalAddress || address.isMulticastAddress) { + return false + } + val bytes = address.address + if (bytes.size == 4) { + val first = bytes[0].toInt() and 0xff + val second = bytes[1].toInt() and 0xff + // Shared address space (100.64/10) and reserved/non-routed ranges Java does not classify. + if (first == 0 || first >= 224) return false + if (first == 100 && second in 64..127) return false + if (first == 192 && second == 0) return false + if (first == 198 && second in 18..19) return false + if (first == 198 && second == 51 && (bytes[2].toInt() and 0xff) == 100) return false + if (first == 203 && second == 0 && (bytes[2].toInt() and 0xff) == 113) return false + } else if (bytes.size == 16) { + val first = bytes[0].toInt() and 0xff + if (first and 0xfe == 0xfc) return false // fc00::/7 unique-local + if ( + first == 0x20 && + (bytes[1].toInt() and 0xff) == 0x01 && + (bytes[2].toInt() and 0xff) == 0x0d && + (bytes[3].toInt() and 0xff) == 0xb8 + ) { + return false // 2001:db8::/32 documentation range + } + } + return true + } + private const val NOSTR_FETCH_TIMEOUT_MS = 8_000L + private const val FETCH_TIMEOUT_SECONDS = 30L + private const val MAX_REDIRECTS = 5 + private const val MIME_PREFIX_BYTES = 8 * 1024 + private const val MAX_DATA_URL_CHARS = 24 * 1024 * 1024 + private const val BLOSSOM_SHA256_PREFIX = "blossom:sha256:" + const val MAX_RESOURCE_BYTES = 10 * 1024 * 1024 + private const val ERROR_INVALID_REQUEST = "invalid-request" + private const val ERROR_NOT_FOUND = "not-found" + private const val ERROR_BLOCKED = "blocked-by-policy" + private const val ERROR_TIMEOUT = "timeout" + private const val ERROR_TOO_LARGE = "too-large" + private const val ERROR_UNSUPPORTED_SCHEME = "unsupported-scheme" + private const val ERROR_DECODE_FAILED = "decode-failed" + private const val ERROR_NETWORK = "network-error" + private val SHA256 = Regex("^[0-9a-f]{64}$") + private val ALLOWED_SNIFFED_TYPES = + setOf( + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "audio/ogg", + "video/mp4", + ) } + + private class BlockedResourceException( + message: String, + ) : java.io.IOException(message) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt index 66bce77ba8..201c818aa3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability internal fun NappletCapability.symbol(): MaterialSymbol = when (this) { - NappletCapability.SHELL -> MaterialSymbols.Tune NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle NappletCapability.KEYS -> MaterialSymbols.Key NappletCapability.RELAY -> MaterialSymbols.Public diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt index 9fc041b421..211ea1a7be 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope import com.vitorpamplona.quartz.nip01Core.core.Event import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull @@ -73,11 +74,6 @@ class NappletProtocolJsonTest { assertEquals(NappletRequest.GetPublicKey, NappletProtocolJson.decodeRequest("""{"type":"identity.getPublicKey","id":"1"}""")) } - @Test - fun decodesShellSupports() { - assertEquals(NappletRequest.ShellSupports("relay"), NappletProtocolJson.decodeRequest("""{"type":"shell.supports","id":"1","domain":"relay"}""")) - } - @Test fun decodesPublishFromAnUnsignedTemplateInTheEventField() { // @napplet/shim carries the unsigned template in the `event` field. The shell signs it. @@ -165,13 +161,22 @@ class NappletProtocolJsonTest { assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","key":"k"}""")) assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","key":"k","value":"v"}""")) assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","key":"k"}""")) - assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys"}""")) + assertEquals(NappletRequest.StorageKeys(), NappletProtocolJson.decodeRequest("""{"type":"storage.keys"}""")) + assertEquals( + NappletRequest.StorageGet("k", NappletStorageScope.INSTANCE), + NappletProtocolJson.decodeRequest("""{"type":"storage.get","key":"k","scope":"instance"}"""), + ) } @Test fun decodesValueResourceUpload() { assertEquals(NappletRequest.PayInvoice("lnbc1"), NappletProtocolJson.decodeRequest("""{"type":"value.payInvoice","invoice":"lnbc1"}""")) + assertEquals(NappletRequest.ResourceInfo, NappletProtocolJson.decodeRequest("""{"type":"resource.info"}""")) assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","url":"https://x"}""")) + assertEquals( + NappletRequest.ResourceBytesMany(listOf("https://x", "data:text/plain,hi")), + NappletProtocolJson.decodeRequest("""{"type":"resource.bytesMany","urls":["https://x","data:text/plain,hi"]}"""), + ) // "SGk=" is base64 for "Hi"; shell.html inlines the request Blob as request.dataBase64. val up = NappletProtocolJson.decodeRequest("""{"type":"upload.upload","request":{"dataBase64":"SGk=","mimeType":"text/plain","filename":"a.txt"}}""") as NappletRequest.UploadBlob assertEquals("text/plain", up.contentType) @@ -184,6 +189,7 @@ class NappletProtocolJsonTest { assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}""")) // keys.signEvent is not a real domain method (keys = keyboard actions, not signing). assertNull(NappletProtocolJson.decodeRequest("""{"type":"keys.signEvent","id":"1"}""")) + assertNull(NappletProtocolJson.decodeRequest("""{"type":"identity.futureMethod","id":"1"}""")) assertNull(NappletProtocolJson.decodeRequest("""{"foo":"bar"}""")) } @@ -211,7 +217,9 @@ class NappletProtocolJsonTest { assertEquals("s1", ev["subId"]?.jsonPrimitive?.content) assertEquals( "a".repeat(64), - ev["event"] + ev["result"] + ?.jsonObject + ?.get("event") ?.jsonObject ?.get("id") ?.jsonPrimitive @@ -233,13 +241,6 @@ class NappletProtocolJsonTest { assertEquals("pk", o["pubkey"]?.jsonPrimitive?.content) } - @Test - fun encodesSupported() { - val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("shell.supports", NappletResponse.Supported(true))).jsonObject - assertEquals("shell.supports.result", o["type"]?.jsonPrimitive?.content) - assertTrue(o["supported"]!!.jsonPrimitive.boolean) - } - @Test fun encodesPublishedEventAndEvents() { // relay.publish resolves to the signed event (matching upstream NostrEvent return). @@ -257,6 +258,18 @@ class NappletProtocolJsonTest { val events = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.query", NappletResponse.Events(listOf(sampleEvent())))).jsonObject assertEquals(1, events["events"]?.jsonArray?.size) + assertEquals( + "a".repeat(64), + events["events"] + ?.jsonArray + ?.first() + ?.jsonObject + ?.get("event") + ?.jsonObject + ?.get("id") + ?.jsonPrimitive + ?.content, + ) } @Test @@ -269,6 +282,71 @@ class NappletProtocolJsonTest { assertEquals(2, keys["keys"]?.jsonArray?.size) } + @Test + fun encodesResourceInfoBulkItemsAndTypedErrors() { + val info = + json + .parseToJsonElement( + NappletProtocolJson.encodeResponse( + "resource.info", + NappletResponse.ResourceInfo(listOf("https"), 10L * 1024L * 1024L, 16), + ), + ).jsonObject + assertEquals( + "https", + info["info"] + ?.jsonObject + ?.get("schemes") + ?.jsonArray + ?.first() + ?.jsonObject + ?.get("scheme") + ?.jsonPrimitive + ?.content, + ) + + val items = + json + .parseToJsonElement( + NappletProtocolJson.encodeResponse( + "resource.bytesMany", + NappletResponse.ResourceItems( + listOf( + NappletResponse.ResourceItem("https://x", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain")), + NappletResponse.ResourceItem("https://y", error = "not-found"), + ), + ), + ), + ).jsonObject["items"] + ?.jsonArray + assertEquals( + "SGk=", + items + ?.first() + ?.jsonObject + ?.get("bytes") + ?.jsonPrimitive + ?.content, + ) + assertEquals( + "not-found", + items + ?.get(1) + ?.jsonObject + ?.get("error") + ?.jsonPrimitive + ?.content, + ) + + val failure = + json + .parseToJsonElement( + NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.ResourceFailure("blocked-by-policy", "private target")), + ).jsonObject + assertEquals("resource.bytes.error", failure["type"]?.jsonPrimitive?.content) + assertEquals("blocked-by-policy", failure["error"]?.jsonPrimitive?.content) + } + @Test fun encodesBytesAsBase64WithMime() { val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain"))).jsonObject diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartextTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartextTest.kt new file mode 100644 index 0000000000..d2de5606d6 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartextTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class NappletRelayCleartextTest { + @Test + fun plaintextPassesThroughWithoutDecrypting() = + runTest { + val event = event(content = "hello") + val result = + NappletRelayCleartext.forDelivery(event, USER) { _, _ -> + error("plaintext must not be decrypted") + } + + assertSame(event, result) + } + + @Test + fun inboundNip04IsProjectedAsCleartext() = + runTest { + val event = event(content = NIP04, tags = arrayOf(arrayOf("p", USER))) + val result = + NappletRelayCleartext.forDelivery(event, USER) { ciphertext, peer -> + assertEquals(NIP04, ciphertext) + assertEquals(AUTHOR, peer) + "secret" + } + + assertEquals("secret", result?.content) + assertEquals(event.id, result?.id) + assertEquals(event.sig, result?.sig) + } + + @Test + fun outboundEncryptedEventUsesItsRecipientAsPeer() = + runTest { + val event = event(pubKey = USER, content = NIP04, tags = arrayOf(arrayOf("p", RECIPIENT))) + val result = + NappletRelayCleartext.forDelivery(event, USER) { _, peer -> + assertEquals(RECIPIENT, peer) + "sent secret" + } + + assertEquals("sent secret", result?.content) + } + + @Test + fun encryptedEventForAnotherUserIsWithheld() = + runTest { + val event = event(content = NIP04, tags = arrayOf(arrayOf("p", RECIPIENT))) + val result = + NappletRelayCleartext.forDelivery(event, USER) { _, _ -> + error("unrelated ciphertext must not be offered to the signer") + } + + assertNull(result) + } + + @Test + fun decryptionFailureWithholdsCiphertext() = + runTest { + val event = event(content = NIP04, tags = arrayOf(arrayOf("p", USER))) + val result = + NappletRelayCleartext.forDelivery(event, USER) { _, _ -> + error("signer refused") + } + + assertNull(result) + } + + private fun event( + pubKey: String = AUTHOR, + content: String, + tags: Array> = emptyArray(), + ) = Event("id", pubKey, 1L, 4, tags, content, "sig") + + companion object { + private const val USER = "user" + private const val AUTHOR = "author" + private const val RECIPIENT = "recipient" + private const val NIP04 = "ciphertext-that-is-long-enough?iv=123456789012345678901234" + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt index fe65a51e6c..82f99a1a98 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletSdkConformanceTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope import com.vitorpamplona.quartz.nip01Core.core.Event import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray @@ -114,17 +115,31 @@ class NappletSdkConformanceTest { // RelayQueryResultMessage: { type:'relay.query.result', id, events, error? } val o = result("relay.query", NappletResponse.Events(listOf(sampleEvent()))) assertEquals(1, o["events"]?.jsonArray?.size) + assertEquals( + "a".repeat(64), + o["events"] + ?.jsonArray + ?.first() + ?.jsonObject + ?.get("event") + ?.jsonObject + ?.get("id") + ?.jsonPrimitive + ?.content, + ) } @Test fun relayEventAndEosePushesMatchTheSdk() { - // RelayEventMessage (PUSH): { type:'relay.event', subId, event } + // RelayEventMessage (PUSH): { type:'relay.event', subId, result:{event, sidecar?} } val ev = json.parseToJsonElement(NappletProtocolJson.encodeRelayEvent("s1", sampleEvent())).jsonObject assertEquals("relay.event", ev["type"]?.jsonPrimitive?.content) assertEquals("s1", ev["subId"]?.jsonPrimitive?.content) assertEquals( "a".repeat(64), - ev["event"] + ev["result"] + ?.jsonObject + ?.get("event") ?.jsonObject ?.get("id") ?.jsonPrimitive @@ -175,7 +190,11 @@ class NappletSdkConformanceTest { assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","id":"1","key":"k"}""")) assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","id":"1","key":"k","value":"v"}""")) assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","id":"1","key":"k"}""")) - assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys","id":"1"}""")) + assertEquals(NappletRequest.StorageKeys(), NappletProtocolJson.decodeRequest("""{"type":"storage.keys","id":"1"}""")) + assertEquals( + NappletRequest.StorageGet("k", NappletStorageScope.INSTANCE), + NappletProtocolJson.decodeRequest("""{"type":"storage.get","id":"1","key":"k","scope":"instance"}"""), + ) // StorageGetResultMessage.value, StorageKeysResultMessage.keys assertTrue(result("storage.get", NappletResponse.StorageValue("v")).containsKey("value")) @@ -186,7 +205,12 @@ class NappletSdkConformanceTest { @Test fun resourceBytesRequestAndResultMatch() { + assertEquals(NappletRequest.ResourceInfo, NappletProtocolJson.decodeRequest("""{"type":"resource.info","id":"0"}""")) assertEquals(NappletRequest.ResourceBytes("https://x"), NappletProtocolJson.decodeRequest("""{"type":"resource.bytes","id":"1","url":"https://x"}""")) + assertEquals( + NappletRequest.ResourceBytesMany(listOf("https://x", "data:text/plain,hi")), + NappletProtocolJson.decodeRequest("""{"type":"resource.bytesMany","id":"2","urls":["https://x","data:text/plain,hi"]}"""), + ) // The host emits base64 bytes + mime; shell.html rebuilds the Blob the SDK expects. val o = result("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain")) assertEquals("SGk=", o["bytes"]?.jsonPrimitive?.content) @@ -203,29 +227,6 @@ class NappletSdkConformanceTest { assertTrue(result("relay.query", NappletResponse.Failed("boom")).containsKey("error")) } - // ---------- shell handshake (ShellReadyMessage / ShellInitMessage) ---------- - - @Test - fun shellInitAdvertisesTheCapabilityEnvironment() { - // shell.ready is answered by the host (not the codec) with this shell.init env, which the - // SDK caches and answers shell.supports() from locally. ShellInitMessage: - // { type:'shell.init', capabilities:{ domains, protocols }, services }. - val o = json.parseToJsonElement(NappletProtocolJson.encodeShellInit(listOf("shell", "relay"), listOf("shell", "relay"))).jsonObject - assertEquals("shell.init", o["type"]?.jsonPrimitive?.content) - assertEquals( - 2, - o["capabilities"] - ?.jsonObject - ?.get("domains") - ?.jsonArray - ?.size, - ) - assertTrue(o["capabilities"]?.jsonObject?.containsKey("protocols") == true) - assertEquals(2, o["services"]?.jsonArray?.size) - // shell.ready stays a host-layer message — the codec doesn't treat it as a broker request. - assertNull(NappletProtocolJson.decodeRequest("""{"type":"shell.ready"}""")) - } - // ---------- keys (keyboard/command actions) ---------- @Test diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcherPolicyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcherPolicyTest.kt new file mode 100644 index 0000000000..5f8643317d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcherPolicyTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet.gateways + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.InetAddress + +class NappletResourceFetcherPolicyTest { + @Test + fun blocksPrivateSpecialAndLocalAddresses() { + val blocked = + listOf( + "0.0.0.0", + "10.0.0.1", + "100.64.0.1", + "127.0.0.1", + "169.254.169.254", + "172.16.0.1", + "192.0.0.1", + "192.0.2.1", + "192.168.1.1", + "198.18.0.1", + "198.51.100.1", + "203.0.113.1", + "224.0.0.1", + "::1", + "2001:db8::1", + "fc00::1", + "fe80::1", + ) + + blocked.forEach { + assertFalse(it, NappletResourceFetcher.isPublicAddress(InetAddress.getByName(it))) + } + } + + @Test + fun permitsPublicAddresses() { + assertTrue(NappletResourceFetcher.isPublicAddress(InetAddress.getByName("1.1.1.1"))) + assertTrue(NappletResourceFetcher.isPublicAddress(InetAddress.getByName("2606:4700:4700::1111"))) + } + + @Test + fun acceptsOnlyCredentialFreeHttpsUrls() { + assertTrue(NappletResourceFetcher.isSafeHttpsResourceUrl("https://example.com/a")) + assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("http://example.com/a")) + assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("https://user:secret@example.com/a")) + assertFalse(NappletResourceFetcher.isSafeHttpsResourceUrl("file:///etc/passwd")) + } +} diff --git a/commons/src/commonMain/composeResources/files/napplet/shell.html b/commons/src/commonMain/composeResources/files/napplet/shell.html index 78e42c56c3..e7658bad3e 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shell.html +++ b/commons/src/commonMain/composeResources/files/napplet/shell.html @@ -1,13 +1,10 @@ @@ -19,7 +16,7 @@ - + diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js index 89e256a2f4..6ccd099cac 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shim.js +++ b/commons/src/commonMain/composeResources/files/napplet/shim.js @@ -79,7 +79,7 @@ // Subscription pushes are keyed by subId, not a request id. if (msg.type === 'relay.event' || msg.type === 'relay.eose' || msg.type === 'relay.closed') { var sub = subs[msg.subId]; if (!sub) return; - if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.event); } + if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.result); } else if (msg.type === 'relay.eose') { if (sub.onEose) sub.onEose(); } else { delete subs[msg.subId]; if (sub.onClosed) sub.onClosed(msg.reason); } return; @@ -91,7 +91,7 @@ // identity.changed push: the active user's key changed (account switch / connect / disconnect). if (msg.type === 'identity.changed') { identityHandlers.slice().forEach(function(h){ try { h(msg.pubkey); } catch (_) {} }); return; } if (!msg.id) return; - var p = pending[msg.id]; if (!p) return; delete pending[msg.id]; + var p = pending[msg.id]; if (!p) return; delete pending[msg.id]; if (p.cleanup) p.cleanup(); if (msg.ok) p.resolve(msg); else { var err = new Error(msg.reason || msg.operation || msg.error || 'napplet error'); err.napplet = msg; p.reject(err); } } @@ -101,16 +101,31 @@ window.addEventListener('message', function(e){ if (e.source !== parent) return; onIncoming(e.data); }); } function field(promise, name){ return promise.then(function(m){ return m[name]; }); } - function normFilters(filters){ return Array.isArray(filters) ? { filters: filters } : { filter: filters || {} }; } + function resourceCall(type, fields, opts){ + var signal = opts && opts.signal; + if (signal && signal.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError')); + return new Promise(function(resolve, reject){ + var env = { type: type }; for (var k in fields) env[k] = fields[k]; + var id = send(env), onAbort; + var cleanup = function(){ if (signal && onAbort) signal.removeEventListener('abort', onAbort); }; + pending[id] = { resolve: resolve, reject: reject, cleanup: cleanup }; + if (signal) { + onAbort = function(){ + if (!pending[id]) return; + delete pending[id]; cleanup(); + post('resource.cancel', { id: id }); + reject(new DOMException('Aborted', 'AbortError')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + } + }); + } + function normFilters(filters){ return { filters: Array.isArray(filters) ? filters : [filters || {}] }; } function bytesToB64(bytes){ var u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); var s=''; for (var i=0;i= 0) identityHandlers.splice(i, 1); - if (identityHandlers.length === 0) post('identity.unwatch'); } }; } }, @@ -152,6 +164,7 @@ var subId = 's' + (seq++); subs[subId] = { onEvent: onEvent, onEose: onEose }; var env = normFilters(filters); env.subId = subId; + if (options && options.relay) env.relay = options.relay; post('relay.subscribe', env); return { close: function(){ delete subs[subId]; post('relay.close', { subId: subId }); } }; } @@ -161,23 +174,49 @@ getItem: function(key){ return field(call('storage.get', { key: key }), 'value'); }, setItem: function(key, value){ return call('storage.set', { key: key, value: value }).then(function(){}); }, removeItem: function(key){ return call('storage.remove', { key: key }).then(function(){}); }, - keys: function(){ return field(call('storage.keys'), 'keys'); } + keys: function(){ return field(call('storage.keys'), 'keys'); }, + instance: { + getItem: function(key){ return field(call('storage.get', { key: key, scope: 'instance' }), 'value'); }, + setItem: function(key, value){ return call('storage.set', { key: key, value: value, scope: 'instance' }).then(function(){}); }, + removeItem: function(key){ return call('storage.remove', { key: key, scope: 'instance' }).then(function(){}); }, + keys: function(){ return field(call('storage.keys', { scope: 'instance' }), 'keys'); } + } }, // value.payInvoice is an Amethyst-specific extension (not part of @napplet/shim). value: { payInvoice: function(invoice){ return field(call('value.payInvoice', { invoice: invoice }), 'preimage'); } }, resource: { - // The shell rebuilds the Blob from the host's base64 before this resolves. - bytes: function(url){ return field(call('resource.bytes', { url: url }), 'blob'); }, - bytesAsObjectURL: function(url){ return field(call('resource.bytes', { url: url }), 'blob').then(function(blob){ return URL.createObjectURL(blob); }); } + info: function(){ return field(call('resource.info'), 'info'); }, + // The shell rebuilds Blobs from the host's base64 before these resolve. + bytes: function(url, opts){ return field(resourceCall('resource.bytes', { url: url }, opts), 'blob'); }, + bytesMany: function(urls, opts){ return field(resourceCall('resource.bytesMany', { urls: Array.from(urls || []) }, opts), 'items'); }, + bytesAsObjectURL: function(url){ + var objectUrl = '', revoked = false; + var handle = { url: '', revoke: function(){ if (revoked) return; revoked = true; if (objectUrl) URL.revokeObjectURL(objectUrl); } }; + var ready = available.resource.bytes(url).then(function(blob){ + if (revoked) return; + objectUrl = URL.createObjectURL(blob); handle.url = objectUrl; return objectUrl; + }); + Object.defineProperty(handle, 'ready', { value: ready, enumerable: false }); + return handle; + } }, upload: { // Sends the SDK's upload.upload; we inline the bytes as base64 (shell.html does the same for // a Blob from a stock napplet). Resolves to the uploaded URL. blob: function(bytes, contentType){ return field(call('upload.upload', { request: { dataBase64: bytesToB64(bytes), mimeType: contentType } }), 'url'); } + }, + theme: { + get: function(){ return field(call('theme.get'), 'theme'); } } }; + var requested = []; + try { if (Array.isArray(window.__nappletDomains)) requested = window.__nappletDomains; } catch (_) {} + var napplet = {}; + requested.forEach(function(domain){ + if (typeof domain === 'string' && Object.prototype.hasOwnProperty.call(available, domain)) napplet[domain] = available[domain]; + }); window.napplet = Object.freeze(napplet); // ---- IME agent (in-app browser only) ------------------------------------------------------- diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicy.kt new file mode 100644 index 0000000000..8cc23cdada --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicy.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag + +/** Pure NIP-5D artifact checks shared by every launch surface. */ +object NappletArtifactPolicy { + /** Returns the runtime-computed artifact identity, or null when the manifest must not execute. */ + fun verifiedAggregateHash( + paths: List, + declaredAggregateHash: HexKey?, + ): HexKey? { + val entry = paths.singleOrNull() ?: return null + if (entry.path != "/index.html" || !SHA256.matches(entry.hash)) return null + val computed = SiteAggregateHash.compute(paths) + if (declaredAggregateHash != null && !declaredAggregateHash.equals(computed, ignoreCase = true)) return null + return computed + } + + private val SHA256 = Regex("^[0-9a-fA-F]{64}$") +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 48800d865e..0a9a923848 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -122,12 +122,6 @@ class NappletBroker( ): NappletResponse { val capability = request.capability - // shell.supports is capability negotiation: always answerable, no declaration/consent. - if (request is NappletRequest.ShellSupports) { - val cap = NappletCapability.fromNapDomain(request.domain) - return NappletResponse.Supported(cap != null && cap in declared) - } - if (capability !in declared) { return NappletResponse.Denied(capability, "This napplet did not declare the '${capability.name.lowercase()}' capability.") } @@ -149,7 +143,7 @@ class NappletBroker( // Keyboard/command action registration is a shell-mediated UI affordance, not key // access — declared is enough; it never prompts. request is NappletRequest.RegisterAction || request is NappletRequest.UnregisterAction -> true - // Cosmetic/negotiation capabilities (theme) never prompt. + // Cosmetic capabilities (theme) never prompt. !capability.requiresConsent -> true // A standing allow short-circuits, except for per-use capabilities (e.g. payments). ledger.decide(identity, capability) == PermissionDecision.ALLOW && !capability.requiresPerUseConsent -> true @@ -219,9 +213,6 @@ class NappletBroker( request: NappletRequest, ): NappletResponse = when (request) { - // Negotiation is resolved in handle(); execute() is never reached for it. - is NappletRequest.ShellSupports -> NappletResponse.Supported(true) - is NappletRequest.GetPublicKey -> NappletResponse.PublicKey(signer.pubKey) is NappletRequest.ThemeGet -> { @@ -267,24 +258,24 @@ class NappletBroker( is NappletRequest.StorageGet -> { val store = storage ?: return NappletResponse.Unsupported("storage.getItem") - NappletResponse.StorageValue(store.get(identity.coordinate, request.key)) + NappletResponse.StorageValue(store.get(storageCoordinate(identity, request.scope), request.key)) } is NappletRequest.StorageSet -> { val store = storage ?: return NappletResponse.Unsupported("storage.setItem") - store.set(identity.coordinate, request.key, request.value) + store.set(storageCoordinate(identity, request.scope), request.key, request.value) NappletResponse.Done } is NappletRequest.StorageRemove -> { val store = storage ?: return NappletResponse.Unsupported("storage.removeItem") - store.remove(identity.coordinate, request.key) + store.remove(storageCoordinate(identity, request.scope), request.key) NappletResponse.Done } is NappletRequest.StorageKeys -> { val store = storage ?: return NappletResponse.Unsupported("storage.keys") - NappletResponse.Strings(store.keys(identity.coordinate)) + NappletResponse.Strings(store.keys(storageCoordinate(identity, request.scope))) } is NappletRequest.NotifyCreate -> { @@ -316,8 +307,38 @@ class NappletBroker( is NappletRequest.ResourceBytes -> { val gateway = resource ?: return NappletResponse.Unsupported("resource.bytes") - val fetched = gateway.fetch(request.url, identity.coordinate) ?: return NappletResponse.Failed("Could not fetch the resource.") - NappletResponse.Bytes(fetched.bytes, fetched.contentType) + when (val fetched = gateway.fetch(request.url, identity.coordinate)) { + is NappletResourceResult.Success -> NappletResponse.Bytes(fetched.resource.bytes, fetched.resource.contentType) + is NappletResourceResult.Failure -> NappletResponse.ResourceFailure(fetched.error, fetched.message) + } + } + + is NappletRequest.ResourceInfo -> { + resource ?: return NappletResponse.Unsupported("resource.info") + NappletResponse.ResourceInfo( + schemes = listOf("data", "https", "blossom", "nostr"), + maxBytes = RESOURCE_MAX_BYTES, + maxUrls = RESOURCE_MAX_URLS, + ) + } + + is NappletRequest.ResourceBytesMany -> { + val gateway = resource ?: return NappletResponse.Unsupported("resource.bytesMany") + if (request.urls.isEmpty()) return NappletResponse.ResourceFailure("invalid-request", "Resource URL list is empty.") + if (request.urls.size > RESOURCE_MAX_URLS) return NappletResponse.ResourceFailure("too-large", "Resource URL limit exceeded.") + NappletResponse.ResourceItems( + request.urls.map { url -> + when (val fetched = gateway.fetch(url, identity.coordinate)) { + is NappletResourceResult.Success -> + NappletResponse.ResourceItem( + url = url, + resource = NappletResponse.Bytes(fetched.resource.bytes, fetched.resource.contentType), + ) + is NappletResourceResult.Failure -> + NappletResponse.ResourceItem(url = url, error = fetched.error, message = fetched.message) + } + }, + ) } is NappletRequest.UploadBlob -> { @@ -353,6 +374,15 @@ class NappletBroker( tags + arrayOf(arrayOf("p", recipient)) } + private fun storageCoordinate( + identity: NappletIdentity, + scope: com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope, + ): String = + when (scope) { + com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.SHARED -> identity.storageCoordinate + com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.INSTANCE -> identity.instanceStorageCoordinate + } + /** * Shows the first-connect "Connect to Nostr" dialog if no signer policy exists yet. * On success, stores the chosen policy and bulk-grants all declared non-payment capabilities. @@ -505,16 +535,6 @@ class NappletBroker( } } - /** - * True when [capability] carries a standing denial for [identity]. Push-subscription edge ops - * (identity.watch and friends) short-circuit before [handle], so they have to apply the same - * "a standing denial always wins" rule themselves rather than trusting the declaration alone. - */ - suspend fun isDenied( - identity: NappletIdentity, - capability: NappletCapability, - ): Boolean = ledger.decide(identity, capability) == PermissionDecision.DENY - companion object { /** * How long (ms) a Cancel on the first-connect dialog suppresses re-prompting for the same app. @@ -522,5 +542,7 @@ class NappletBroker( * the dialog per request; short enough that a deliberate user retry seconds later prompts again. */ private const val CANCEL_REPROMPT_COOLDOWN_MS = 3_000L + private const val RESOURCE_MAX_BYTES = 10L * 1024L * 1024L + private const val RESOURCE_MAX_URLS = 16 } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt index e229884068..27b84f70de 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt @@ -156,6 +156,17 @@ class NappletResource( val contentType: String, ) +sealed interface NappletResourceResult { + data class Success( + val resource: NappletResource, + ) : NappletResourceResult + + data class Failure( + val error: String, + val message: String? = null, + ) : NappletResourceResult +} + /** * Bridges the broker to sandboxed resource fetching for [NappletCapability.RESOURCE] * (`resource.bytes`). The host fetches https/blossom/nostr/data URLs on the applet's behalf — @@ -164,13 +175,14 @@ class NappletResource( * * [coordinate] is the calling applet's identity coordinate (`author:identifier`), so the host can * route the fetch the same way the applet's own page loads — through Tor or the open web — per that - * applet's/site's network mode. + * applet's/site's network mode. Failures carry the stable NAP-RESOURCE error code rather than + * collapsing policy rejections and network failures into one nullable result. */ fun interface NappletResourceGateway { suspend fun fetch( url: String, coordinate: String, - ): NappletResource? + ): NappletResourceResult } /** A completed upload: where the blob lives plus NIP-94-ish metadata. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt index a781dc7bc1..33ee94cf7c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt @@ -25,14 +25,11 @@ package com.vitorpamplona.amethyst.commons.napplet * (`napplet/naps`, `@napplet/web`). A napplet declares the domains it needs via `requires` tags; * [fromNapDomain] maps each bare domain string to the capability the broker enforces. * - * The mapping is **default-deny**: an unrecognized NAP domain maps to `null` and the shell must - * surface it as unknown rather than silently granting it. Domains we don't yet broker - * (`intent`, `media`, `config`, `outbox`, `ifc`, `cvm`) therefore resolve to `null` for now. + * The mapping is **default-deny**: an unrecognized or only partially implemented NAP domain maps + * to `null`. Keeping a broker implementation below does not advertise conformance; only domains + * whose current NAP contract is implemented are injected into `window.napplet`. */ enum class NappletCapability { - /** `shell` — capability negotiation (`shell.supports`). Always available; needs no consent. */ - SHELL, - /** `identity` — read-only identity queries (`getPublicKey`, `onChanged`). */ IDENTITY, @@ -70,13 +67,13 @@ enum class NappletCapability { ; /** - * Whether using this capability requires user consent. Negotiation ([SHELL]) and the cosmetic, - * read-only theme read ([THEME]) never prompt; everything else does (subject to the broker's + * Whether using this capability requires user consent. The cosmetic, read-only theme read + * ([THEME]) never prompts; everything else does (subject to the broker's * signer-self-gating and standing-grant rules). [INC] is authorized at the router edge on its * declaration alone, so it never reaches the consent path regardless of this flag. */ val requiresConsent: Boolean - get() = this != SHELL && this != THEME + get() = this != THEME /** * Whether the user must confirm **every single use** — no standing auto-approval. True for @@ -96,25 +93,22 @@ enum class NappletCapability { companion object { /** - * Maps a bare NAP domain to the capability the broker enforces, case-insensitively. - * Returns `null` for any domain the shell does not recognize — callers MUST treat that as - * "unknown, do not grant". + * Maps a bare, currently supported NAP domain to the capability the broker enforces. + * Returns `null` for unknown and partial/legacy domains — callers MUST treat that as + * "unavailable, do not inject or grant". NIP-5D domain names are exact lowercase strings. */ fun fromNapDomain(domain: String): NappletCapability? = - when (domain.trim().lowercase()) { - "shell" -> SHELL + when (domain) { "identity" -> IDENTITY - "keys" -> KEYS - "relay", "relays" -> RELAY + "relay" -> RELAY "storage" -> STORAGE - "value" -> VALUE "resource" -> RESOURCE - "upload" -> UPLOAD "theme" -> THEME - "notify" -> NOTIFY - "inc" -> INC else -> null } + + /** Exact NIP-5D domain names Amethyst currently exposes through its injection prelude. */ + val supportedNapDomains: Set = setOf("identity", "relay", "storage", "resource", "theme") } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentity.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentity.kt index a543bf505f..9d2a759ab6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentity.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentity.kt @@ -41,7 +41,15 @@ data class NappletIdentity( val authorPubKey: HexKey, val identifier: String, val aggregateHash: HexKey? = null, + /** Opaque host-assigned lifetime id used only for NAP-STORAGE's instance scope. */ + val instanceId: String? = null, ) { /** The ledger key: coordinate only, never the [aggregateHash], so grants survive updates. */ val coordinate: String = "$authorPubKey:$identifier" + + /** NAP-STORAGE shared namespace: exact publisher + dTag + verified artifact identity. */ + val storageCoordinate: String = "$coordinate:${aggregateHash.orEmpty()}" + + /** NAP-STORAGE instance namespace, stable for this host launch and isolated from sibling launches. */ + val instanceStorageCoordinate: String = "$storageCoordinate:instance:${instanceId.orEmpty()}" } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt index 74f960c331..43d780edf3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt @@ -41,33 +41,19 @@ object NappletWebContract { const val SHELL_URL = "$ORIGIN/__shell__" /** - * The applet runs on its **own per-applet origin** — a unique subdomain of [HOST] — served at the - * origin root, NOT on the shell [ORIGIN]. Two reasons, both load-bearing: - * - * 1. **A real (non-opaque) origin is what gives the applet working, persistent storage.** An - * `allow-scripts`-only opaque-origin iframe has no `localStorage`/`IndexedDB`/service worker - * (reads throw `SecurityError`), which crash-loops essentially every SPA. A real origin with - * `allow-same-origin` has them, scoped and isolated per applet (subdomains don't share - * storage), so applets can't read each other's data. - * 2. **Keeping it on a DISTINCT origin from the shell is what preserves the trust boundary.** The - * native bridge is origin-restricted to the shell [ORIGIN]; the applet, being cross-origin, - * still can't reach it (nor read the shell DOM) — it talks only via `postMessage`, which the - * shell relays. `allow-same-origin` is therefore safe here precisely because the applet is - * same-origin only with *itself*, never with the shell. - * - * The applet is served at its origin root because SPA bundlers (Vite, CRA, webpack, nsyte, …) emit - * **absolute** asset URLs (`/assets/app.js`, `/fonts/x.woff2`) that resolve against the origin root. - * - * [appId] must be a stable, unique, DNS-label-safe token per applet (the host derives it from the - * applet's author + identifier), so the same applet keeps its storage across launches. + * Per-site origin retained for Amethyst's NIP-5A WEBSITE profile. NIP-5D napplets never navigate + * here: their verified, self-contained `/index.html` is assigned through `srcdoc` and therefore + * executes with an opaque origin in an `allow-scripts`-only sandbox. */ fun appOrigin(appId: String): String = "https://$appId.$HOST" /** True for the shell host and any per-applet subdomain — i.e. everything we serve internally. */ fun isInternalHost(host: String?): Boolean = host == HOST || (host != null && host.endsWith(".$HOST")) - /** Placeholder in [SHELL_HTML_PATH] the host replaces with the per-applet [appOrigin] before serving. */ + /** Placeholders in [SHELL_HTML_PATH] replaced by the host before serving the trusted shell. */ const val APP_ORIGIN_PLACEHOLDER = "__APP_ORIGIN__" + const val APP_SANDBOX_PLACEHOLDER = "__APP_SANDBOX__" + const val APP_BOOTSTRAP_PLACEHOLDER = "__APP_BOOTSTRAP__" /** Name of the origin-restricted native bridge the shell (and only the shell) can reach. */ const val BRIDGE_NAME = "__nappletBridge" @@ -76,24 +62,72 @@ object NappletWebContract { * CSP for the shell document: it may inline its own bridge script/style and frame **only this * applet's** origin, but has no network and cannot navigate or submit anywhere. */ - fun shellCsp(appOrigin: String): String = + fun shellCsp(frameSource: String): String = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + - "frame-src $appOrigin; base-uri 'none'; form-action 'none'" + "frame-src $frameSource; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" /** - * CSP for the applet document. The applet has a real origin now, so `'self'` resolves to its own - * per-applet origin and the shell origin is deliberately NOT granted. The key lever is - * `connect-src 'none'`: the applet gets **no** direct network — every fetch goes through the - * brokered, consent-gated `resource.bytes`. + * Conservative NIP-5D CSP injected as the first element of the verified napplet's `head` before + * the runtime prelude. A `srcdoc` napplet has an opaque origin, so self-hosted subresources are + * intentionally unavailable; a conforming napplet is one self-contained `/index.html`. */ const val APP_CSP: String = - "default-src 'self'; " + - "script-src 'self' 'unsafe-inline'; " + - "style-src 'self' 'unsafe-inline'; " + - "img-src 'self' data: blob:; " + - "font-src 'self' data:; " + - "media-src 'self' blob: data:; " + - "connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'" + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + + "img-src data: blob:; font-src data:; connect-src 'none'; worker-src 'none'; " + + "child-src 'none'; frame-src 'none'; media-src 'none'; object-src 'none'; " + + "manifest-src 'none'; base-uri 'none'; form-action 'none'" + + /** + * Injects host-owned policy and the runtime prelude before any authored element in `head`. + * [locked] is the NIP-5D posture; WEBSITE callers deliberately retain Amethyst's NIP-07 and + * normal-origin behavior. Only syntactically valid, explicitly authorized NAP domains are + * projected onto `window.napplet` by the trusted [shimJs]. + */ + fun injectPrelude( + html: ByteArray, + shimJs: String, + declaredDomains: List, + locked: Boolean, + injectNip07: Boolean = false, + imeProxy: Boolean = false, + ): ByteArray { + val text = html.decodeToString() + val policy = + if (locked) { + "" + } else { + "" + } + val style = "" + val flags = + "" + val safeDomains = + declaredDomains + .filter { it.matches(NAP_DOMAIN) && it in NappletCapability.supportedNapDomains } + .distinct() + val domainsJson = safeDomains.joinToString(prefix = "[", postfix = "]") { "\"$it\"" } + val prelude = "$policy$style$flags" + val headIdx = text.indexOf("= 0 -> { + val close = text.indexOf('>', headIdx) + if (close >= 0) text.substring(0, close + 1) + prelude + text.substring(close + 1) else prelude + text + } + else -> { + val htmlIdx = text.indexOf("= 0) text.indexOf('>', htmlIdx) else -1 + if (htmlClose >= 0) { + text.substring(0, htmlClose + 1) + "$prelude" + text.substring(htmlClose + 1) + } else { + "$prelude$text" + } + } + } + return injected.encodeToByteArray() + } const val SHELL_HTML_PATH = "files/napplet/shell.html" const val SHIM_JS_PATH = "files/napplet/shim.js" @@ -114,4 +148,6 @@ object NappletWebContract { /** The `window.napplet` client shim a host injects into the applet document. */ @OptIn(ExperimentalResourceApi::class) suspend fun shimJs(): ByteArray = Res.readBytes(SHIM_JS_PATH) + + private val NAP_DOMAIN = Regex("^[a-z][a-z0-9-]*$") } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt index d85925650f..25a58bca43 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt @@ -24,6 +24,11 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +enum class NappletStorageScope { + SHARED, + INSTANCE, +} + /** * A capability request from a napplet, after it has crossed the postMessage + IPC edge * and been deserialized into a typed object. Each variant declares the [capability] the @@ -68,14 +73,6 @@ sealed interface NappletRequest { override val capability get() = NappletCapability.THEME } - /** `shell.supports(domain, protocol?)` — capability negotiation; always answerable, no consent. */ - data class ShellSupports( - val domain: String, - val protocol: String? = null, - ) : NappletRequest { - override val capability get() = NappletCapability.SHELL - } - /** * Publish an event built from an **unsigned template**. The napplet supplies only `kind`, * `tags`, and `content`; the shell sets `pubkey` from the real signer, stamps `created_at`, @@ -198,6 +195,7 @@ sealed interface NappletRequest { /** Read a value from this napplet's sandboxed key-value store (`storage.getItem`). */ data class StorageGet( val key: String, + val scope: NappletStorageScope = NappletStorageScope.SHARED, ) : NappletRequest { override val capability get() = NappletCapability.STORAGE } @@ -206,6 +204,7 @@ sealed interface NappletRequest { data class StorageSet( val key: String, val value: String, + val scope: NappletStorageScope = NappletStorageScope.SHARED, ) : NappletRequest { override val capability get() = NappletCapability.STORAGE } @@ -213,12 +212,15 @@ sealed interface NappletRequest { /** Remove a value from this napplet's sandboxed key-value store (`storage.removeItem`). */ data class StorageRemove( val key: String, + val scope: NappletStorageScope = NappletStorageScope.SHARED, ) : NappletRequest { override val capability get() = NappletCapability.STORAGE } /** List the keys this napplet has stored (`storage.keys`). */ - data object StorageKeys : NappletRequest { + data class StorageKeys( + val scope: NappletStorageScope = NappletStorageScope.SHARED, + ) : NappletRequest { override val capability get() = NappletCapability.STORAGE } @@ -282,6 +284,18 @@ sealed interface NappletRequest { override val capability get() = NappletCapability.RESOURCE } + /** Describe the bounded schemes and limits of this shell's existing resource broker. */ + data object ResourceInfo : NappletRequest { + override val capability get() = NappletCapability.RESOURCE + } + + /** Fetch several resources in input order, returning a per-URL success/error record. */ + data class ResourceBytesMany( + val urls: List, + ) : NappletRequest { + override val capability get() = NappletCapability.RESOURCE + } + /** Upload a blob to the user's Blossom server (`upload.upload`). */ data class UploadBlob( val bytes: ByteArray, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt index 63ad63afff..07f0394b0c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt @@ -51,11 +51,6 @@ sealed interface NappletResponse { val events: List, ) : NappletResponse - /** Result of `shell.supports(domain)`. */ - data class Supported( - val supported: Boolean, - ) : NappletResponse - /** Result of `keys.registerAction`: the shell-assigned [actionId] and the [binding] it honored (e.g. `"Ctrl+S"`). */ data class ActionRegistered( val actionId: String, @@ -97,6 +92,28 @@ sealed interface NappletResponse { override fun hashCode(): Int = 31 * contentType.hashCode() + bytes.contentHashCode() } + data class ResourceInfo( + val schemes: List, + val maxBytes: Long, + val maxUrls: Int, + ) : NappletResponse + + data class ResourceItem( + val url: String, + val resource: Bytes? = null, + val error: String? = null, + val message: String? = null, + ) + + data class ResourceItems( + val items: List, + ) : NappletResponse + + data class ResourceFailure( + val error: String, + val message: String? = null, + ) : NappletResponse + /** Result of an `upload.upload`; [url] is where the blob can be fetched, plus NIP-94-ish metadata. */ data class Uploaded( val url: String, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicyTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicyTest.kt new file mode 100644 index 0000000000..a079eeab6d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletArtifactPolicyTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet + +import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NappletArtifactPolicyTest { + private val htmlHash = "11".repeat(32) + private val index = PathTag("/index.html", htmlHash) + + @Test + fun computesIdentityFromTheSignedSingleIndexPath() { + assertEquals( + SiteAggregateHash.compute(listOf(index)), + NappletArtifactPolicy.verifiedAggregateHash(listOf(index), null), + ) + } + + @Test + fun acceptsMatchingDeclaredIdentityCaseInsensitively() { + val computed = SiteAggregateHash.compute(listOf(index)) + assertEquals(computed, NappletArtifactPolicy.verifiedAggregateHash(listOf(index), computed.uppercase())) + } + + @Test + fun rejectsDriftedOrNonSelfContainedArtifacts() { + assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(index), "22".repeat(32))) + assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(PathTag("index.html", htmlHash)), null)) + assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(index, PathTag("/app.js", "22".repeat(32))), null)) + assertNull(NappletArtifactPolicy.verifiedAggregateHash(listOf(PathTag("/index.html", "not-a-sha256")), null)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index 0b5bcca09f..fc59bb7f87 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -182,9 +182,18 @@ class NappletBrokerTest { relay: NappletRelayGateway? = null, storage: NappletStorage? = null, wallet: NappletWalletGateway? = null, + resource: NappletResourceGateway? = null, signer: NostrSigner = this.signer, ledger: NappletPermissionLedger = NappletPermissionLedger(InMemoryNappletPermissionStore()), - ) = NappletBroker(signer, ledger, prompt, relay, storage, wallet) + ) = NappletBroker( + signer = signer, + ledger = ledger, + consentPrompt = prompt, + relay = relay, + storage = storage, + wallet = wallet, + resource = resource, + ) @Test fun getPublicKeyReturnsTheUsersKeyWhenAllowed() = @@ -545,8 +554,8 @@ class NappletBrokerTest { assertEquals(NappletResponse.Done, broker.handle(applet, NappletRequest.StorageSet("k", "v"), allDeclared)) assertEquals(NappletResponse.StorageValue("v"), broker.handle(applet, NappletRequest.StorageGet("k"), allDeclared)) - // Stored under the applet coordinate, never a shared namespace. - assertEquals("v", storage.data["${applet.coordinate}::k"]) + // Shared storage is scoped to the verified artifact identity, not just the d-tag. + assertEquals("v", storage.data["${applet.storageCoordinate}::k"]) assertEquals(NappletResponse.Done, broker.handle(applet, NappletRequest.StorageRemove("k"), allDeclared)) assertEquals(NappletResponse.StorageValue(null), broker.handle(applet, NappletRequest.StorageGet("k"), allDeclared)) @@ -563,7 +572,7 @@ class NappletBrokerTest { broker.handle(applet, NappletRequest.StorageSet("b", "2"), allDeclared) broker.handle(other, NappletRequest.StorageSet("c", "3"), allDeclared) - val response = broker.handle(applet, NappletRequest.StorageKeys, allDeclared) + val response = broker.handle(applet, NappletRequest.StorageKeys(), allDeclared) assertIs(response) assertEquals(setOf("a", "b"), response.values.toSet()) // never sees the other applet's "c" } @@ -643,19 +652,6 @@ class NappletBrokerTest { assertIs(response) } - @Test - fun shellSupportsReflectsDeclaredCapabilitiesWithoutConsent() = - runTest { - // The DENY prompt would block anything that reached consent; supports must not. - val broker = broker(ScriptedPrompt(GrantState.DENY)) - val declared = setOf(NappletCapability.RELAY) - - assertEquals(NappletResponse.Supported(true), broker.handle(applet, NappletRequest.ShellSupports("relay"), declared)) - assertEquals(NappletResponse.Supported(false), broker.handle(applet, NappletRequest.ShellSupports("storage"), declared)) - // Unknown/unbrokered domain. - assertEquals(NappletResponse.Supported(false), broker.handle(applet, NappletRequest.ShellSupports("cvm"), declared)) - } - @Test fun identityReadReturnsGatewayJsonOrUnsupported() = runTest { @@ -693,4 +689,60 @@ class NappletBrokerTest { assertIs(broker.handle(applet, NappletRequest.ResourceBytes("https://x"), allDeclared)) assertIs(broker.handle(applet, NappletRequest.UploadBlob(ByteArray(0), "image/png"), allDeclared)) } + + @Test + fun resourceInfoAndTypedFailuresFollowTheCurrentNapContract() = + runTest { + val resource = + NappletResourceGateway { _, _ -> + NappletResourceResult.Failure("blocked-by-policy", "private target") + } + val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource) + + val info = broker.handle(applet, NappletRequest.ResourceInfo, allDeclared) + assertIs(info) + assertEquals(listOf("data", "https", "blossom", "nostr"), info.schemes) + assertEquals(10L * 1024L * 1024L, info.maxBytes) + + assertEquals( + NappletResponse.ResourceFailure("blocked-by-policy", "private target"), + broker.handle(applet, NappletRequest.ResourceBytes("https://internal.example"), allDeclared), + ) + } + + @Test + fun resourceBytesManyPreservesOrderAndSiblingResults() = + runTest { + val resource = + NappletResourceGateway { url, _ -> + if (url.endsWith("ok")) { + NappletResourceResult.Success(NappletResource("ok".encodeToByteArray(), "text/plain")) + } else { + NappletResourceResult.Failure("not-found") + } + } + val response = + broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource) + .handle(applet, NappletRequest.ResourceBytesMany(listOf("https://x/ok", "https://x/missing")), allDeclared) + + assertIs(response) + assertEquals(listOf("https://x/ok", "https://x/missing"), response.items.map { it.url }) + assertIs(response.items[0].resource) + assertEquals("not-found", response.items[1].error) + } + + @Test + fun resourceBytesManyRejectsInvalidBulkSizesWithNapErrorCodes() = + runTest { + val resource = NappletResourceGateway { _, _ -> error("invalid bulk must not fetch") } + val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), resource = resource) + + val empty = broker.handle(applet, NappletRequest.ResourceBytesMany(emptyList()), allDeclared) + val tooLarge = broker.handle(applet, NappletRequest.ResourceBytesMany(List(17) { "data:,x" }), allDeclared) + + assertIs(empty) + assertEquals("invalid-request", empty.error) + assertIs(tooLarge) + assertEquals("too-large", tooLarge.error) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt index 4894ab6951..52cf819efd 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapabilityTest.kt @@ -28,18 +28,12 @@ import kotlin.test.assertTrue class NappletCapabilityTest { @Test - fun mapsKnownDomainsCaseInsensitively() { - assertEquals(NappletCapability.SHELL, NappletCapability.fromNapDomain("shell")) + fun mapsOnlyConformingDomainsExactly() { assertEquals(NappletCapability.IDENTITY, NappletCapability.fromNapDomain("identity")) - assertEquals(NappletCapability.KEYS, NappletCapability.fromNapDomain("keys")) - assertEquals(NappletCapability.RELAY, NappletCapability.fromNapDomain("Relay")) - assertEquals(NappletCapability.VALUE, NappletCapability.fromNapDomain("value")) - assertEquals(NappletCapability.STORAGE, NappletCapability.fromNapDomain(" STORAGE ")) + assertEquals(NappletCapability.RELAY, NappletCapability.fromNapDomain("relay")) + assertEquals(NappletCapability.STORAGE, NappletCapability.fromNapDomain("storage")) assertEquals(NappletCapability.RESOURCE, NappletCapability.fromNapDomain("resource")) - assertEquals(NappletCapability.UPLOAD, NappletCapability.fromNapDomain("upload")) assertEquals(NappletCapability.THEME, NappletCapability.fromNapDomain("theme")) - assertEquals(NappletCapability.NOTIFY, NappletCapability.fromNapDomain("notify")) - assertEquals(NappletCapability.INC, NappletCapability.fromNapDomain("inc")) } @Test @@ -48,6 +42,14 @@ class NappletCapabilityTest { assertNull(NappletCapability.fromNapDomain("intent")) assertNull(NappletCapability.fromNapDomain("cvm")) assertNull(NappletCapability.fromNapDomain("filesystem")) + assertNull(NappletCapability.fromNapDomain("shell")) + assertNull(NappletCapability.fromNapDomain("keys")) + assertNull(NappletCapability.fromNapDomain("value")) + assertNull(NappletCapability.fromNapDomain("upload")) + assertNull(NappletCapability.fromNapDomain("notify")) + assertNull(NappletCapability.fromNapDomain("inc")) + assertNull(NappletCapability.fromNapDomain("Relay")) + assertNull(NappletCapability.fromNapDomain(" storage ")) assertNull(NappletCapability.fromNapDomain("")) } @@ -56,10 +58,10 @@ class NappletCapabilityTest { val resolved = resolveRequiredCapabilities(listOf("identity", "relay", "intent", "value")) assertEquals( - setOf(NappletCapability.IDENTITY, NappletCapability.RELAY, NappletCapability.VALUE), + setOf(NappletCapability.IDENTITY, NappletCapability.RELAY), resolved.capabilities, ) - assertEquals(listOf(UnknownNapDomain("intent")), resolved.unknown) + assertEquals(listOf(UnknownNapDomain("intent"), UnknownNapDomain("value")), resolved.unknown) assertTrue(resolved.hasUnknown) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt new file mode 100644 index 0000000000..656ebcc1a6 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContractTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NappletWebContractTest { + @Test + fun lockedPreludeIsTheFirstHeadContentAndRunsBeforeAuthoredCode() { + val authored = "" + val injected = + NappletWebContract + .injectPrelude( + html = authored.encodeToByteArray(), + shimJs = "window.__shimRan=true;", + declaredDomains = listOf("identity", "relay", "shell", "Relay", "bad\"domain", "relay"), + locked = true, + ).decodeToString() + + val head = injected.indexOf("") + "".length + val csp = injected.indexOf(", @@ -89,7 +83,7 @@ object NappletRequestRouter { declared: Set, payload: String, ): Outcome { - val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet" + val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: return Outcome.Ignore // Fire-and-forget edge ops that never reach the broker. when (requestType) { @@ -97,25 +91,9 @@ object NappletRequestRouter { val subId = runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull() return if (subId != null) Outcome.CloseSubscription(subId) else Outcome.Ignore } - "resource.cancel" -> - return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, NappletResponse.Done)) - // identity.watch/unwatch are a push subscription (like relay.subscribe), gated on the - // IDENTITY declaration directly — the actual pubkey stream is the host's job. The - // ledger still gets a say: this bypasses NappletBroker.handle, so without an explicit - // check a standing IDENTITY denial would not stop the pushes (and would keep leaking - // account-switch timing and every npub the user rotates between). - "identity.watch" -> - return if (NappletCapability.IDENTITY in declared && - !broker.isDenied(identity, NappletCapability.IDENTITY) - ) { - Outcome.WatchIdentity - } else { - Outcome.Ignore - } - "identity.unwatch" -> - return Outcome.UnwatchIdentity + "resource.cancel" -> return Outcome.Ignore // inc bus: a topic pub/sub between napplets/services, authorized on the INC declaration - // alone (like identity.watch) — no per-call consent. The host owns the cross-session fan-out. + // alone. The host owns the cross-session fan-out. "inc.subscribe" -> { val topic = runCatching { NappletProtocolJson.readTopic(payload) }.getOrNull() return if (topic != null && NappletCapability.INC in declared) Outcome.SubscribeInc(topic) else Outcome.Ignore @@ -136,7 +114,7 @@ object NappletRequestRouter { val request = runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull() - ?: return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Malformed or unsupported request."))) + ?: return Outcome.Ignore val response = broker.handle(identity, request, declared) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt index 4a1c52243b..1df933a9e7 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt @@ -26,6 +26,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.add +import kotlinx.serialization.json.addJsonObject import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.int @@ -34,6 +35,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.long import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import java.util.Base64 @@ -62,7 +64,7 @@ object NappletProtocolJson { /** The `subId` of a subscription request, used to key the `relay.event`/`relay.eose` pushes back to it. */ fun readSubId(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("subId") - /** A `relay.event` push: delivers one matching [event] to the subscription [subId] (no request id). */ + /** A `relay.event` push carrying the NAP-RELAY `RelayEventResult` wrapper. */ fun encodeRelayEvent( subId: String, event: Event, @@ -70,7 +72,9 @@ object NappletProtocolJson { buildJsonObject { put("type", "relay.event") put("subId", subId) - put("event", json.parseToJsonElement(event.toJson())) + putJsonObject("result") { + put("event", json.parseToJsonElement(event.toJson())) + } }.toString() /** A `relay.eose` push: signals end-of-stored-events for the subscription [subId]. */ @@ -130,29 +134,10 @@ object NappletProtocolJson { put("reason", reason) }.toString() - /** - * The `shell.init` handshake reply (`@napplet/core`): the capability environment the napplet - * caches and answers `shell.supports()` from. [domains] is the set of NAP domains this shell - * will broker for the applet; [services] mirrors them. We don't advertise numbered protocols. - */ - fun encodeShellInit( - domains: List, - services: List, - ): String = - buildJsonObject { - put("type", "shell.init") - putJsonObject("capabilities") { - put("domains", buildJsonArray { domains.forEach { add(it) } }) - putJsonObject("protocols") {} - } - put("services", buildJsonArray { services.forEach { add(it) } }) - }.toString() - /** Parses a request envelope. Returns `null` for an unrecognized `type` so the broker can deny it. */ fun decodeRequest(envelopeJson: String): NappletRequest? { val o = json.parseToJsonElement(envelopeJson).jsonObject return when (o.str("type")) { - "shell.supports" -> NappletRequest.ShellSupports(o.req("domain"), o.str("protocol")) "theme.get" -> NappletRequest.ThemeGet "identity.getPublicKey" -> NappletRequest.GetPublicKey "relay.publish" -> { @@ -181,10 +166,10 @@ object NappletProtocolJson { createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000), ) } - "storage.get" -> NappletRequest.StorageGet(o.req("key")) - "storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value")) - "storage.remove" -> NappletRequest.StorageRemove(o.req("key")) - "storage.keys" -> NappletRequest.StorageKeys + "storage.get" -> NappletRequest.StorageGet(o.req("key"), o.storageScope()) + "storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value"), o.storageScope()) + "storage.remove" -> NappletRequest.StorageRemove(o.req("key"), o.storageScope()) + "storage.keys" -> NappletRequest.StorageKeys(o.storageScope()) "notify.create" -> NappletRequest.NotifyCreate(o.str("title") ?: "", o.str("body") ?: "") "notify.list" -> NappletRequest.NotifyList "notify.dismiss" -> NappletRequest.NotifyDismiss(o.str("notificationId") ?: o.str("id") ?: "") @@ -194,7 +179,9 @@ object NappletProtocolJson { } "keys.unregisterAction" -> NappletRequest.UnregisterAction(o.req("actionId")) "value.payInvoice" -> NappletRequest.PayInvoice(o.req("invoice")) + "resource.info" -> NappletRequest.ResourceInfo "resource.bytes" -> NappletRequest.ResourceBytes(o.req("url")) + "resource.bytesMany" -> NappletRequest.ResourceBytesMany(o.getValue("urls").jsonArray.map { it.jsonPrimitive.content }) "upload.upload" -> { // UploadUploadMessage: { type, id, request: { data, mimeType?, filename?, ... } }. // The Blob in `request.data` is inlined as base64 `request.dataBase64` by shell.html. @@ -209,7 +196,7 @@ object NappletProtocolJson { // Any other identity.* read (getProfile/getRelays/getFollows/getList/...) routes through // a generic IdentityRead; the broker/gateway decides which are implemented. val type = o.str("type") - if (type != null && type.startsWith("identity.")) { + if (type != null && type in IDENTITY_READ_TYPES) { NappletRequest.IdentityRead(type.removePrefix("identity."), o.str("listType") ?: o.str("argument")) } else { null @@ -230,6 +217,7 @@ object NappletProtocolJson { when (response) { is NappletResponse.NotifyCreated -> "notify.created" is NappletResponse.NotifyListed -> "notify.listed" + is NappletResponse.ResourceFailure -> "$requestType.error" else -> "$requestType.result" } put("type", responseType) @@ -247,11 +235,11 @@ object NappletProtocolJson { } is NappletResponse.Events -> { put("ok", true) - put("events", buildJsonArray { response.events.forEach { add(json.parseToJsonElement(it.toJson())) } }) - } - is NappletResponse.Supported -> { - put("ok", true) - put("supported", response.supported) + putJsonArray("events") { + response.events.forEach { event -> + addJsonObject { put("event", json.parseToJsonElement(event.toJson())) } + } + } } is NappletResponse.ActionRegistered -> { put("ok", true) @@ -279,6 +267,42 @@ object NappletProtocolJson { put("bytes", Base64.getEncoder().encodeToString(response.bytes)) put("mime", response.contentType) } + is NappletResponse.ResourceInfo -> { + put("ok", true) + putJsonObject("info") { + putJsonArray("schemes") { + response.schemes.forEach { scheme -> + addJsonObject { + put("scheme", scheme) + put("enabled", true) + } + } + } + put("maxBytes", response.maxBytes) + put("maxUrls", response.maxUrls) + } + } + is NappletResponse.ResourceItems -> { + put("ok", true) + putJsonArray("items") { + response.items.forEach { item -> + addJsonObject { + put("url", item.url) + put("ok", item.resource != null) + item.resource?.let { + put("bytes", Base64.getEncoder().encodeToString(it.bytes)) + put("mime", it.contentType) + } + item.error?.let { put("error", it) } + item.message?.let { put("message", it) } + } + } + } + } + is NappletResponse.ResourceFailure -> { + put("error", response.error) + response.message?.let { put("message", it) } + } is NappletResponse.Uploaded -> { // UploadResult: { ok, uploadId, status, url?, sha256?, size?, mimeType?, ... }. put("ok", true) @@ -397,6 +421,8 @@ object NappletProtocolJson { private fun JsonObject.kindOf(): Int = getValue("kind").jsonPrimitive.int + private fun JsonObject.storageScope(): NappletStorageScope = if (str("scope") == "instance") NappletStorageScope.INSTANCE else NappletStorageScope.SHARED + /** The result field a given identity read returns, matching `@napplet/nap` identity message types. */ private fun identityResultField(requestType: String): String = when (requestType) { @@ -408,4 +434,16 @@ object NappletProtocolJson { "identity.getBadges" -> "badges" else -> "result" } + + private val IDENTITY_READ_TYPES = + setOf( + "identity.getRelays", + "identity.getProfile", + "identity.getFollows", + "identity.getList", + "identity.getZaps", + "identity.getMutes", + "identity.getBlocked", + "identity.getBadges", + ) } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt index 94e3e4c763..eca9000437 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt @@ -85,11 +85,12 @@ class NappletRequestRouterTest { } @Test - fun resourceCancelRepliesDone() = + fun resourceCancelIsSilentlyHandled() = runTest { - val outcome = route("""{"type":"resource.cancel"}""") - assertIs(outcome) - assertTrue(outcome.payload.contains("resource.cancel.result")) + assertEquals( + NappletRequestRouter.Outcome.Ignore, + route("""{"type":"resource.cancel"}"""), + ) } @Test @@ -117,11 +118,11 @@ class NappletRequestRouterTest { } @Test - fun malformedRequestRepliesFailed() = + fun unknownAndMalformedRequestsAreSilentlyIgnored() = runTest { - val outcome = route("""{"type":"totally.unknown"}""") - assertIs(outcome) - assertTrue(outcome.payload.contains("failed")) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"totally.unknown"}""")) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("not json")) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"relay.publish"}""")) } @Test @@ -153,29 +154,9 @@ class NappletRequestRouterTest { } @Test - fun identityWatchWhenDeclaredBecomesWatchIdentity() = + fun removedIdentityWatchMessagesAreIgnored() = runTest { - assertEquals( - NappletRequestRouter.Outcome.WatchIdentity, - NappletRequestRouter.route(broker(), applet, allDeclared, """{"type":"identity.watch"}"""), - ) - } - - @Test - fun identityWatchWithoutDeclarationIsIgnored() = - runTest { - assertEquals( - NappletRequestRouter.Outcome.Ignore, - NappletRequestRouter.route(broker(), applet, emptySet(), """{"type":"identity.watch"}"""), - ) - } - - @Test - fun identityUnwatchBecomesUnwatchIdentity() = - runTest { - assertEquals( - NappletRequestRouter.Outcome.UnwatchIdentity, - NappletRequestRouter.route(broker(), applet, emptySet(), """{"type":"identity.unwatch"}"""), - ) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"identity.watch"}""")) + assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"identity.unwatch"}""")) } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt index 7f32a39474..b7186df770 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.napplethost +import android.util.Base64 import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract @@ -60,6 +61,9 @@ class NappletContentServer( // The host posture: a WEBSITE nSite is a normal web app — NIP-07 window.nostr provider, normal // network (no app CSP; off-origin requests defer to the WebView), unlike a locked NAPPLET. private val profile: HostProfile = HostProfile.NAPPLET, + // Exact NAP domains the trusted main process authorized for this launch. The injected prelude + // projects only these objects; absence is the NIP-5D capability-availability signal. + private val declaredDomains: List = emptyList(), // Embedded surfaces (a windowless Service) can't host the soft keyboard, so the shim installs the // IME proxy agent that relays the focused field to the host's keyboard. The full-screen Activity // host has a native keyboard and leaves this false. @@ -130,15 +134,44 @@ class NappletContentServer( } private fun serveShell(): WebResourceResponse { - // The shell HTML carries an APP_ORIGIN_PLACEHOLDER for the iframe src; bind it to this applet's - // origin so the shell frames exactly this applet (and the CSP frame-src is pinned to it too). - val html = shellHtmlBytes.decodeToString().replace(NappletWebContract.APP_ORIGIN_PLACEHOLDER, appOrigin).encodeToByteArray() + val sandbox: String + val frameSource: String + val bootstrap: String + + if (profile == HostProfile.NAPPLET) { + // NIP-5D identity is bound to the exact verified bytes that execute. Resolve the sole + // /index.html blob, inject host-owned policy/prelude outside its aggregate hash, then + // hand those bytes to the opaque-origin child via srcdoc (never a navigated src). + val resolution = resolveCacheFirst("/index.html") + if (resolution !is StaticSiteResolution.Resolved) return notFound() + val encoded = Base64.encodeToString(injectShim(resolution.bytes), Base64.NO_WRAP) + sandbox = "allow-scripts" + frameSource = "'self'" + bootstrap = + "var b=atob('$encoded'),u=new Uint8Array(b.length);" + + "for(var j=0;jwindow.__nappletNip07=true;" else "" - // Embedded surface: turn on the IME proxy agent (set before the shim runs). - val imeFlag = if (imeProxy) "" else "" - val script = "$style$nip07Flag$imeFlag" - val headIdx = text.indexOf("= 0 -> { - val close = text.indexOf('>', headIdx) - if (close >= 0) text.substring(0, close + 1) + script + text.substring(close + 1) else script + text - } - else -> script + text - } - return injected.encodeToByteArray() - } + /** Inserts host policy and the explicit-domain `window.napplet` prelude before authored code. */ + private fun injectShim(html: ByteArray): ByteArray = + NappletWebContract.injectPrelude( + html = html, + shimJs = shimJs, + declaredDomains = declaredDomains, + locked = profile == HostProfile.NAPPLET, + injectNip07 = profile.injectsNip07, + imeProxy = imeProxy, + ) private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0))) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index d061653f72..74c3c6aaac 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -67,7 +67,6 @@ import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson -import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities import com.vitorpamplona.amethyst.napplethost.R import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution @@ -243,7 +242,7 @@ class NappletHostActivity : ComponentActivity() { // "Open web" for a site makes everything direct — both its blob fetches (here) and its live // web traffic (the WebView proxy, below). Tor (the default) routes both through the SOCKS port. val effectiveProxy = if (useTor) proxyPort else -1 - contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, profile) + contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, profile, declaredDomains) // Create + warm the WebView NOW so its (slow, first-in-process) Chromium init runs on the main // thread concurrently with the index probe below (which runs on IO) — instead of serially after @@ -473,10 +472,10 @@ class NappletHostActivity : ComponentActivity() { webViewProfile = intent.getStringExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE) val requires = intent.getStringArrayListExtra(NappletHostContract.EXTRA_REQUIRES) ?: emptyList() - val resolved = resolveRequiredCapabilities(requires) - // shell is always available; the rest are the declared domains advertised to the applet in the - // handshake. (The broker enforces the authoritative set from the launch token, not this list.) - declaredDomains = (listOf("shell") + resolved.capabilities.map { it.name.lowercase() }).distinct() + val resolved = profile.declaredCapabilities(requires) + // Domain-object presence is the NIP-5D availability signal. The broker still enforces the + // authoritative set minted into the launch token in the main process. + declaredDomains = resolved.map { it.name.lowercase() }.distinct() return author.isNotEmpty() && launchToken.isNotEmpty() } @@ -677,13 +676,6 @@ class NappletHostActivity : ComponentActivity() { // correlate on its id. The broker reads `type` to decode and to build the .result reply. val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return - // Shell handshake: the SDK posts `shell.ready` (no id) and answers shell.supports() locally - // from the `shell.init` environment we send back here. - if (envelope.optString("type") == "shell.ready") { - runCatching { replyProxy.postMessage(NappletProtocolJson.encodeShellInit(declaredDomains, declaredDomains)) } - return - } - // Unbind a keyboard action as soon as the applet drops it (the broker's Done reply carries no // actionId, so the binding is removed here from the envelope itself). if (envelope.optString("type") == "keys.unregisterAction") { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index ceaf53b6e2..37dd512352 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -54,8 +54,6 @@ import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract -import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson -import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.utils.sha256.sha256 @@ -201,7 +199,8 @@ class NappletHostService : Service() { if (author.isEmpty() || launchToken.isEmpty()) return null val requires = data.getStringArrayList(NappletHostContract.EXTRA_REQUIRES) ?: emptyList() - val declaredDomains = (listOf("shell") + resolveRequiredCapabilities(requires).capabilities.map { it.name.lowercase() }).distinct() + val profile = HostProfile.fromName(data.getString(NappletHostContract.EXTRA_HOST_PROFILE)) + val declaredDomains = profile.declaredCapabilities(requires).map { it.name.lowercase() }.distinct() val tab = NappletTab( @@ -212,7 +211,7 @@ class NappletHostService : Service() { author = author, identifier = data.getString(NappletHostContract.EXTRA_IDENTIFIER).orEmpty(), launchToken = launchToken, - profile = HostProfile.fromName(data.getString(NappletHostContract.EXTRA_HOST_PROFILE)), + profile = profile, useTor = data.getBoolean(NappletHostContract.EXTRA_USE_TOR, true), proxyPort = data.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1), bgColor = data.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE), @@ -300,7 +299,19 @@ class NappletHostService : Service() { NappletWebViewProfile.apply(context, wv, tab.webViewProfile) val appOrigin = NappletWebContract.appOrigin(deriveAppId(tab.author, tab.identifier)) val effectiveProxy = if (tab.useTor) tab.proxyPort else -1 - tab.contentServer = NappletContentServer(tab.paths, tab.servers, effectiveProxy, cacheDir, shellHtml, shimJs, appOrigin, tab.profile, imeProxy = true) + tab.contentServer = + NappletContentServer( + tab.paths, + tab.servers, + effectiveProxy, + cacheDir, + shellHtml, + shimJs, + appOrigin, + tab.profile, + tab.declaredDomains, + imeProxy = true, + ) hardenWebView(wv, tab) // Theme the pre-load background so the shell/app loading shows Amethyst's background, not white. @@ -468,11 +479,6 @@ class NappletHostService : Service() { val raw = message.data ?: return val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return - if (envelope.optString("type") == "shell.ready") { - runCatching { replyProxy.postMessage(NappletProtocolJson.encodeShellInit(tab.declaredDomains, tab.declaredDomains)) } - return - } - // IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client. if (envelope.optString("type").startsWith("ime.")) { val reply = From 129401bdaf5f51025e3b89409199fd0fd043be7f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 21:36:22 +0000 Subject: [PATCH 116/227] test(relay): characterize Yggdrasil/IPv6 relay handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assesses how the app fares when relays live on an Yggdrasil overlay, where every relay is a bracketed IPv6 literal in 0200::/7 served over plain ws:// (no DNS, no CA-issuable certificate). The happy path works: a hand-typed ws://[...]:port normalizes, survives the RFC 3986 pass and is dialed by OkHttp; nothing in the stack is IPv4-only and cleartext is already permitted globally. Four gaps are pinned by the new characterization tests: 1. RelayUrlNormalizer folds hex case but not zero-compression, so two legal spellings of one address yield two NormalizedRelayUrl values while OkHttp collapses them to one host — duplicate sockets, REQs and stat entries. 2. isLocalHost() does not know 0200::/7, so a schemeless literal defaults to wss:// and can only fail its TLS handshake. 3. An unbracketed literal (what yggdrasilctl getSelf prints) is rejected, and RelayUrlEditField.submitRelay has no else branch — the Add button silently does nothing. 4. TorRelayEvaluation classifies mesh relays as "new", so with Tor on they are dialed through the SOCKS proxy, which cannot route 0200::/7. No behavior is changed. quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md records the full assessment, the NIP-65/outbox propagation consequences of publishing a key-derived mesh address, and what could not be verified here (the analysis container has no IPv6 stack, so nothing below the socket was exercised). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk --- .../commons/tor/YggdrasilTorRoutingTest.kt | 65 ++++++++++ .../plans/2026-08-04-yggdrasil-ipv6-relays.md | 103 ++++++++++++++++ .../YggdrasilCompatCharacterizationTest.kt | 116 ++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt create mode 100644 quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt new file mode 100644 index 0000000000..68a8941821 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.tor + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * GAP 4 — an Yggdrasil relay is classified as a plain clearnet relay, so with Tor on it is + * dialed through the SOCKS proxy. Tor cannot route `0200::/7`: the connection can only fail. + * + * Compare `ws://192.168.1.100:8080/`, which [TorRelayEvaluation] correctly keeps off Tor + * because `isLocalHost()` recognizes the LAN prefix. Yggdrasil has no such recognition. + */ +class YggdrasilTorRoutingTest { + private val yggdrasilRelay = NormalizedRelayUrl("ws://[201:d0e:9ba5:8bbc::1]:8080/") + private val lanRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/") + + private fun evaluation(newViaTor: Boolean) = + TorRelayEvaluation( + torSettings = + TorRelaySettings( + torType = TorType.INTERNAL, + onionRelaysViaTor = true, + dmRelaysViaTor = true, + newRelaysViaTor = newViaTor, + trustedRelaysViaTor = false, + moneyOperationsViaTor = false, + ), + trustedRelayList = emptySet(), + dmRelayList = emptySet(), + ) + + @Test + fun yggdrasilRelayIsSentThroughTorWhileLanRelayIsNot() { + val eval = evaluation(newViaTor = true) + assertTrue(eval.useTor(yggdrasilRelay), "Yggdrasil relay is routed via Tor, which cannot reach 0200::/7") + assertFalse(eval.useTor(lanRelay), "LAN relay is correctly kept off Tor") + } + + @Test + fun yggdrasilRelayWorksOnlyWhenNewRelaysViaTorIsOff() { + assertFalse(evaluation(newViaTor = false).useTor(yggdrasilRelay)) + } +} diff --git a/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md b/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md new file mode 100644 index 0000000000..f854bffaab --- /dev/null +++ b/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md @@ -0,0 +1,103 @@ +# Amethyst over Yggdrasil (IPv6 overlay) — compatibility assessment + +Status: **analysis only** — no behavior changed. Characterization tests landed alongside +this doc pin the current behavior so a fix has a baseline to diff against. + +## What Yggdrasil looks like to the app + +Yggdrasil is an encrypted end-to-end mesh. Every node gets an IPv6 address derived from its +public key inside `0200::/7`, and hands out `0300::/8` subnets. Consequences that matter here: + +- **No DNS.** A relay on the mesh is addressed as a bracketed IPv6 literal, always. +- **No certificates.** No CA issues for `0200::/7` literals, so relays run plain `ws://`. + This is not a downgrade — the overlay already provides end-to-end encryption and + authenticates the peer by its address. +- **On Android it is a `VpnService`**, so the app's default network becomes the VPN network. +- The address is a **stable node identifier**, so publishing it is equivalent to publishing + a long-lived pseudonymous handle for the device. + +## Verdict + +A hand-typed `ws://[…]:port` relay works end to end: it normalizes, survives the RFC 3986 +pass, and OkHttp parses and dials it. Nothing in the stack is IPv4-only, `TcpNoDelaySocketFactory` +is family-agnostic, and `network_security_config.xml` permits cleartext globally, so the +`ws://` requirement is already satisfied. + +Everything around that happy path is where it degrades. Four gaps, in severity order. + +### GAP 1 — one relay, two identities (correctness) + +`RelayUrlNormalizer` folds hex case but does **not** canonicalize zero-compression or +leading zeros: + +| input | `NormalizedRelayUrl` | OkHttp host | +|---|---|---| +| `ws://[201:d0e:9ba5:8bbc::1]:8080` | `ws://[201:d0e:9ba5:8bbc::1]:8080/` | `201:d0e:9ba5:8bbc::1` | +| `ws://[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]:8080` | `ws://[201:0d0e:…:0001]:8080/` | `201:d0e:9ba5:8bbc::1` | + +OkHttp collapses both to one host; the app does not. `NormalizedRelayUrl` is the key of the +connection pool (`PoolRequests`, `RelayPool`), every relay-list set, the NIP-11 cache and the +per-relay stat maps — so the same relay written two ways gets **two sockets, two REQ sets and +doubled traffic**, and appears twice in the relay UI. This affects all IPv6 literals, but it +only bites Yggdrasil users in practice, because on the mesh a literal is the *only* way to +name a relay. Fix: canonicalize the bracketed literal to RFC 5952 inside `fix()`. + +### GAP 2 — schemeless entry defaults to `wss://` (dead end) + +`RelayUrlNormalizer.isLocalHost()` recognizes `127.0.0.1`, `localhost`, `//umbrel:`, +`192.168.`, `.local:` / `.local/`. An Yggdrasil address matches none of them, so a schemeless +`[201:…]:8080` falls through to the clearnet default and becomes `wss://[201:…]:8080/` — a +URL whose TLS handshake can never succeed. The user must know to type `ws://` themselves. +Fix: teach `isLocalHost()` (or a sibling `isOverlayNetwork()`) the `0200::/7` prefix. + +### GAP 3 — unbracketed literal is silently rejected (UX) + +`yggdrasilctl getSelf` prints the address **unbracketed**, which is exactly what a user +copies into the "add a relay" box. `isBareHostAndPath()` rejects it (correctly — it is +ambiguous with a scheme), so `normalizeOrNull` returns null. But +`RelayUrlEditField.submitRelay()` has no else branch: the Add button just does nothing, with +no error. Fix: either auto-bracket a candidate that parses as an IPv6 address, or surface a +validation message instead of a silent no-op. + +### GAP 4 — Tor routing sends mesh traffic into the SOCKS proxy (breaks the relay) + +`TorRelayEvaluation` classifies relays as localhost / onion / dm / trusted / new. Yggdrasil +lands in **new**, so with Tor on and the default "new relays via Tor", the relay is dialed +through the Tor SOCKS proxy — which cannot route `0200::/7`. The connection can only fail. +Compare `ws://192.168.1.100:8080/`, which is correctly kept off Tor because `isLocalHost()` +knows the LAN prefix. Today the only workaround is turning "new relays via Tor" off, which +weakens the setting for every genuine clearnet relay. The same gap exists on desktop +(`DesktopHttpClient`) and for non-relay HTTP (`RoleBasedHttpClientBuilder`). Fixing GAP 2's +prefix check fixes this one too, since both read the same predicate. + +## Propagation / privacy note (not a bug, a decision) + +An Yggdrasil relay is not filtered out of NIP-65 publishing (`AdvertisedRelayInfoTag` only +rejects localhost) nor out of the outbox model +(`RelayListRecommendationProcessor.filterValidRelays`). So a mesh relay in your relay list is +**published to public relays and recommended to other users**. Two effects: + +- Peers not on the mesh dial `[201:…]` and burn reconnect attempts on an unreachable host. +- Your Yggdrasil address — a stable, key-derived node identifier — becomes public. + +Onion relays get special handling here (`hasOnionConnection` gates whether they are even +considered). An overlay-network classification would let Yggdrasil be treated the same way. + +## Not covered + +- **No live socket test.** The analysis container has no IPv6 stack at all + (`AF_INET6` → `EAFNOSUPPORT`), so everything above is verified below the socket: URL + normalization, OkHttp URL/host parsing, and the Tor routing decision. An on-device run + against a real mesh relay is still needed to confirm the happy path end to end. +- **Android VPN interaction untested.** `ConnectivityFlow` uses + `registerDefaultNetworkCallback`, so it follows the app's default network into the VPN. + Whether `isMeteredOrMobileData()` reads correctly through Yggdrasil's `VpnService` depends + on whether that app declares underlying networks; worth checking on device before assuming + data-saving mode behaves. +- Media loading (Coil) and NIP-05 resolution against mesh hosts were not exercised. + +## Tests + +- `quartz/src/jvmAndroidTest/…/relay/YggdrasilCompatCharacterizationTest.kt` — GAPs 1–3 plus + the working happy path. +- `commons/src/commonTest/…/tor/YggdrasilTorRoutingTest.kt` — GAP 4. diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt new file mode 100644 index 0000000000..638858dbd8 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp +import okhttp3.Request +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Characterization of how relay URLs on an Yggdrasil overlay behave today. + * + * Yggdrasil gives every node an IPv6 address inside `0200::/7` (nodes) and hands out + * `0300::/8` subnets, with no DNS and no CA-issuable certificate. A relay on the mesh is + * therefore always reached as a **bracketed IPv6 literal over plain `ws://`** — a shape + * the relay stack only partially handles. + * + * These tests document the CURRENT behavior (including the gaps) so a later fix has a + * baseline to diff against. Each gap is marked GAP with what a user sees. + */ +class YggdrasilCompatCharacterizationTest { + // Same node, three legal RFC 4291 spellings of one address. + private val canonical = "ws://[201:d0e:9ba5:8bbc::1]:8080" + private val expanded = "ws://[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]:8080" + private val uppercase = "ws://[201:D0E:9BA5:8BBC::1]:8080" + + private fun host(url: String) = + Request + .Builder() + .url(url) + .build() + .url.host + + @Test + fun bracketedLiteralsSurviveNormalizationAndReachOkHttp() { + val n = canonical.normalizeRelayUrl() + assertEquals("ws://[201:d0e:9ba5:8bbc::1]:8080/", n.url) + assertEquals("201:d0e:9ba5:8bbc::1", host(n.url)) + // NIP-11 / relay-icon fetches derive their http url from the same string. + assertEquals("http://[201:d0e:9ba5:8bbc::1]:8080/", n.toHttp()) + } + + @Test + fun yggdrasilSubnetAddressesAndUppercaseHexWork() { + assertEquals("ws://[300:1b5d:d0e9:ba58::1]:4848/", "ws://[300:1b5d:d0e9:ba58::1]:4848".normalizeRelayUrl().url) + // Hex case IS folded, so the uppercase spelling collapses onto the canonical one. + assertEquals(canonical.normalizeRelayUrl(), uppercase.normalizeRelayUrl()) + } + + /** + * GAP 1 — zero-compression is NOT canonicalized, so one relay gets two identities. + * + * `NormalizedRelayUrl` is the key of the connection pool, the relay-list sets, the NIP-11 + * cache and every per-relay stat map. OkHttp collapses both spellings to one host (below), + * so the app opens two sockets to the same relay and counts it twice everywhere. + */ + @Test + fun gapZeroCompressionSplitsOneRelayIntoTwoIdentities() { + assertNotEquals(canonical.normalizeRelayUrl(), expanded.normalizeRelayUrl()) + // ...even though they are literally the same host on the wire: + assertEquals(host(canonical), host(expanded)) + } + + /** + * GAP 2 — a schemeless IPv6 literal defaults to `wss://`. + * + * `isLocalHost()` only knows 127.0.0.1 / localhost / umbrel / 192.168. / .local, so an + * Yggdrasil address falls through to the clearnet default. No CA issues certificates for + * `0200::/7` literals, so the resulting wss:// url can only ever fail its TLS handshake. + */ + @Test + fun gapSchemelessYggdrasilAddressDefaultsToWss() { + assertEquals("wss://[201:d0e:9ba5:8bbc::1]:8080/", "[201:d0e:9ba5:8bbc::1]:8080".normalizeRelayUrl().url) + assertFalse("ws://[201:d0e:9ba5:8bbc::1]:8080/".normalizeRelayUrl().isLocalHost()) + } + + /** + * GAP 3 — an unbracketed IPv6 literal is rejected outright. + * + * `yggdrasilctl getSelf` prints the address unbracketed, which is what a user copies into + * the "add a relay" field. Normalization returns null and `RelayUrlEditField.submitRelay` + * has no else branch, so the Add button silently does nothing. + */ + @Test + fun gapUnbracketedYggdrasilAddressIsRejected() { + assertNull(RelayUrlNormalizer.normalizeOrNull("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5")) + assertNull(RelayUrlNormalizer.normalizeOrNull("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5:8080")) + // Bracketing it by hand is the only accepted form. + assertTrue(RelayUrlNormalizer.normalizeOrNull("[201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5]:8080") != null) + } +} From 067d68b89cced66b7ea0caede630004310bb7c04 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 22:28:07 +0000 Subject: [PATCH 117/227] feat(relay): canonicalize IPv6 relay urls and support overlay meshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the four gaps the previous commit characterized for relays on an Yggdrasil overlay, where every relay is an IPv6 literal in 0200::/7 served over plain ws:// (no DNS, no CA-issuable certificate). New quartz/utils/Ipv6.kt: pure-Kotlin literal parsing, RFC 5952 canonical formatting and range classification. No java.net, so it works on every KMP target. - Canonicalize the bracketed host in RelayUrlNormalizer.norm(). RFC 4291 lets one address be spelled many ways and the RFC 3986 pass only folded hex case, so two spellings survived as two NormalizedRelayUrl values for one host — and that value keys the connection pool, the relay-list sets, the NIP-11 cache and the per-relay stats, so the app dialed one relay twice. The canonical form matches what OkHttp renders when it dials; the tests assert that agreement differentially. Relay lists rehydrate through normalizeOrNull, so stored entries fold on load and no migration is needed. - Add isOverlayNetwork() for 0200::/7 and default those relays to ws://: nothing can issue a certificate for the range, so wss:// could only fail its handshake, and the overlay already encrypts end to end. - Teach isLocalHost() the IPv6 twins of the literals it already knew — ::1, fc00::/7 and fe80::/10 — so a relay on one skips TLS and Tor and stays out of published relay lists, as its IPv4 equivalent already did. - Never route an overlay relay through Tor: the range is unroutable there, so proxying guaranteed failure rather than privacy. TorRelayEvaluation covers both the Android and desktop relay paths; RoleBasedHttpClientBuilder covers non-relay HTTP. - Bracket a bare IPv6 literal automatically (what yggdrasilctl getSelf prints), but only when the whole string parses as an address, so host:port and addressable pointers still fall through. RelayUrlEditField now shows an error instead of no-opping, fixing the silent Add button for all invalid input. Mesh relays are still published in NIP-65 and offered by the outbox model; the plan doc explains why that is left as a maintainer's call, and records that no live socket test was possible here (the container has no IPv6 stack). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk --- .../RoleBasedHttpClientBuilder.kt | 6 +- .../relays/common/RelayUrlEditField.kt | 17 ++ amethyst/src/main/res/values/strings.xml | 1 + .../commons/tor/TorRelayEvaluation.kt | 6 + .../commons/tor/YggdrasilTorRoutingTest.kt | 25 +- .../plans/2026-08-04-yggdrasil-ipv6-relays.md | 149 +++++----- .../relay/normalizer/NormalizedRelayUrl.kt | 3 + .../relay/normalizer/RelayUrlNormalizer.kt | 93 +++++- .../com/vitorpamplona/quartz/utils/Ipv6.kt | 264 ++++++++++++++++++ .../vitorpamplona/quartz/utils/Ipv6Test.kt | 139 +++++++++ .../YggdrasilCompatCharacterizationTest.kt | 118 +++++--- 11 files changed, 696 insertions(+), 125 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6Test.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index b2ca4dcafc..1f2a0722e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -67,7 +67,9 @@ class RoleBasedHttpClientBuilder( normalizedUrl: String, final: Boolean, ): Boolean = - if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { + if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) { + // Overlay-mesh hosts (0200::/7) are reachable only through the local mesh + // interface — Tor cannot route the range, so proxying only breaks the fetch. false } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { true @@ -113,7 +115,7 @@ class RoleBasedHttpClientBuilder( isOnionRelaysActive: Boolean, final: Boolean, ): Boolean = - if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { + if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) { false } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { isOnionRelaysActive diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index e14ca5c66f..d523058cfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -170,6 +170,7 @@ fun RelayUrlEditField( nav: INav, ) { var url by remember { mutableStateOf("") } + var isInvalid by remember { mutableStateOf(false) } fun submitRelay() { if (url.isNotBlank()) { @@ -177,7 +178,13 @@ fun RelayUrlEditField( if (relay != null) { onNewRelay(relay) url = "" + isInvalid = false relaySuggestions.reset() + } else { + // Without this the Add button is a silent no-op, which reads as a broken button. + // Bare IPv6 literals are the common way to land here: an overlay-mesh address + // pasted straight out of `yggdrasilctl getSelf` needs brackets to carry a port. + isInvalid = true } } } @@ -189,8 +196,18 @@ fun RelayUrlEditField( value = url, onValueChange = { url = it + isInvalid = false relaySuggestions.processInput(it) }, + isError = isInvalid, + supportingText = { + if (isInvalid) { + Text( + text = stringRes(R.string.relay_url_not_valid), + color = MaterialTheme.colorScheme.error, + ) + } + }, placeholder = { Text( text = "server.com", diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1843dbfdd4..2221700aea 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -214,6 +214,7 @@ Percentage of successful connections to the relay Search and add user Add a Relay + Not a valid relay address. Use a host name, or an IP address in brackets (for example [201:d0e:9ba5:8bbc::1]:8080). My @tag name Display Name My display name diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt index 42987dfb56..40695348b8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.tor import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOverlayNetwork class TorRelayEvaluation( val torSettings: TorRelaySettings, @@ -36,6 +37,11 @@ class TorRelayEvaluation( } else { if (relay.isLocalHost()) { false + } else if (relay.isOverlayNetwork()) { + // An overlay-mesh relay (0200::/7, e.g. Yggdrasil) is reachable only through the + // local mesh interface: Tor cannot route the range at all, so proxying it would + // guarantee failure rather than privacy. The overlay already encrypts end to end. + false } else if (relay.isOnion()) { // .onion is only reachable over Tor regardless of any other classification. torSettings.onionRelaysViaTor diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt index 68a8941821..9a2dbb577b 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt @@ -26,15 +26,16 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * GAP 4 — an Yggdrasil relay is classified as a plain clearnet relay, so with Tor on it is - * dialed through the SOCKS proxy. Tor cannot route `0200::/7`: the connection can only fail. - * - * Compare `ws://192.168.1.100:8080/`, which [TorRelayEvaluation] correctly keeps off Tor - * because `isLocalHost()` recognizes the LAN prefix. Yggdrasil has no such recognition. + * An overlay-mesh relay (`0200::/7`, e.g. Yggdrasil) must never be dialed through the Tor SOCKS + * proxy: Tor cannot route the range, so proxying guarantees failure rather than privacy. The + * overlay already encrypts end to end and authenticates the peer by its key-derived address. */ class YggdrasilTorRoutingTest { private val yggdrasilRelay = NormalizedRelayUrl("ws://[201:d0e:9ba5:8bbc::1]:8080/") + private val yggdrasilSubnetRelay = NormalizedRelayUrl("ws://[300:1b5d:d0e9:ba58::1]:4848/") private val lanRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/") + private val ulaRelay = NormalizedRelayUrl("ws://[fd12:3456::1]:8080/") + private val clearnetIpv6Relay = NormalizedRelayUrl("wss://[2001:db8::1]:8080/") private fun evaluation(newViaTor: Boolean) = TorRelayEvaluation( @@ -52,14 +53,18 @@ class YggdrasilTorRoutingTest { ) @Test - fun yggdrasilRelayIsSentThroughTorWhileLanRelayIsNot() { + fun overlayRelaysAreNeverTorifiedEvenWhenNewRelaysViaTorIsOn() { val eval = evaluation(newViaTor = true) - assertTrue(eval.useTor(yggdrasilRelay), "Yggdrasil relay is routed via Tor, which cannot reach 0200::/7") - assertFalse(eval.useTor(lanRelay), "LAN relay is correctly kept off Tor") + assertFalse(eval.useTor(yggdrasilRelay), "0200::/8 node address must not be proxied") + assertFalse(eval.useTor(yggdrasilSubnetRelay), "0300::/8 subnet address must not be proxied") + assertFalse(eval.useTor(lanRelay), "LAN relay stays off Tor") + assertFalse(eval.useTor(ulaRelay), "IPv6 unique local address stays off Tor") } @Test - fun yggdrasilRelayWorksOnlyWhenNewRelaysViaTorIsOff() { - assertFalse(evaluation(newViaTor = false).useTor(yggdrasilRelay)) + fun clearnetIpv6RelaysStillFollowTheTorSetting() { + // The overlay exemption must not leak into ordinary IPv6 relays. + assertTrue(evaluation(newViaTor = true).useTor(clearnetIpv6Relay)) + assertFalse(evaluation(newViaTor = false).useTor(clearnetIpv6Relay)) } } diff --git a/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md b/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md index f854bffaab..33d8aaef31 100644 --- a/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md +++ b/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md @@ -1,103 +1,114 @@ -# Amethyst over Yggdrasil (IPv6 overlay) — compatibility assessment +# Amethyst over Yggdrasil (IPv6 overlay) -Status: **analysis only** — no behavior changed. Characterization tests landed alongside -this doc pin the current behavior so a fix has a baseline to diff against. +Status: **fixed** — the four gaps found in the original assessment are closed. The last +section records what was deliberately left alone. ## What Yggdrasil looks like to the app Yggdrasil is an encrypted end-to-end mesh. Every node gets an IPv6 address derived from its -public key inside `0200::/7`, and hands out `0300::/8` subnets. Consequences that matter here: +public key inside `0200::/7` (nodes in `0200::/8`, subnets in `0300::/8`). Consequences: -- **No DNS.** A relay on the mesh is addressed as a bracketed IPv6 literal, always. -- **No certificates.** No CA issues for `0200::/7` literals, so relays run plain `ws://`. - This is not a downgrade — the overlay already provides end-to-end encryption and - authenticates the peer by its address. +- **No DNS.** A relay on the mesh is addressed as an IPv6 literal, always. +- **No certificates.** No CA issues for `0200::/7`, so relays run plain `ws://`. Not a + downgrade — the overlay already encrypts end to end and authenticates the peer by an + address derived from its public key. - **On Android it is a `VpnService`**, so the app's default network becomes the VPN network. -- The address is a **stable node identifier**, so publishing it is equivalent to publishing - a long-lived pseudonymous handle for the device. +- `0200::/7` is deprecated NSAP space, so nothing else routes there. An address in the range + is reachable *only* through a running mesh interface — which is what makes it safe to key + behavior off the prefix. -## Verdict +## What was wrong, and what fixed it -A hand-typed `ws://[…]:port` relay works end to end: it normalizes, survives the RFC 3986 -pass, and OkHttp parses and dials it. Nothing in the stack is IPv4-only, `TcpNoDelaySocketFactory` -is family-agnostic, and `network_security_config.xml` permits cleartext globally, so the -`ws://` requirement is already satisfied. +### 1. One relay, two identities -Everything around that happy path is where it degrades. Four gaps, in severity order. +`RelayUrlNormalizer` folded hex case but not zero-compression, so +`[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]` and `[201:d0e:9ba5:8bbc::1]` stayed two distinct +`NormalizedRelayUrl`s for one host — while OkHttp collapsed both to the same host when +dialing. Since that value keys the connection pool, the relay-list sets, the NIP-11 cache and +the per-relay stat maps, the app opened two sockets to one relay and counted it twice. -### GAP 1 — one relay, two identities (correctness) +**Fix:** new `Ipv6` util (`quartz/utils/Ipv6.kt`) — pure-Kotlin parse, RFC 5952 canonical +format and range classification, no `java.net`, so it works on every KMP target. +`RelayUrlNormalizer.norm()` now canonicalizes the bracketed host. The canonical form is +byte-for-byte what OkHttp renders, so the key the app stores is the host it actually dials — +asserted differentially against OkHttp in `YggdrasilCompatCharacterizationTest`. -`RelayUrlNormalizer` folds hex case but does **not** canonicalize zero-compression or -leading zeros: +Affects every IPv6 relay, not just mesh ones; it only bit Yggdrasil users because on the mesh +a literal is the *only* way to name a relay. No migration needed: relay lists are rehydrated +from event tags through `normalizeOrNull`, so stored entries fold on load. -| input | `NormalizedRelayUrl` | OkHttp host | -|---|---|---| -| `ws://[201:d0e:9ba5:8bbc::1]:8080` | `ws://[201:d0e:9ba5:8bbc::1]:8080/` | `201:d0e:9ba5:8bbc::1` | -| `ws://[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]:8080` | `ws://[201:0d0e:…:0001]:8080/` | `201:d0e:9ba5:8bbc::1` | +### 2. Schemeless entry defaulted to `wss://` -OkHttp collapses both to one host; the app does not. `NormalizedRelayUrl` is the key of the -connection pool (`PoolRequests`, `RelayPool`), every relay-list set, the NIP-11 cache and the -per-relay stat maps — so the same relay written two ways gets **two sockets, two REQ sets and -doubled traffic**, and appears twice in the relay UI. This affects all IPv6 literals, but it -only bites Yggdrasil users in practice, because on the mesh a literal is the *only* way to -name a relay. Fix: canonicalize the bracketed literal to RFC 5952 inside `fix()`. +`isLocalHost()` knew `127.0.0.1` / `localhost` / `//umbrel:` / `192.168.` / `.local`, so a +mesh address fell through to the clearnet default and produced a `wss://` url whose TLS +handshake could never succeed. -### GAP 2 — schemeless entry defaults to `wss://` (dead end) +**Fix:** new `RelayUrlNormalizer.isOverlayNetwork()` recognizes `0200::/7` and joins +`isOnion` / `isLocalHost` in choosing `ws://`. Clearnet IPv6 (`2001:db8::1`) still gets +`wss://`. -`RelayUrlNormalizer.isLocalHost()` recognizes `127.0.0.1`, `localhost`, `//umbrel:`, -`192.168.`, `.local:` / `.local/`. An Yggdrasil address matches none of them, so a schemeless -`[201:…]:8080` falls through to the clearnet default and becomes `wss://[201:…]:8080/` — a -URL whose TLS handshake can never succeed. The user must know to type `ws://` themselves. -Fix: teach `isLocalHost()` (or a sibling `isOverlayNetwork()`) the `0200::/7` prefix. +`isLocalHost()` separately grew the IPv6 twins of the literals it already knew — `::1` +(loopback), `fc00::/7` (unique local, the 192.168. analogue) and `fe80::/10` (link-local). +Those are the same question every caller is asking, so a relay on one now correctly skips TLS +and Tor and stays out of published relay lists. -### GAP 3 — unbracketed literal is silently rejected (UX) +### 3. Unbracketed literal silently rejected -`yggdrasilctl getSelf` prints the address **unbracketed**, which is exactly what a user -copies into the "add a relay" box. `isBareHostAndPath()` rejects it (correctly — it is -ambiguous with a scheme), so `normalizeOrNull` returns null. But -`RelayUrlEditField.submitRelay()` has no else branch: the Add button just does nothing, with -no error. Fix: either auto-bracket a candidate that parses as an IPv6 address, or surface a -validation message instead of a silent no-op. +`yggdrasilctl getSelf` prints the address unbracketed — exactly what gets pasted into "add a +relay". Normalization returned null (correctly: it is ambiguous with a scheme) and +`RelayUrlEditField.submitRelay` had no else branch, so the Add button did nothing at all. -### GAP 4 — Tor routing sends mesh traffic into the SOCKS proxy (breaks the relay) +**Fix, two halves:** +- `fix()` brackets a bare literal automatically, but only when the whole string parses as an + IPv6 address — so `31990:hex:dtag` (addressable pointer), `abcd:1234` (host:port) and + `relay.example.com:8080` still fall through untouched. +- The edit field now sets `isError` and shows `relay_url_not_valid` instead of no-opping. + That fixes the dead button for *all* invalid input, not just IPv6. -`TorRelayEvaluation` classifies relays as localhost / onion / dm / trusted / new. Yggdrasil -lands in **new**, so with Tor on and the default "new relays via Tor", the relay is dialed -through the Tor SOCKS proxy — which cannot route `0200::/7`. The connection can only fail. -Compare `ws://192.168.1.100:8080/`, which is correctly kept off Tor because `isLocalHost()` -knows the LAN prefix. Today the only workaround is turning "new relays via Tor" off, which -weakens the setting for every genuine clearnet relay. The same gap exists on desktop -(`DesktopHttpClient`) and for non-relay HTTP (`RoleBasedHttpClientBuilder`). Fixing GAP 2's -prefix check fixes this one too, since both read the same predicate. +### 4. Tor routing broke mesh relays -## Propagation / privacy note (not a bug, a decision) +`TorRelayEvaluation` classified mesh relays as "new", so with Tor on and the default "new +relays via Tor" they were dialed through the SOCKS proxy — which cannot route `0200::/7`. +Guaranteed failure, not privacy. -An Yggdrasil relay is not filtered out of NIP-65 publishing (`AdvertisedRelayInfoTag` only -rejects localhost) nor out of the outbox model -(`RelayListRecommendationProcessor.filterValidRelays`). So a mesh relay in your relay list is -**published to public relays and recommended to other users**. Two effects: +**Fix:** `useTor()` returns false for `isOverlayNetwork()`, checked right after the localhost +branch. Both the Android and desktop relay paths delegate here (`TorRelayState`, +`DesktopHttpClient`), so one change covers both. `RoleBasedHttpClientBuilder` got the same +treatment for non-relay HTTP (images, previews, NIP-05, money ops). Clearnet IPv6 relays keep +following the Tor setting — asserted in `YggdrasilTorRoutingTest`. + +## Deliberately not changed + +**Mesh relays are still published and recommended.** `AdvertisedRelayInfoTag` (NIP-65) and +`RelayListRecommendationProcessor.filterValidRelays` only exclude localhost, so a mesh relay +in your relay list is still published to public relays and offered to other users via the +outbox model. Two consequences worth a maintainer's decision: - Peers not on the mesh dial `[201:…]` and burn reconnect attempts on an unreachable host. - Your Yggdrasil address — a stable, key-derived node identifier — becomes public. -Onion relays get special handling here (`hasOnionConnection` gates whether they are even -considered). An overlay-network classification would let Yggdrasil be treated the same way. +Onion relays already have precedent for both readings: they *are* published, but +`filterValidRelays` gates them behind `hasOnionConnection`. The equivalent for overlay relays +would be a `hasMeshConnection` gate. That is a product call about whether mesh relays are +meant to be discoverable, so it is flagged rather than decided here. -## Not covered +## Not verified here -- **No live socket test.** The analysis container has no IPv6 stack at all - (`AF_INET6` → `EAFNOSUPPORT`), so everything above is verified below the socket: URL - normalization, OkHttp URL/host parsing, and the Tor routing decision. An on-device run - against a real mesh relay is still needed to confirm the happy path end to end. +- **No live socket test.** The analysis container has no IPv6 stack at all (`AF_INET6` → + `EAFNOSUPPORT`), so everything is verified below the socket: normalization, OkHttp URL/host + agreement, and the Tor routing decision. An on-device run against a real mesh relay is + still needed to confirm the happy path end to end. - **Android VPN interaction untested.** `ConnectivityFlow` uses - `registerDefaultNetworkCallback`, so it follows the app's default network into the VPN. - Whether `isMeteredOrMobileData()` reads correctly through Yggdrasil's `VpnService` depends - on whether that app declares underlying networks; worth checking on device before assuming + `registerDefaultNetworkCallback`, so it follows the app into the VPN network. Whether + `isMeteredOrMobileData()` reads correctly through Yggdrasil's `VpnService` depends on + whether that app declares underlying networks — worth checking on device before assuming data-saving mode behaves. - Media loading (Coil) and NIP-05 resolution against mesh hosts were not exercised. ## Tests -- `quartz/src/jvmAndroidTest/…/relay/YggdrasilCompatCharacterizationTest.kt` — GAPs 1–3 plus - the working happy path. -- `commons/src/commonTest/…/tor/YggdrasilTorRoutingTest.kt` — GAP 4. +- `quartz/…/utils/Ipv6Test.kt` — parser, RFC 5952 formatting, range classification. +- `quartz/…/relay/YggdrasilCompatCharacterizationTest.kt` — normalization end to end, plus + the differential assertions that our identity matches the host OkHttp dials. +- `commons/…/tor/YggdrasilTorRoutingTest.kt` — overlay relays never Torified, clearnet IPv6 + still follows the setting. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt index ac3f69a1f3..0196ac0bbf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -47,3 +47,6 @@ fun NormalizedRelayUrl.toHttp() = fun NormalizedRelayUrl.isOnion() = url.contains(".onion/") fun NormalizedRelayUrl.isLocalHost() = RelayUrlNormalizer.isLocalHost(this.url) + +/** True for a relay inside an encrypted IPv6 overlay mesh. See [RelayUrlNormalizer.isOverlayNetwork]. */ +fun NormalizedRelayUrl.isOverlayNetwork() = RelayUrlNormalizer.isOverlayNetwork(this.url) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt index bc5c524482..18d83c66af 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.normalizer import androidx.collection.LruCache +import com.vitorpamplona.quartz.utils.Ipv6 import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Rfc3986 import kotlinx.coroutines.CancellationException @@ -44,7 +45,51 @@ class RelayUrlNormalizer { url.contains("//umbrel:") || url.contains("192.168.") || url.contains(".local:") || - url.contains(".local/") + url.contains(".local/") || + isPrivateIpv6(url) + + /** + * The IPv6 twins of the literals above: `::1` (127.0.0.1), `fc00::/7` unique local + * addresses (192.168.0.0/16) and `fe80::/10` link-local. All three name a host that + * only exists on this machine or this LAN, which is what every caller of [isLocalHost] + * means by the question — so a relay on one must not be Torified, must not need TLS, + * and must not be advertised to the network. + */ + private fun isPrivateIpv6(url: String): Boolean { + val bytes = ipv6HostOf(url) ?: return false + return Ipv6.isLoopback(bytes) || Ipv6.isUniqueLocal(bytes) || Ipv6.isLinkLocal(bytes) + } + + /** + * True for a relay inside an encrypted IPv6 overlay mesh — today `0200::/7`, the range + * Yggdrasil derives node addresses and subnets from. + * + * Unlike [isLocalHost] this is not a private address: it is reachable from anywhere on + * the mesh. But it is unreachable *off* the mesh, which has two consequences the relay + * stack has to honour — it can never be dialed through a SOCKS/Tor proxy, and it can + * never present a CA-issued certificate, so it speaks plain `ws://`. Both are safe: + * the overlay already encrypts end to end and authenticates the peer by its address, + * which is derived from the peer's public key. + */ + fun isOverlayNetwork(url: String): Boolean { + val bytes = ipv6HostOf(url) ?: return false + return Ipv6.isOverlayMesh(bytes) + } + + /** + * Extracts the bracketed IPv6 host of [url] as raw bytes, dropping any `%zone` suffix. + * Returns null — cheaply, on a single `indexOf` — for the overwhelmingly common case of + * a url with a DNS host. + */ + private fun ipv6HostOf(url: String): ByteArray? { + val open = url.indexOf('[') + if (open < 0) return null + val close = url.indexOf(']', open + 1) + if (close <= open + 1) return null + val zone = url.indexOf('%', open + 1) + val end = if (zone in (open + 1) until close) zone else close + return Ipv6.parse(url.substring(open + 1, end)) + } fun isOnion(url: String) = url.endsWith(".onion") || url.contains(".onion/") @@ -82,7 +127,31 @@ class RelayUrlNormalizer { return false } - private fun norm(url: String) = NormalizedRelayUrl(Rfc3986.normalize(url)) + private fun norm(url: String) = NormalizedRelayUrl(canonicalizeIpv6Host(Rfc3986.normalize(url))) + + /** + * Rewrites a bracketed IPv6 host into its RFC 5952 canonical form. + * + * RFC 4291 lets one address be spelled many ways, and the RFC 3986 pass only folds hex + * case — so `[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]` and `[201:d0e:9ba5:8bbc::1]` + * survive as two different [NormalizedRelayUrl]s for one host. That value keys the + * connection pool, the relay-list sets, the NIP-11 cache and the per-relay stats, so the + * app would dial the same relay twice and count it twice. OkHttp canonicalizes to this + * exact form when it dials, so folding here makes the stored key the host on the wire. + * + * Returns [url] itself — no allocation — when there is no literal or it is already + * canonical, which is every url with a DNS host. + */ + private fun canonicalizeIpv6Host(url: String): String { + val open = url.indexOf('[') + if (open < 0) return url + val close = url.indexOf(']', open + 1) + if (close <= open + 1) return url + val inner = url.substring(open + 1, close) + val canonical = Ipv6.canonicalizeOrNull(inner) ?: return url + if (canonical == inner) return url + return url.substring(0, open + 1) + canonical + url.substring(close) + } private fun isInvisible(c: Char) = c == '\u200B' || c == '\u200C' || c == '\u200D' || c == '\u2060' || c == '\uFEFF' @@ -267,15 +336,29 @@ class RelayUrlNormalizer { } // protocol-relative urls (`//host/`) are just missing the scheme - val bare = if (trimmed.startsWith("//")) trimmed.drop(2) else trimmed - if (bare.length < 4) return null + val protocolRelative = if (trimmed.startsWith("//")) trimmed.drop(2) else trimmed + if (protocolRelative.length < 4) return null + + // A bare IPv6 literal is missing its brackets, not malformed. This is the shape a + // user actually has in hand — `yggdrasilctl getSelf` prints the address unbracketed + // — and without the brackets `isBareHostAndPath` rejects it below as a host with too + // many colons. Only a string that parses as a whole address is bracketed, so an + // addressable-event pointer (`31990:hex:dtag`) or a `host:port` still falls through. + val bare = + if (protocolRelative[0] != '[' && Ipv6.isLiteral(protocolRelative)) { + "[$protocolRelative]" + } else { + protocolRelative + } if (!isBareHostAndPath(bare)) { Log.d("RelayUrlNormalizer") { "Rejected $url" } return null } - return if (isOnion(bare) || isLocalHost(bare)) { + // Overlay and localhost relays cannot hold a certificate, so wss:// could only ever + // fail its handshake. Both carry their own encryption, so ws:// is not a downgrade. + return if (isOnion(bare) || isLocalHost(bare) || isOverlayNetwork(bare)) { "ws://$bare" } else { "wss://$bare" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt new file mode 100644 index 0000000000..27d0d724ed --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +/** + * Pure-Kotlin IPv6 literal parsing, RFC 5952 canonical formatting and address + * classification. No `java.net`, so it works on every KMP target. + * + * Exists because relay identity is a *string*: [com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl] + * is the key of the connection pool, the relay-list sets, the NIP-11 cache and every + * per-relay stat map. RFC 4291 lets one address be written many ways + * (`[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]` and `[201:d0e:9ba5:8bbc::1]` are the same + * host), and without folding them the app treats one relay as two — two sockets, two REQ + * sets, two rows in the UI. The canonical form here matches what OkHttp renders, so the + * key the app stores is the host it actually dials. + */ +object Ipv6 { + /** Longest legal literal is 45 chars (`::ffff:` + dotted quad is shorter than 8 full groups). */ + private const val MAX_LITERAL = 45 + + /** + * Parses a bracket-less, zone-less IPv6 literal into its 16 bytes, or null when [address] + * is not a valid literal. Accepts `::` compression and a trailing dotted quad + * (`::ffff:192.168.1.1`). + */ + fun parse(address: String): ByteArray? { + val len = address.length + if (len < 2 || len > MAX_LITERAL) return null + + val out = ByteArray(16) + // Bytes written so far, counting from the left. When a `::` is present the bytes after + // it are written contiguously here and shifted to the right end at the very end. + var fill = 0 + var gapAt = -1 + var i = 0 + + if (address[0] == ':') { + if (address[1] != ':') return null + gapAt = 0 + i = 2 + if (i == len) return out + } + + while (true) { + val groupStart = i + var value = 0 + var digits = 0 + while (i < len) { + val digit = hexDigit(address[i]) + if (digit < 0) break + if (digits == 4) return null + value = (value shl 4) or digit + digits++ + i++ + } + + if (i < len && address[i] == '.') { + // Trailing dotted quad: occupies the last four bytes, so nothing may follow it. + if (fill > 12) return null + if (!parseIpv4Into(address, groupStart, len, out, fill)) return null + fill += 4 + i = len + break + } + + if (digits == 0) return null + if (fill + 2 > 16) return null + out[fill++] = (value ushr 8).toByte() + out[fill++] = value.toByte() + + if (i == len) break + if (address[i] != ':') return null + i++ + if (i == len) return null // a single trailing ':' is not a valid literal + if (address[i] == ':') { + if (gapAt >= 0) return null // only one `::` allowed + gapAt = fill + i++ + if (i == len) break + } + } + + if (gapAt < 0) { + if (fill != 16) return null + } else { + // `::` must stand for at least one omitted group. + if (fill == 16) return null + val tail = fill - gapAt + for (k in tail - 1 downTo 0) { + out[16 - tail + k] = out[gapAt + k] + out[gapAt + k] = 0 + } + } + return out + } + + /** + * RFC 5952 text form: lowercase hex, no leading zeros, and the longest run of two or more + * zero groups replaced by `::` (leftmost run wins a tie). IPv4-mapped addresses keep their + * dotted tail. This is byte-for-byte what OkHttp prints for the same address. + */ + fun format(bytes: ByteArray): String { + require(bytes.size == 16) { "An IPv6 address is 16 bytes, got ${bytes.size}" } + + var bestStart = -1 + var bestLen = 0 + var i = 0 + while (i < 16) { + if (bytes[i] == ZERO && bytes[i + 1] == ZERO) { + val runStart = i + var j = i + while (j < 16 && bytes[j] == ZERO && bytes[j + 1] == ZERO) j += 2 + if (j - runStart > bestLen) { + bestLen = j - runStart + bestStart = runStart + } + i = j + } else { + i += 2 + } + } + // A single zero group is written as `0`, never as `::`. + if (bestLen < 4) { + bestStart = -1 + bestLen = 0 + } + + val out = StringBuilder(39) + // ::ffff:a.b.c.d — IPv4-mapped addresses read as IPv4 everywhere else, so keep them that way. + if (bestStart == 0 && bestLen == 10 && bytes[10] == ALL_ONES && bytes[11] == ALL_ONES) { + out.append("::ffff:") + appendIpv4(out, bytes, 12) + return out.toString() + } + + i = 0 + while (i < 16) { + if (i == bestStart) { + out.append(':') + i += bestLen + if (i == 16) out.append(':') + } else { + if (i > 0) out.append(':') + out.append(group(bytes, i).toString(16)) + i += 2 + } + } + return out.toString() + } + + /** + * Canonicalizes a bracket-less literal, preserving any `%zone` suffix verbatim (in URLs the + * zone arrives percent-encoded, e.g. `fe80::1%25wlan0`). Returns null when [address] is not + * a valid literal. + */ + fun canonicalizeOrNull(address: String): String? { + val zoneAt = address.indexOf('%') + if (zoneAt < 0) return parse(address)?.let(::format) + val bytes = parse(address.substring(0, zoneAt)) ?: return null + return format(bytes) + address.substring(zoneAt) + } + + /** True when [address] is a valid bracket-less literal that names more than one group. */ + fun isLiteral(address: String): Boolean = address.indexOf(':') >= 0 && parse(address) != null + + /** `::1` — the IPv6 loopback, twin of 127.0.0.1. */ + fun isLoopback(bytes: ByteArray): Boolean { + for (i in 0 until 15) if (bytes[i] != ZERO) return false + return bytes[15] == ONE + } + + /** `fe80::/10` — link-local, only meaningful on the interface it came from. */ + fun isLinkLocal(bytes: ByteArray): Boolean = bytes[0] == FE.toByte() && (bytes[1].toInt() and 0xC0) == 0x80 + + /** `fc00::/7` — unique local addresses, the IPv6 twin of 192.168.0.0/16. */ + fun isUniqueLocal(bytes: ByteArray): Boolean = (bytes[0].toInt() and 0xFE) == 0xFC + + /** + * `0200::/7` — the range Yggdrasil derives node addresses (`0200::/8`) and subnets + * (`0300::/8`) from. Formally deprecated NSAP space, so nothing else routes here: an + * address in this range is reachable only through a running mesh interface, is already + * end-to-end encrypted by the overlay, and can never hold a CA-issued certificate. + */ + fun isOverlayMesh(bytes: ByteArray): Boolean = (bytes[0].toInt() and 0xFE) == 0x02 + + private fun group( + bytes: ByteArray, + at: Int, + ) = ((bytes[at].toInt() and 0xFF) shl 8) or (bytes[at + 1].toInt() and 0xFF) + + private fun appendIpv4( + out: StringBuilder, + bytes: ByteArray, + from: Int, + ) { + for (k in 0 until 4) { + if (k > 0) out.append('.') + out.append(bytes[from + k].toInt() and 0xFF) + } + } + + /** + * Parses `a.b.c.d` in `[from, to)` into four bytes at [at]. Leading zeros are rejected — + * they invite the octal reading that makes `010.1.1.1` ambiguous across resolvers. + */ + private fun parseIpv4Into( + text: String, + from: Int, + to: Int, + out: ByteArray, + at: Int, + ): Boolean { + var i = from + for (octet in 0 until 4) { + if (octet > 0) { + if (i >= to || text[i] != '.') return false + i++ + } + var value = 0 + var digits = 0 + while (i < to && text[i] in '0'..'9') { + if (digits == 3) return false + if (digits == 1 && value == 0) return false // leading zero + value = value * 10 + (text[i] - '0') + digits++ + i++ + } + if (digits == 0 || value > 255) return false + out[at + octet] = value.toByte() + } + return i == to + } + + private fun hexDigit(c: Char): Int = + when (c) { + in '0'..'9' -> c - '0' + in 'a'..'f' -> c - 'a' + 10 + in 'A'..'F' -> c - 'A' + 10 + else -> -1 + } + + private const val FE = 0xFE + private const val ZERO = 0.toByte() + private const val ONE = 1.toByte() + private const val ALL_ONES = 0xFF.toByte() +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6Test.kt new file mode 100644 index 0000000000..66d4059de4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6Test.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Ipv6Test { + private fun canonical(address: String) = Ipv6.canonicalizeOrNull(address) + + @Test + fun rfc5952CanonicalForm() { + // leading zeros suppressed, hex lowercased + assertEquals("201:d0e:9ba5:8bbc::1", canonical("201:0d0e:9ba5:8bbc:0000:0000:0000:0001")) + assertEquals("201:d0e:9ba5:8bbc::1", canonical("201:D0E:9BA5:8BBC::1")) + assertEquals("2001:db8::1", canonical("2001:0DB8:0000:0000:0000:0000:0000:0001")) + // already canonical stays put + assertEquals("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5", canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5")) + assertEquals("::", canonical("::")) + assertEquals("::1", canonical("0:0:0:0:0:0:0:1")) + } + + @Test + fun singleZeroGroupIsNotCompressed() { + // RFC 5952 §4.2.2: `::` must not stand for a single group. + assertEquals("2001:db8:0:1:1:1:1:1", canonical("2001:db8:0:1:1:1:1:1")) + } + + @Test + fun longestZeroRunWinsAndTiesGoLeft() { + assertEquals("2001:0:0:1::1", canonical("2001:0:0:1:0:0:0:1")) + // equal runs of two groups: the leftmost is the one compressed + assertEquals("2001::1:1:0:0:1", canonical("2001:0:0:1:1:0:0:1")) + } + + @Test + fun ipv4MappedKeepsDottedTail() { + assertEquals("::ffff:192.168.1.1", canonical("::ffff:192.168.1.1")) + assertEquals("::ffff:127.0.0.1", canonical("::FFFF:127.0.0.1")) + // an embedded quad that is not ipv4-mapped collapses to plain hex + assertEquals("::c0a8:101", canonical("::192.168.1.1")) + } + + @Test + fun zoneIdIsPreservedVerbatim() { + // In URLs the zone arrives percent-encoded. + assertEquals("fe80::1%25wlan0", canonical("fe80:0000:0000:0000:0000:0000:0000:0001%25wlan0")) + } + + @Test + fun rejectsMalformedLiterals() { + assertNull(canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2")) // too few groups + assertNull(canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5:1234")) // too many + assertNull(canonical("201::9ba5::1")) // two `::` + assertNull(canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5:")) // trailing colon + assertNull(canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2:gggg")) // non-hex + assertNull(canonical("201:00d0e:9ba5:8bbc::1")) // five-digit group + assertNull(canonical("192.168.1.1")) // ipv4 + assertNull(canonical("localhost")) + assertNull(canonical("::ffff:192.168.1")) // short quad + assertNull(canonical("::ffff:010.1.1.1")) // leading zero in quad + assertNull(canonical("0:0:0:0:0:0:0:0:0")) + } + + @Test + fun compressionMustCoverAtLeastOneGroup() { + // A `::` that stands for nothing is not a legal literal. + assertNull(canonical("1:2:3:4:5:6:7::8")) + } + + @Test + fun classifiesYggdrasilAndPrivateRanges() { + assertTrue(Ipv6.isOverlayMesh(Ipv6.parse("201:d0e:9ba5:8bbc::1")!!), "0200::/8 node address") + assertTrue(Ipv6.isOverlayMesh(Ipv6.parse("300:1b5d:d0e9:ba58::1")!!), "0300::/8 subnet address") + assertTrue(Ipv6.isOverlayMesh(Ipv6.parse("2ff::1")!!)) + assertFalse(Ipv6.isOverlayMesh(Ipv6.parse("2001:db8::1")!!), "documentation range is clearnet") + assertFalse(Ipv6.isOverlayMesh(Ipv6.parse("400::1")!!), "just past 0200::/7") + assertFalse(Ipv6.isOverlayMesh(Ipv6.parse("::1")!!)) + + assertTrue(Ipv6.isLoopback(Ipv6.parse("::1")!!)) + assertFalse(Ipv6.isLoopback(Ipv6.parse("::2")!!)) + assertFalse(Ipv6.isLoopback(Ipv6.parse("::")!!)) + + assertTrue(Ipv6.isLinkLocal(Ipv6.parse("fe80::1")!!)) + assertTrue(Ipv6.isLinkLocal(Ipv6.parse("febf::1")!!)) + assertFalse(Ipv6.isLinkLocal(Ipv6.parse("fec0::1")!!)) + + assertTrue(Ipv6.isUniqueLocal(Ipv6.parse("fd00::1")!!)) + assertTrue(Ipv6.isUniqueLocal(Ipv6.parse("fc00::1")!!)) + assertFalse(Ipv6.isUniqueLocal(Ipv6.parse("fe00::1")!!)) + } + + @Test + fun isLiteralDiscriminatesAgainstNonAddresses() { + assertTrue(Ipv6.isLiteral("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5")) + assertTrue(Ipv6.isLiteral("201:d0e:9ba5:8bbc::1")) + // Things a relay-url field realistically receives, none of which may pass as an address. + assertFalse(Ipv6.isLiteral("relay.example.com:8080")) + assertFalse(Ipv6.isLiteral("wss:")) + assertFalse(Ipv6.isLiteral("localhost:4869")) + assertFalse(Ipv6.isLiteral("31990:abcdef:mydtag"), "addressable event pointer") + assertFalse(Ipv6.isLiteral("abcd:1234")) + assertFalse(Ipv6.isLiteral("nos.lol")) + } + + @Test + fun roundTripsEveryFormOfTheSameAddress() { + val forms = + listOf( + "201:d0e:9ba5:8bbc:0:0:0:1", + "201:0d0e:9ba5:8bbc:0000:0000:0000:0001", + "201:d0e:9ba5:8bbc::1", + "201:D0E:9BA5:8BBC::0001", + ) + val canonicalForms = forms.map { canonical(it) }.toSet() + assertEquals(setOf("201:d0e:9ba5:8bbc::1"), canonicalForms) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt index 638858dbd8..160ca7c9e2 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt @@ -22,29 +22,30 @@ package com.vitorpamplona.quartz.nip01Core.relay import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOverlayNetwork import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import okhttp3.Request import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotEquals -import kotlin.test.assertNull +import kotlin.test.assertNotNull import kotlin.test.assertTrue /** - * Characterization of how relay URLs on an Yggdrasil overlay behave today. + * Relay URLs on an Yggdrasil overlay, end to end through the normalizer. * - * Yggdrasil gives every node an IPv6 address inside `0200::/7` (nodes) and hands out - * `0300::/8` subnets, with no DNS and no CA-issuable certificate. A relay on the mesh is - * therefore always reached as a **bracketed IPv6 literal over plain `ws://`** — a shape - * the relay stack only partially handles. + * Yggdrasil gives every node an IPv6 address inside `0200::/7` (nodes in `0200::/8`, subnets in + * `0300::/8`), with no DNS and no CA-issuable certificate. A relay on the mesh is therefore + * always a **bracketed IPv6 literal over plain `ws://`**. * - * These tests document the CURRENT behavior (including the gaps) so a later fix has a - * baseline to diff against. Each gap is marked GAP with what a user sees. + * The differential assertions against OkHttp are the point of this file living in + * `jvmAndroidTest`: OkHttp is what actually dials the socket, so a normalized url that + * disagrees with OkHttp's own canonical host is a relay the app tracks under a name it does + * not connect to. */ class YggdrasilCompatCharacterizationTest { - // Same node, three legal RFC 4291 spellings of one address. + // Same node, several legal RFC 4291 spellings of one address. private val canonical = "ws://[201:d0e:9ba5:8bbc::1]:8080" private val expanded = "ws://[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]:8080" private val uppercase = "ws://[201:D0E:9BA5:8BBC::1]:8080" @@ -66,51 +67,90 @@ class YggdrasilCompatCharacterizationTest { } @Test - fun yggdrasilSubnetAddressesAndUppercaseHexWork() { + fun yggdrasilSubnetAddressesWork() { assertEquals("ws://[300:1b5d:d0e9:ba58::1]:4848/", "ws://[300:1b5d:d0e9:ba58::1]:4848".normalizeRelayUrl().url) - // Hex case IS folded, so the uppercase spelling collapses onto the canonical one. - assertEquals(canonical.normalizeRelayUrl(), uppercase.normalizeRelayUrl()) } /** - * GAP 1 — zero-compression is NOT canonicalized, so one relay gets two identities. - * - * `NormalizedRelayUrl` is the key of the connection pool, the relay-list sets, the NIP-11 - * cache and every per-relay stat map. OkHttp collapses both spellings to one host (below), - * so the app opens two sockets to the same relay and counts it twice everywhere. + * Every legal spelling of one address collapses to one [NormalizedRelayUrl] — the key of the + * connection pool, the relay-list sets, the NIP-11 cache and the per-relay stat maps. Without + * this the app dials one relay twice and shows it twice. */ @Test - fun gapZeroCompressionSplitsOneRelayIntoTwoIdentities() { - assertNotEquals(canonical.normalizeRelayUrl(), expanded.normalizeRelayUrl()) - // ...even though they are literally the same host on the wire: - assertEquals(host(canonical), host(expanded)) + fun everySpellingOfOneAddressIsOneRelay() { + val identities = listOf(canonical, expanded, uppercase).map { it.normalizeRelayUrl() }.toSet() + assertEquals(setOf("ws://[201:d0e:9ba5:8bbc::1]:8080/"), identities.map { it.url }.toSet()) + // ...and that one identity is the host OkHttp dials for all of them. + assertEquals(setOf("201:d0e:9ba5:8bbc::1"), listOf(canonical, expanded, uppercase).map { host(it) }.toSet()) + } + + @Test + fun normalizedIdentityAlwaysMatchesTheHostOkHttpDials() { + listOf( + "ws://[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]:8080", + "ws://[300:1b5d:d0e9:ba58:0:0:0:1]:4848", + "ws://[2001:0DB8:0000:0000:0000:0000:0000:0001]:7777", + "ws://[::1]:4869", + ).forEach { raw -> + val normalized = raw.normalizeRelayUrl().url + assertEquals(host(normalized), host(raw), "identity for $raw disagrees with the dialed host") + } } /** - * GAP 2 — a schemeless IPv6 literal defaults to `wss://`. - * - * `isLocalHost()` only knows 127.0.0.1 / localhost / umbrel / 192.168. / .local, so an - * Yggdrasil address falls through to the clearnet default. No CA issues certificates for - * `0200::/7` literals, so the resulting wss:// url can only ever fail its TLS handshake. + * A schemeless overlay address defaults to `ws://`: no CA issues certificates for + * `0200::/7`, so `wss://` could only ever fail its handshake. The mesh already encrypts + * end to end, so this is not a downgrade. */ @Test - fun gapSchemelessYggdrasilAddressDefaultsToWss() { - assertEquals("wss://[201:d0e:9ba5:8bbc::1]:8080/", "[201:d0e:9ba5:8bbc::1]:8080".normalizeRelayUrl().url) + fun schemelessOverlayAddressDefaultsToWs() { + assertEquals("ws://[201:d0e:9ba5:8bbc::1]:8080/", "[201:d0e:9ba5:8bbc::1]:8080".normalizeRelayUrl().url) + assertTrue("ws://[201:d0e:9ba5:8bbc::1]:8080/".normalizeRelayUrl().isOverlayNetwork()) + // A clearnet IPv6 relay keeps requiring TLS. + assertEquals("wss://[2001:db8::1]:8080/", "[2001:db8::1]:8080".normalizeRelayUrl().url) + assertFalse("wss://[2001:db8::1]:8080/".normalizeRelayUrl().isOverlayNetwork()) + } + + /** + * `::1`, `fc00::/7` and `fe80::/10` are the IPv6 twins of 127.0.0.1 and 192.168., so they + * answer [isLocalHost] the same way — no TLS, no Tor, never advertised to the network. + */ + @Test + fun ipv6LoopbackAndPrivateRangesCountAsLocalHost() { + assertEquals("ws://[::1]:4869/", "[::1]:4869".normalizeRelayUrl().url) + assertTrue("ws://[::1]:4869/".normalizeRelayUrl().isLocalHost()) + assertTrue("ws://[fd12:3456::1]:8080/".normalizeRelayUrl().isLocalHost(), "unique local address") + assertTrue("ws://[fe80::1]:8080/".normalizeRelayUrl().isLocalHost(), "link local address") + assertFalse("wss://[2001:db8::1]:8080/".normalizeRelayUrl().isLocalHost(), "clearnet ipv6") + // An overlay relay is reachable across the mesh, so it is NOT localhost. assertFalse("ws://[201:d0e:9ba5:8bbc::1]:8080/".normalizeRelayUrl().isLocalHost()) } /** - * GAP 3 — an unbracketed IPv6 literal is rejected outright. - * - * `yggdrasilctl getSelf` prints the address unbracketed, which is what a user copies into - * the "add a relay" field. Normalization returns null and `RelayUrlEditField.submitRelay` - * has no else branch, so the Add button silently does nothing. + * `yggdrasilctl getSelf` prints the address unbracketed, which is what a user pastes into + * the "add a relay" field. It is bracketed automatically rather than rejected. */ @Test - fun gapUnbracketedYggdrasilAddressIsRejected() { - assertNull(RelayUrlNormalizer.normalizeOrNull("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5")) - assertNull(RelayUrlNormalizer.normalizeOrNull("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5:8080")) - // Bracketing it by hand is the only accepted form. - assertTrue(RelayUrlNormalizer.normalizeOrNull("[201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5]:8080") != null) + fun bareUnbracketedLiteralIsBracketedAutomatically() { + assertEquals( + "ws://[201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5]/", + "201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5".normalizeRelayUrl().url, + ) + assertEquals("ws://[201:d0e:9ba5:8bbc::1]/", "201:d0e:9ba5:8bbc::1".normalizeRelayUrl().url) + assertNotNull(RelayUrlNormalizer.normalizeOrNull("[201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5]:8080")) + } + + /** + * Auto-bracketing must not swallow the other colon-bearing strings that reach the + * normalizer. Only a string that parses as a whole IPv6 address is bracketed. + */ + @Test + fun autoBracketingDoesNotCaptureNonAddresses() { + assertEquals("wss://relay.example.com:8080/", "relay.example.com:8080".normalizeRelayUrl().url) + assertEquals("ws://localhost:4869/", "localhost:4869".normalizeRelayUrl().url) + // addressable-event pointer, not a relay + assertEquals(null, RelayUrlNormalizer.normalizeOrNull("31990:abcdef:mydtag")) + // two hex-looking groups are a host and a port, not an address + assertEquals("wss://abcd:1234/", "abcd:1234".normalizeRelayUrl().url) } } From 70d51cc98b8bea7e6ed3ad2d90573ab6c212b986 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 04:22:59 +0000 Subject: [PATCH 118/227] fix(relay): parse the authority instead of substring-matching the url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the IPv6 work found a family of bugs in isLocalHost/isOnion, most predating this branch, all with one root cause: the predicates ran `contains` over the whole url rather than parsing the authority. These decide whether a relay is exempt from Tor, and relay urls arrive from other people (NIP-65 lists, relay hints, r tags), so they are attacker-controlled input. - A path could impersonate the host. `wss://evil.example.com/127.0.0.1` answered isLocalHost() == true, so any relay list could hand the app a url that silently dropped its own Tor routing. The IPv6 lookup added earlier on this branch had the same flaw via `/[fd00::1]`, and IPv6 canonicalization could rewrite a path outright, corrupting the url. - `.onion:8080` never matched the `.onion/` test, so an onion relay on an explicit port was not treated as onion at all: never forced onto Tor, and its hostname went to the clearnet DNS resolver. The fully-qualified `.onion.` spelling missed the same way. - Host tests were case-sensitive, but fix() asks them before the RFC 3986 pass folds case, so LOCALHOST:8080 and ABC.ONION:8080 were handed a wss:// scheme neither host can serve. - Private IPv4 was substring-matched, which missed 10.0.0.5, 172.16.3.4 and 127.1.2.3 — a LAN relay got wss:// and was dialed through Tor — while matching 192.168.evil.com and 127.0.0.1.evil.com, registrable domains that could therefore exempt themselves from Tor. Same for notlocalhost.example.com against `contains("localhost")`. - A `://` inside a path was read as a scheme separator, so `relay.com/x://127.0.0.1` read its path as the authority. Fixes: a shared hostStart/hostEnd/hostEndWithoutPort trio bounds every test to the authority, strips :port and trailing dots and validates the scheme; private ranges are parsed via a new Ipv4 util rather than substring-matched; comparisons are case-insensitive per RFC 4343; NormalizedRelayUrl.isOnion() delegates instead of keeping a second, weaker copy of the test. No performance regression: the old form ran six full-string scans, the new one bounds its work to the authority and rejects a DNS host from an IP parse on one character. Ipv6.isLiteral gained a two-colon gate so the schemeless host:port case answers without allocating the parser's buffer. Ipv6 is now pinned by a differential test: 4000 random addresses round-trip against java.net.InetAddress in both directions, and the canonical form is asserted equal to OkHttp's host for the same address, so the relay identity the app stores provably matches the host it dials. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk --- .../relays/common/RelayUrlEditField.kt | 19 +- .../plans/2026-08-04-yggdrasil-ipv6-relays.md | 40 ++++ .../relay/normalizer/NormalizedRelayUrl.kt | 4 +- .../relay/normalizer/RelayUrlNormalizer.kt | 191 +++++++++++++++--- .../com/vitorpamplona/quartz/utils/Ipv4.kt | 99 +++++++++ .../com/vitorpamplona/quartz/utils/Ipv6.kt | 51 ++--- .../relay/RelayUrlAuthorityAnchoringTest.kt | 186 +++++++++++++++++ .../quartz/utils/Ipv6DifferentialTest.kt | 108 ++++++++++ 8 files changed, 623 insertions(+), 75 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv4.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlAuthorityAnchoringTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6DifferentialTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index d523058cfb..963ddb3724 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -200,14 +200,19 @@ fun RelayUrlEditField( relaySuggestions.processInput(it) }, isError = isInvalid, - supportingText = { + // Null, not an empty lambda: a non-null slot reserves its line height even when it + // draws nothing, which would pad the field permanently for every user. + supportingText = if (isInvalid) { - Text( - text = stringRes(R.string.relay_url_not_valid), - color = MaterialTheme.colorScheme.error, - ) - } - }, + { + Text( + text = stringRes(R.string.relay_url_not_valid), + color = MaterialTheme.colorScheme.error, + ) + } + } else { + null + }, placeholder = { Text( text = "server.com", diff --git a/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md b/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md index 33d8aaef31..412210a81b 100644 --- a/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md +++ b/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md @@ -77,6 +77,46 @@ branch. Both the Android and desktop relay paths delegate here (`TorRelayState`, treatment for non-relay HTTP (images, previews, NIP-05, money ops). Clearnet IPv6 relays keep following the Tor setting — asserted in `YggdrasilTorRoutingTest`. +## Audit round: bugs found in the host predicates + +Auditing the change above turned up a family of bugs in `isLocalHost` / `isOnion` that predate +it. All shared one root cause — the predicates ran `contains` over the **whole url** instead of +parsing the authority — and all are now anchored, parsed and covered by +`RelayUrlAuthorityAnchoringTest`. + +These predicates decide whether a relay is exempt from Tor, and relay urls arrive from other +people (NIP-65 lists, relay hints, `r` tags), so they are attacker-controlled input. + +| # | Bug | Effect | +|---|---|---| +| 1 | A path could impersonate the host: `wss://evil.example.com/127.0.0.1` answered `isLocalHost() == true` | Any relay list could hand the app a url that silently dropped its own Tor routing | +| 2 | `.onion:8080` never matched the `.onion/` test | An onion relay on an explicit port was not treated as onion — never forced onto Tor, hostname sent to the clearnet DNS resolver | +| 3 | `.onion.` / `localhost.` (RFC 1034 fully-qualified form) matched nothing | Same leak as #2, via a different spelling | +| 4 | Host tests were case-sensitive, but `fix()` runs *before* the RFC 3986 pass folds case | `LOCALHOST:8080` and `ABC.ONION:8080` were given a `wss://` scheme neither host can serve | +| 5 | Private IPv4 was substring-matched | `10.0.0.5`, `172.16.3.4`, `127.1.2.3` were not local (LAN relay got `wss://` and Tor), while `192.168.evil.com` — a registrable domain — was | +| 6 | `contains("localhost")` matched `notlocalhost.example.com` | Same Tor exemption as #1, via a registrable domain | +| 7 | A `://` inside a path was read as a scheme separator | `relay.com/x://127.0.0.1` read its path as the authority | + +Two bugs were introduced by this branch and caught in the same pass: the IPv6 host lookup had +the #1 flaw (`wss://evil.example.com/[fd00::1]` read as localhost), and IPv6 canonicalization +could rewrite a **path** (`/x[0:0:0:0:0:0:0:1]y` → `/x[::1]y`), corrupting the url. + +Fixes: a shared `hostStart` / `hostEnd` / `hostEndWithoutPort` trio bounds every test to the +authority, strips `:port` and trailing dots, and validates the scheme; private ranges are +parsed via the new `Ipv4` util and `Ipv6` rather than substring-matched; comparisons are +case-insensitive per RFC 4343; `NormalizedRelayUrl.isOnion()` now delegates instead of keeping +a second, weaker copy of the test. + +Performance: no regression, likely a small win. The old form ran six full-string `contains` +scans; the new one bounds its work to the authority and rejects a DNS host from an IP parse on +a single character. `Ipv6.isLiteral` gained a two-colon gate so the schemeless `host:port` case +answers without allocating the parser's 16-byte buffer. + +`Ipv6` itself is pinned by `Ipv6DifferentialTest`: 4000 random addresses round-trip against +`java.net.InetAddress` in both directions, and the canonical form is asserted equal to +OkHttp's host for the same address — so the relay identity the app stores provably matches the +host it dials. + ## Deliberately not changed **Mesh relays are still published and recommended.** `AdvertisedRelayInfoTag` (NIP-65) and diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt index 0196ac0bbf..5215e1f473 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -44,7 +44,9 @@ fun NormalizedRelayUrl.toHttp() = "https://$url" } -fun NormalizedRelayUrl.isOnion() = url.contains(".onion/") +// Delegates rather than re-implementing `contains(".onion/")`: that copy missed +// `wss://host.onion:8080/`, so an onion relay on an explicit port was never forced onto Tor. +fun NormalizedRelayUrl.isOnion() = RelayUrlNormalizer.isOnion(this.url) fun NormalizedRelayUrl.isLocalHost() = RelayUrlNormalizer.isLocalHost(this.url) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt index 18d83c66af..5d68b066f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.normalizer import androidx.collection.LruCache +import com.vitorpamplona.quartz.utils.Ipv4 import com.vitorpamplona.quartz.utils.Ipv6 import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Rfc3986 @@ -39,25 +40,60 @@ val normalizedUrls = LruCache(5000) class RelayUrlNormalizer { companion object { - fun isLocalHost(url: String) = - url.contains("127.0.0.1") || - url.contains("localhost") || - url.contains("//umbrel:") || - url.contains("192.168.") || - url.contains(".local:") || - url.contains(".local/") || - isPrivateIpv6(url) + /** + * Every host test below is anchored to the **authority** (`host[:port]`), never to the + * whole url. A plain `contains` reads the path and query too, so + * `wss://evil.example.com/127.0.0.1` used to answer true here — and since [isLocalHost] + * is what exempts a relay from Tor, any relay list could hand the app a url that quietly + * dropped its own Tor routing. Relay urls arrive from other people (NIP-65 lists, relay + * hints, `r` tags), so they are attacker-controlled input and have to be parsed as such. + * + * Anchoring is also what makes `host:port` work: `.onion:8080` never matched the old + * `.onion/` test, so an onion relay on an explicit port was not recognized as onion at + * all and its hostname went to the clearnet DNS resolver. + */ + fun isLocalHost(url: String): Boolean { + val start = hostStart(url) + val end = hostEnd(url, start) + if (end <= start) return false + val hostEnd = hostEndWithoutPort(url, start, end) + return isPrivateIpv4(url, start, hostEnd) || + // RFC 6761: `localhost` and anything under it — the same rule SurgeDns applies + // when deciding whether a loopback answer is legitimate. A substring test would + // also match `notlocalhost.example.com`, which is registrable. + regionEquals(url, "localhost", start, hostEnd) || + regionEndsWith(url, ".localhost", start, hostEnd) || + regionEquals(url, "umbrel", start, hostEnd) || + regionEndsWith(url, ".local", start, hostEnd) || + isPrivateIpv6(url, start, end) + } /** - * The IPv6 twins of the literals above: `::1` (127.0.0.1), `fc00::/7` unique local - * addresses (192.168.0.0/16) and `fe80::/10` link-local. All three name a host that - * only exists on this machine or this LAN, which is what every caller of [isLocalHost] - * means by the question — so a relay on one must not be Torified, must not need TLS, - * and must not be advertised to the network. + * The IPv4 ranges that are never a public relay: `127.0.0.0/8` loopback, the RFC 1918 + * private blocks, `169.254.0.0/16` link-local and `0.0.0.0/8`. + * + * Parsed rather than substring-matched, which was wrong both ways: `contains("192.168.")` + * missed `10.0.0.5` and `172.16.3.4` — so a LAN relay was given `wss://` and dialed + * through Tor — while matching `192.168.evil.com`, a registrable domain that could + * therefore exempt itself from Tor. */ - private fun isPrivateIpv6(url: String): Boolean { - val bytes = ipv6HostOf(url) ?: return false - return Ipv6.isLoopback(bytes) || Ipv6.isUniqueLocal(bytes) || Ipv6.isLinkLocal(bytes) + private fun isPrivateIpv4( + url: String, + start: Int, + end: Int, + ): Boolean { + val bytes = Ipv4.parse(url, start, end) ?: return false + return Ipv4.isLoopback(bytes) || + Ipv4.isPrivate(bytes) || + Ipv4.isLinkLocal(bytes) || + Ipv4.isUnspecified(bytes) + } + + fun isOnion(url: String): Boolean { + val start = hostStart(url) + val end = hostEnd(url, start) + if (end <= start) return false + return regionEndsWith(url, ".onion", start, hostEndWithoutPort(url, start, end)) } /** @@ -72,26 +108,115 @@ class RelayUrlNormalizer { * which is derived from the peer's public key. */ fun isOverlayNetwork(url: String): Boolean { - val bytes = ipv6HostOf(url) ?: return false + val start = hostStart(url) + val bytes = ipv6HostOf(url, start, hostEnd(url, start)) ?: return false return Ipv6.isOverlayMesh(bytes) } /** - * Extracts the bracketed IPv6 host of [url] as raw bytes, dropping any `%zone` suffix. - * Returns null — cheaply, on a single `indexOf` — for the overwhelmingly common case of - * a url with a DNS host. + * The IPv6 twins of the literals in [isLocalHost]: `::1` (127.0.0.1), `fc00::/7` unique + * local addresses (192.168.0.0/16) and `fe80::/10` link-local. All three name a host that + * only exists on this machine or this LAN, which is what every caller of [isLocalHost] + * means by the question — so a relay on one must not be Torified, must not need TLS, + * and must not be advertised to the network. */ - private fun ipv6HostOf(url: String): ByteArray? { - val open = url.indexOf('[') - if (open < 0) return null - val close = url.indexOf(']', open + 1) - if (close <= open + 1) return null - val zone = url.indexOf('%', open + 1) - val end = if (zone in (open + 1) until close) zone else close - return Ipv6.parse(url.substring(open + 1, end)) + private fun isPrivateIpv6( + url: String, + start: Int, + end: Int, + ): Boolean { + val bytes = ipv6HostOf(url, start, end) ?: return false + return Ipv6.isLoopback(bytes) || Ipv6.isUniqueLocal(bytes) || Ipv6.isLinkLocal(bytes) } - fun isOnion(url: String) = url.endsWith(".onion") || url.contains(".onion/") + /** + * Parses the authority of [url] as a bracketed IPv6 literal, dropping any `%zone` suffix. + * Returns null — on a single char comparison — for the overwhelmingly common case of a + * url with a DNS host. + */ + private fun ipv6HostOf( + url: String, + start: Int, + end: Int, + ): ByteArray? { + if (start >= end || url[start] != '[') return null + val close = url.indexOf(']', start + 1) + if (close < 0 || close >= end || close <= start + 1) return null + val zone = url.indexOf('%', start + 1) + val addressEnd = if (zone in (start + 1) until close) zone else close + return Ipv6.parse(url.substring(start + 1, addressEnd)) + } + + /** + * Index of the first char of the authority: past `://`, or 0 for a schemeless host. + * + * The `://` only counts when what precedes it is a real RFC 3986 scheme + * (`ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`). Otherwise `relay.com/x://127.0.0.1` + * would have its *path* read as the authority and answer true to [isLocalHost]. + */ + private fun hostStart(url: String): Int { + val scheme = url.indexOf("://") + if (scheme <= 0 || !url[0].isLetter()) return 0 + for (i in 1 until scheme) { + val c = url[i] + if (!c.isLetterOrDigit() && c != '+' && c != '-' && c != '.') return 0 + } + return scheme + 3 + } + + /** Index just past the authority — the first `/`, `?` or `#`, or the end of [url]. */ + private fun hostEnd( + url: String, + start: Int, + ): Int { + var i = start + while (i < url.length) { + val c = url[i] + if (c == '/' || c == '?' || c == '#') return i + i++ + } + return i + } + + /** + * [end] trimmed back past a `:port` and any trailing dots, so the host tests see the + * name alone. RFC 1034's fully-qualified form ends in a dot (`abc.onion.`), and missing + * that spelling on [isOnion] would send a `.onion` name to the clearnet DNS resolver. + */ + private fun hostEndWithoutPort( + url: String, + start: Int, + end: Int, + ): Int { + var stop = + if (url[start] == '[') { + // In an IPv6 authority only the `:` after the `]` can start a port. + val close = url.indexOf(']', start + 1) + if (close in start until end) close + 1 else end + } else { + val colon = url.lastIndexOf(':', end - 1) + if (colon >= start) colon else end + } + while (stop > start && url[stop - 1] == '.') stop-- + return stop + } + + // Host names are case-insensitive (RFC 4343), and `fix()` asks these questions *before* + // the RFC 3986 pass folds the case — so a case-sensitive test gave `LOCALHOST:8080` and + // `ABC.ONION:8080` a `wss://` scheme neither host can ever serve. + private fun regionEndsWith( + url: String, + suffix: String, + start: Int, + end: Int, + ): Boolean = end - start >= suffix.length && url.regionMatches(end - suffix.length, suffix, 0, suffix.length, ignoreCase = true) + + private fun regionEquals( + url: String, + name: String, + start: Int, + end: Int, + ): Boolean = end - start == name.length && url.regionMatches(start, name, 0, name.length, ignoreCase = true) fun isRelaySchemePrefix(url: String) = url.length > 6 && url[0] == 'w' && url[1] == 's' @@ -143,10 +268,12 @@ class RelayUrlNormalizer { * canonical, which is every url with a DNS host. */ private fun canonicalizeIpv6Host(url: String): String { - val open = url.indexOf('[') - if (open < 0) return url + // Anchored to the authority: a `[...]` in a path or query is data, and rewriting it + // would silently corrupt the url. + val open = hostStart(url) + if (open >= url.length || url[open] != '[') return url val close = url.indexOf(']', open + 1) - if (close <= open + 1) return url + if (close <= open + 1 || close >= hostEnd(url, open)) return url val inner = url.substring(open + 1, close) val canonical = Ipv6.canonicalizeOrNull(inner) ?: return url if (canonical == inner) return url diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv4.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv4.kt new file mode 100644 index 0000000000..41bc1c34d1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv4.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +/** + * Pure-Kotlin IPv4 literal parsing and range classification, the companion to [Ipv6]. + * + * Exists because asking "is this host private?" with `url.contains("192.168.")` is wrong in + * both directions: it misses `10.0.0.5` and `172.16.3.4` (so a LAN relay gets `wss://` and is + * dialed through Tor) and it matches `192.168.evil.com`, a perfectly registrable domain (so a + * hostile relay url can exempt itself from Tor). Parsing the host and testing the range is the + * only form of the question that has a right answer. + */ +object Ipv4 { + /** Parses a dotted quad in `[from, to)`, or null when the region is not one. */ + fun parse( + text: String, + from: Int, + to: Int, + ): ByteArray? { + // Cheapest possible rejection of a DNS host: a literal always starts with a digit. + if (from >= to || text[from] !in '0'..'9') return null + val out = ByteArray(4) + return if (parseInto(text, from, to, out, 0)) out else null + } + + /** + * Parses `a.b.c.d` in `[from, to)` into four bytes at [at]. Leading zeros are rejected — + * they invite the octal reading that makes `010.1.1.1` ambiguous across resolvers. + */ + fun parseInto( + text: String, + from: Int, + to: Int, + out: ByteArray, + at: Int, + ): Boolean { + var i = from + for (octet in 0 until 4) { + if (octet > 0) { + if (i >= to || text[i] != '.') return false + i++ + } + var value = 0 + var digits = 0 + while (i < to && text[i] in '0'..'9') { + if (digits == 3) return false + if (digits == 1 && value == 0) return false // leading zero + value = value * 10 + (text[i] - '0') + digits++ + i++ + } + if (digits == 0 || value > 255) return false + out[at + octet] = value.toByte() + } + return i == to + } + + /** `127.0.0.0/8` — the whole loopback block, not just 127.0.0.1. */ + fun isLoopback(bytes: ByteArray): Boolean = octet(bytes, 0) == 127 + + /** RFC 1918: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`. */ + fun isPrivate(bytes: ByteArray): Boolean { + val first = octet(bytes, 0) + val second = octet(bytes, 1) + return first == 10 || + (first == 172 && second in 16..31) || + (first == 192 && second == 168) + } + + /** `169.254.0.0/16` — link-local / APIPA, reachable only on the local segment. */ + fun isLinkLocal(bytes: ByteArray): Boolean = octet(bytes, 0) == 169 && octet(bytes, 1) == 254 + + /** `0.0.0.0/8` — "this network"; never a routable relay. */ + fun isUnspecified(bytes: ByteArray): Boolean = octet(bytes, 0) == 0 + + private fun octet( + bytes: ByteArray, + at: Int, + ) = bytes[at].toInt() and 0xFF +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt index 27d0d724ed..ec00f67a46 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt @@ -75,7 +75,7 @@ object Ipv6 { if (i < len && address[i] == '.') { // Trailing dotted quad: occupies the last four bytes, so nothing may follow it. if (fill > 12) return null - if (!parseIpv4Into(address, groupStart, len, out, fill)) return null + if (!Ipv4.parseInto(address, groupStart, len, out, fill)) return null fill += 4 i = len break @@ -178,8 +178,21 @@ object Ipv6 { return format(bytes) + address.substring(zoneAt) } - /** True when [address] is a valid bracket-less literal that names more than one group. */ - fun isLiteral(address: String): Boolean = address.indexOf(':') >= 0 && parse(address) != null + /** + * True when [address] is a valid bracket-less literal. + * + * The two-colon gate is what keeps this off the normalizer's hot path: every schemeless + * `host:port` reaching [com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer] + * has exactly one colon, and a literal needs at least two, so the common case answers + * without entering [parse] and allocating its 16-byte buffer. + */ + fun isLiteral(address: String): Boolean { + val firstColon = address.indexOf(':') + if (firstColon < 0 || address.indexOf(':', firstColon + 1) < 0) return false + // A zone id is part of the literal (`fe80::1%25eth0`), not a reason to reject it. + val zoneAt = address.indexOf('%') + return parse(if (zoneAt < 0) address else address.substring(0, zoneAt)) != null + } /** `::1` — the IPv6 loopback, twin of 127.0.0.1. */ fun isLoopback(bytes: ByteArray): Boolean { @@ -217,38 +230,6 @@ object Ipv6 { } } - /** - * Parses `a.b.c.d` in `[from, to)` into four bytes at [at]. Leading zeros are rejected — - * they invite the octal reading that makes `010.1.1.1` ambiguous across resolvers. - */ - private fun parseIpv4Into( - text: String, - from: Int, - to: Int, - out: ByteArray, - at: Int, - ): Boolean { - var i = from - for (octet in 0 until 4) { - if (octet > 0) { - if (i >= to || text[i] != '.') return false - i++ - } - var value = 0 - var digits = 0 - while (i < to && text[i] in '0'..'9') { - if (digits == 3) return false - if (digits == 1 && value == 0) return false // leading zero - value = value * 10 + (text[i] - '0') - digits++ - i++ - } - if (digits == 0 || value > 255) return false - out[at + octet] = value.toByte() - } - return i == to - } - private fun hexDigit(c: Char): Int = when (c) { in '0'..'9' -> c - '0' diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlAuthorityAnchoringTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlAuthorityAnchoringTest.kt new file mode 100644 index 0000000000..7d91ab2542 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlAuthorityAnchoringTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [RelayUrlNormalizer.isLocalHost] and [RelayUrlNormalizer.isOnion] decide whether a relay is + * exempt from Tor, so they must read the authority and nothing else. Relay urls arrive from + * other people — NIP-65 lists, relay hints, `r` tags — so a url whose *path* can flip those + * answers is a url that can drop a Tor user's protection. + */ +class RelayUrlAuthorityAnchoringTest { + @Test + fun aPathCannotMakeAForeignHostLookLocal() { + listOf( + "wss://evil.example.com/127.0.0.1", + "wss://evil.example.com/localhost", + "wss://evil.example.com/192.168.1.1", + "wss://evil.example.com/umbrel", + "wss://evil.example.com/x.local/y", + "wss://evil.example.com/[fd00::1]", + "wss://evil.example.com/[::1]", + "wss://evil.example.com/?q=127.0.0.1", + "wss://evil.example.com/?q=[::1]", + ).forEach { + assertFalse(RelayUrlNormalizer.isLocalHost(it), "$it must not read as localhost") + } + } + + @Test + fun aPathCannotMakeAForeignHostLookLikeAnOverlayOrOnion() { + assertFalse(RelayUrlNormalizer.isOverlayNetwork("wss://evil.example.com/[201:d0e:9ba5:8bbc::1]")) + assertFalse(RelayUrlNormalizer.isOnion("wss://evil.example.com/?u=http://nos.lol/.onion/")) + assertFalse(RelayUrlNormalizer.isOnion("wss://evil.example.com/abc.onion")) + } + + @Test + fun realLocalAndOnionHostsStillMatch() { + listOf( + "ws://127.0.0.1:8080/", + "ws://localhost:4869/", + "ws://umbrel:4848/", + "ws://192.168.1.100:8080/", + "ws://myrelay.local:8080/", + "ws://myrelay.local/", + "ws://foo.localhost:8080/", + "ws://[::1]:4869/", + "ws://[fd12:3456::1]:8080/", + "ws://[fe80::1]/", + ).forEach { + assertTrue(RelayUrlNormalizer.isLocalHost(it), "$it must read as localhost") + } + // schemeless, as fix() sees it before choosing ws:// vs wss:// + assertTrue(RelayUrlNormalizer.isLocalHost("127.0.0.1:8080")) + assertTrue(RelayUrlNormalizer.isLocalHost("umbrel:4848")) + } + + /** + * `.onion:8080` never matched the old `.onion/` test, so an onion relay on an explicit port + * was not recognized as onion — it skipped the forced-Tor branch and its hostname went to + * the clearnet DNS resolver. + */ + @Test + fun onionRelaysOnAnExplicitPortAreRecognized() { + assertTrue(RelayUrlNormalizer.isOnion("wss://abc123.onion:8080/")) + assertTrue(RelayUrlNormalizer.isOnion("wss://abc123.onion/")) + assertTrue(RelayUrlNormalizer.isOnion("abc123.onion:8080")) + assertTrue(RelayUrlNormalizer.isOnion("abc123.onion")) + // and it now gets ws:// like any other onion relay + assertEquals("ws://abc123.onion:8080/", "abc123.onion:8080".normalizeRelayUrl().url) + assertFalse(RelayUrlNormalizer.isOnion("wss://notonion.example.com/")) + } + + /** Canonicalization must never rewrite anything outside the authority. */ + @Test + fun canonicalizationLeavesPathsAndQueriesAlone() { + assertEquals( + "wss://evil.example.com/x[0:0:0:0:0:0:0:1]y", + "wss://evil.example.com/x[0:0:0:0:0:0:0:1]y".normalizeRelayUrl().url, + ) + // ...while still folding a real IPv6 authority + assertEquals( + "wss://[::1]/x[0:0:0:0:0:0:0:1]y", + "wss://[0:0:0:0:0:0:0:1]/x[0:0:0:0:0:0:0:1]y".normalizeRelayUrl().url, + ) + } + + /** + * Private IPv4 was substring-matched, which was wrong in both directions. + */ + @Test + fun allPrivateIpv4RangesCountAsLocal() { + listOf( + "ws://127.0.0.1:8080/", + "ws://127.1.2.3:8080/", + "ws://10.0.0.5:4869/", + "ws://172.16.3.4:4869/", + "ws://172.31.255.1/", + "ws://192.168.1.5:4869/", + "ws://169.254.1.1:4869/", + "ws://0.0.0.0:4869/", + ).forEach { + assertTrue(RelayUrlNormalizer.isLocalHost(it), "$it must read as localhost") + } + // a LAN relay therefore gets ws://, not a wss:// that can never hold a certificate + assertEquals("ws://10.0.0.5:4869/", "10.0.0.5:4869".normalizeRelayUrl().url) + } + + @Test + fun publicIpv4AndPrivateLookalikeDomainsAreNotLocal() { + listOf( + "wss://127.0.0.1.evil.com/", + "wss://192.168.evil.com/", + "wss://10.0.0.5.evil.com/", + "wss://8.8.8.8:4869/", + "wss://172.32.0.1/", + "wss://193.168.1.5/", + "wss://relay.damus.io/", + "wss://notlocalhost.example.com/", + "wss://mylocalhost.io/", + ).forEach { + assertFalse(RelayUrlNormalizer.isLocalHost(it), "$it must not read as localhost") + } + } + + /** + * Host names are case-insensitive (RFC 4343), and `fix()` asks these questions before the + * RFC 3986 pass folds the case — so a case-sensitive test handed `LOCALHOST:8080` and + * `ABC.ONION:8080` a `wss://` scheme neither host can serve. + */ + @Test + fun hostTestsAreCaseInsensitive() { + assertTrue(RelayUrlNormalizer.isLocalHost("wss://LocalHost:8080/")) + assertTrue(RelayUrlNormalizer.isLocalHost("LOCALHOST:8080")) + assertTrue(RelayUrlNormalizer.isLocalHost("wss://MyRelay.LOCAL/")) + assertTrue(RelayUrlNormalizer.isOnion("wss://ABC123.ONION/")) + assertTrue(RelayUrlNormalizer.isOnion("ABC.ONION:8080")) + assertEquals("ws://localhost:8080/", "LOCALHOST:8080".normalizeRelayUrl().url) + assertEquals("ws://abc.onion:8080/", "ABC.ONION:8080".normalizeRelayUrl().url) + } + + /** RFC 1034's fully-qualified form ends in a dot; it names the same host. */ + @Test + fun trailingDotFqdnIsTheSameHost() { + assertTrue(RelayUrlNormalizer.isLocalHost("wss://localhost./")) + assertTrue(RelayUrlNormalizer.isLocalHost("wss://myrelay.local./")) + assertTrue(RelayUrlNormalizer.isOnion("wss://abc123.onion./")) + assertTrue(RelayUrlNormalizer.isOnion("wss://abc123.onion.:8080/")) + } + + /** + * A `://` inside a path is not a scheme separator; only a real RFC 3986 scheme starts the + * authority. Otherwise the path gets read as the host. + */ + @Test + fun aColonSlashSlashInThePathIsNotASchemeSeparator() { + assertFalse(RelayUrlNormalizer.isLocalHost("relay.example.com/x://127.0.0.1")) + assertFalse(RelayUrlNormalizer.isOnion("nos.lol/?u=x://abc.onion")) + // a real scheme still starts the authority + assertTrue(RelayUrlNormalizer.isLocalHost("wss://127.0.0.1/x://evil.com")) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6DifferentialTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6DifferentialTest.kt new file mode 100644 index 0000000000..43b9934f21 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6DifferentialTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.net.InetAddress +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Differential tests for [Ipv6] against the two parsers that actually matter at runtime: the + * JDK's (what `InetAddress` will do with the host) and OkHttp's (what dials the socket). + * + * A hand-written address parser is exactly the kind of code that passes its own examples and + * then disagrees with the real world on the hundredth input, so this pins it against + * references over a deterministic random corpus rather than against more of my own examples. + */ +class Ipv6DifferentialTest { + /** + * Parses through the JDK. IPv4-mapped literals come back as an `Inet4Address` of 4 bytes, + * so they are widened back to the 16-byte mapped form — [Ipv6] keeps them at 16 bytes, + * which is also what OkHttp does. + */ + private fun jdkBytes(literal: String): ByteArray { + val raw = InetAddress.getByName("[$literal]").address + if (raw.size == 16) return raw + return ByteArray(16).also { + it[10] = 0xFF.toByte() + it[11] = 0xFF.toByte() + raw.copyInto(it, 12) + } + } + + private fun randomAddresses(count: Int): List { + val rnd = Random(20260805) + return List(count) { + ByteArray(16) { rnd.nextInt(256).toByte() }.also { bytes -> + // Sprinkle zero runs so every `::` compression path gets exercised. + val runStart = rnd.nextInt(8) * 2 + val runLen = rnd.nextInt(1, 5) * 2 + for (k in runStart until minOf(16, runStart + runLen)) bytes[k] = 0 + } + } + } + + @Test + fun ourTextParsesToTheSameBytesInTheJdk() { + randomAddresses(4000).forEach { bytes -> + val text = Ipv6.format(bytes) + assertTrue(Ipv6.parse(text)!!.contentEquals(bytes), "our own round trip failed for $text") + assertTrue(jdkBytes(text).contentEquals(bytes), "the JDK reads $text as a different address") + } + } + + @Test + fun theJdksTextParsesBackThroughUs() { + randomAddresses(2000).forEach { bytes -> + val jdkText = InetAddress.getByAddress(bytes).hostAddress!! + assertTrue(Ipv6.parse(jdkText)?.contentEquals(bytes) == true, "we cannot read the JDK's own rendering: $jdkText") + } + } + + /** + * The canonical form is the app's relay identity, so it has to equal the host OkHttp shows + * for the same address — otherwise the app keys a relay under a name it does not dial. + */ + @Test + fun ourCanonicalFormMatchesOkHttp() { + randomAddresses(2000).forEach { bytes -> + val text = Ipv6.format(bytes) + assertEquals("http://[$text]/".toHttpUrl().host, text) + } + } + + @Test + fun expandedSpellingsCollapseOntoOkHttpsHost() { + randomAddresses(500).forEach { bytes -> + // The fully expanded, zero-padded, uppercase spelling of the same address. + val expanded = + (0 until 8).joinToString(":") { g -> + val value = ((bytes[g * 2].toInt() and 0xFF) shl 8) or (bytes[g * 2 + 1].toInt() and 0xFF) + value.toString(16).padStart(4, '0').uppercase() + } + assertEquals(Ipv6.format(bytes), Ipv6.canonicalizeOrNull(expanded)) + assertEquals("http://[$expanded]/".toHttpUrl().host, Ipv6.canonicalizeOrNull(expanded)) + } + } +} From f9bef87160c2769dd1382841f9119c1fe58cc982 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 5 Aug 2026 01:47:53 -0400 Subject: [PATCH 119/227] style(desktop): import Compose symbols in NotificationSettingsScreen Follow-up to 8f8713d8 (nostr proposal 259a0bb1). CLAUDE.md forbids fully-qualified class names inline in function bodies; the merged proposal introduced one (androidx.compose.runtime.LaunchedEffect) and the file already carried four more that predate it. Import them all and reference them by simple name: LaunchedEffect, snapshotFlow, rememberCoroutineScope, LocalWindowInfo. Also rewrites two comments the proposal added: - the auto-enable comment was written in the first person and described the author's own earlier mistake; restate it as what the code does and which two paths it covers. - the "Turn on desktop notifications" comment claimed the button renders only when the user explicitly disabled notifications, but the guard is `!enabled` alone. Describe the actual condition and why it is enough. Drops a redundant `enabled = true` on that OutlinedButton (the default). No behaviour change. :desktopApp:compileKotlin and :commons:jvmTest green. Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/settings/NotificationSettingsScreen.kt | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NotificationSettingsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NotificationSettingsScreen.kt index 161b929b68..3e559f4f92 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NotificationSettingsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NotificationSettingsScreen.kt @@ -39,13 +39,17 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.moderation.notifications.HostOs import com.vitorpamplona.amethyst.commons.moderation.notifications.NotifKind @@ -112,7 +116,7 @@ fun NotificationSettingsScreen(onBack: (() -> Unit)? = null) { dispatcher?.nativeAvailable?.collectAsState() ?: remember { mutableStateOf(false) } ) - val coroutineScope = androidx.compose.runtime.rememberCoroutineScope() + val coroutineScope = rememberCoroutineScope() var testStatus by remember { mutableStateOf(null) } var requestingPermission by remember { mutableStateOf(false) } var sendingTest by remember { mutableStateOf(false) } @@ -120,33 +124,30 @@ fun NotificationSettingsScreen(onBack: (() -> Unit)? = null) { // Re-sync permission state whenever this screen enters composition // and whenever the window regains focus — user may have toggled // Amethyst in System Settings → Notifications while we were open. - val windowInfo = androidx.compose.ui.platform.LocalWindowInfo.current - androidx.compose.runtime.LaunchedEffect(dispatcher) { + val windowInfo = LocalWindowInfo.current + LaunchedEffect(dispatcher) { dispatcher?.refreshPermission() } - androidx.compose.runtime.LaunchedEffect(dispatcher, windowInfo) { - androidx.compose.runtime - .snapshotFlow { windowInfo.isWindowFocused } + LaunchedEffect(dispatcher, windowInfo) { + snapshotFlow { windowInfo.isWindowFocused } .collect { focused -> if (focused) dispatcher?.refreshPermission() } } - // Handle the still-broken case that the previous fix missed: - // the user granted OS permission in a prior session (either via - // the older "Enable OS notifications" button whose auto-enable - // guard I initially forgot, via System Settings directly, or on - // Windows/Linux where permissionState defaults to NotApplicable). - // When they come back to Settings, permissionState == Granted so - // the "Enable OS notifications" button doesn't render, the master - // switch is still OFF from first-launch defaults, and there is no - // affordance that both tells them what's wrong and fixes it in - // one click. Auto-enable once per screen entry when we detect - // "permission is fine, but master switch is off and the user - // has never explicitly disabled it". PreferencesNotificationSettings - // exposes [wasExplicitlyDisabled] so we don't overrule a deliberate - // opt-out. - androidx.compose.runtime.LaunchedEffect(permissionState, enabled) { + // Covers the two paths the earlier fix (e9475dd0) missed: the OS + // permission was already granted in a prior session (an older + // build asked, or the user allowed Amethyst in System Settings + // directly), and Windows/Linux, where permissionState is + // NotApplicable from startup. On both, permissionState is not + // NotRequested, so the "Enable OS notifications" button never + // renders, yet the master switch is still OFF from first-launch + // defaults — leaving no affordance that both explains the problem + // and fixes it. Auto-enable when the permission is fine, the + // master switch is off, and the user has never explicitly turned + // it off; [NotificationSettings.wasExplicitlyDisabled] is what + // keeps a deliberate opt-out from being overruled. + LaunchedEffect(permissionState, enabled) { val allowed = permissionState == PermissionState.Granted || permissionState == PermissionState.NotApplicable if (allowed && !enabled && !settings.wasExplicitlyDisabled()) { settings.setEnabled(true) @@ -240,15 +241,18 @@ fun NotificationSettingsScreen(onBack: (() -> Unit)? = null) { } } PermissionState.Granted, PermissionState.NotApplicable -> { - // Turn-on button: renders only when master switch is - // off *and* the user explicitly disabled it before. - // The LaunchedEffect above auto-enables the switch - // for the common "never touched it" path; this button - // is the recovery for the deliberate-opt-out path. + // Recovery affordance for the deliberate-opt-out path. + // The LaunchedEffect above already re-enables the + // switch for anyone who never touched it, so in + // practice the only state that still reaches here with + // `enabled == false` is an explicit opt-out. Guarding + // on `!enabled` alone (rather than also calling + // wasExplicitlyDisabled) keeps this a pure Compose + // state read and leaves the button visible for the one + // frame before the effect runs. if (!enabled) { OutlinedButton( onClick = { settings.setEnabled(true) }, - enabled = true, ) { Text("Turn on desktop notifications") } } OutlinedButton( From f1d2bdee49a3130ae2502615a5f6290a8ab71822 Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Wed, 5 Aug 2026 15:56:00 +1000 Subject: [PATCH 120/227] ci(release): add libegl1 to arm64 .deb Depends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compose-desktop skiko native shipped under ${app}/lib/app/libskiko-linux-arm64.so declares libEGL.so.1 in DT_NEEDED — the aarch64 skiko uses EGL alongside GLX, unlike the x86_64 skiko which only links libGL.so.1. jpackage --type deb only auto-generates Depends from dpkg-shlibdeps against the bundled JRE under lib/runtime/, NOT the app payload under lib/app/. As a result the arm64 .deb produced by the newly-added linux-arm64 CI leg lists libgl1/libglvnd0/libglx0 in Depends but not libegl1. On minimal aarch64 installs — Armbian Server + a lightweight WM, Raspberry Pi OS Lite + LXDE, or any distro base image without an EGL implementation pulled in transitively — Amethyst desktop crashes at startup with: Exception in thread "main" org.jetbrains.skiko.LibraryLoadException: Failed to loade library …/libskiko-linux-arm64.so Caused by: java.lang.UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file: No such file or directory Neither jpackage nor the Compose Multiplatform 1.11 DSL exposes a way to add extra deb Depends, so we rewrite the .deb after the fact — same approach as scripts/relax-deb-libicu.sh (which handles the libicu SONAME divergence across Debian/Ubuntu releases). scripts/add-deb-libegl-dep.sh only touches .debs whose payload actually contains libskiko-linux-arm64.so, and is idempotent (skips if libegl1 is already listed). The workflow step is gated on matrix.arch == 'arm64' so the x64 .deb is untouched (its skiko does NOT NEED libEGL and its GLX-only path stays as-is). Local validation on Apple Silicon (native linux/arm64 in Docker): 1. Rebuilt v1.13.1 arm64 .deb from a110ce0a30's CI leg. 2. readelf -d libskiko-linux-arm64.so | grep NEEDED → confirms libEGL.so.1 3. Ran scripts/add-deb-libegl-dep.sh over the .deb; Depends line now ends `..., zlib1g, libegl1`. Idempotent on re-run. 4. `apt-get install -y -f ./amethyst_*.deb` in a base eclipse-temurin:21-jdk-noble aarch64 container (which lacks libegl1 by default) now pulls libegl1 as a dep. 5. Amethyst launches under Xvfb, `xwininfo -root -tree` shows the 1200×800 "Amethyst" window + Content window + sun-awt-X11-XCanvasPeer Skia canvas. No UnsatisfiedLinkError. --- .github/workflows/create-release.yml | 14 ++++++ scripts/add-deb-libegl-dep.sh | 68 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100755 scripts/add-deb-libegl-dep.sh diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index f87c0556f4..0dfea9bfcd 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -221,6 +221,20 @@ jobs: chmod +x scripts/relax-deb-libicu.sh scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb + # jpackage --type deb only auto-generates Depends from dpkg-shlibdeps + # against the bundled JRE under lib/runtime/, NOT the app payload under + # lib/app/. libskiko-linux-arm64.so has libEGL.so.1 in DT_NEEDED (unlike + # the x64 skiko which only links libGL.so.1), so a minimal aarch64 + # install without EGL crashes at startup with: + # UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file + # Rewrite the arm64 .deb to add libegl1 to Depends. x64 .deb is untouched. + - name: Add libegl1 dep to arm64 .deb + if: matrix.family == 'linux' && matrix.arch == 'arm64' + run: | + set -euo pipefail + chmod +x scripts/add-deb-libegl-dep.sh + scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb + - name: Build portable archives (windows + linux-portable) if: matrix.family == 'windows' || matrix.family == 'linux-portable' run: | diff --git a/scripts/add-deb-libegl-dep.sh b/scripts/add-deb-libegl-dep.sh new file mode 100755 index 0000000000..09b778052c --- /dev/null +++ b/scripts/add-deb-libegl-dep.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Ensure a jpackage-built desktop .deb declares libegl1 as a runtime dep on +# arm64. +# +# Why this is needed: the compose-desktop skiko native shipped in +# ${app}/lib/app/libskiko-linux-arm64.so has libEGL.so.1 in DT_NEEDED (the +# aarch64 build uses EGL alongside GLX, unlike the x86_64 skiko which only +# links libGL.so.1). jpackage's --type deb only auto-generates Depends from +# dpkg-shlibdeps against the bundled JRE under ${app}/lib/runtime/, NOT the +# ${app}/lib/app/ tree — so libegl1 never makes it into the arm64 .deb. +# +# On most desktop Linux systems libegl1 is already installed as a transitive +# of the desktop environment. But minimal aarch64 installs (Armbian Server + +# a lightweight WM, Raspberry Pi OS Lite + LXDE, etc.) can miss it. Without +# libegl1 the app dies at startup with: +# +# Exception in thread "main" org.jetbrains.skiko.LibraryLoadException: +# Failed to loade library …/libskiko-linux-arm64.so +# Caused by: java.lang.UnsatisfiedLinkError: +# libEGL.so.1: cannot open shared object file: No such file or directory +# +# Neither jpackage nor the Compose Multiplatform 1.11 DSL exposes a way to +# override the auto-generated Depends, so we rewrite the .deb after the fact +# (same approach as scripts/relax-deb-libicu.sh). +# +# Usage: add-deb-libegl-dep.sh [ ...] +set -euo pipefail + +for deb in "$@"; do + if [[ ! -f "$deb" ]]; then + echo "skip: not a file: $deb" >&2 + continue + fi + + work="$(mktemp -d)" + trap 'rm -rf "$work"' EXIT + dpkg-deb -R "$deb" "$work/pkg" + control="$work/pkg/DEBIAN/control" + + # Only touch .debs whose payload actually contains the arm64 skiko native. + # Applying this to x64 .debs is harmless but the whole point is to be + # surgical. + if ! find "$work/pkg" -type f -name 'libskiko-linux-arm64.so' | grep -q .; then + echo "No arm64 skiko in payload, leaving as-is: $deb" + rm -rf "$work" + trap - EXIT + continue + fi + + if grep -qE '(^| )libegl1( |,|$)' "$control"; then + echo "libegl1 already in Depends, leaving as-is: $deb" + rm -rf "$work" + trap - EXIT + continue + fi + + # Append libegl1 to the Depends line. jpackage-generated lines are single + # physical lines, e.g. + # Depends: libasound2t64, ..., zlib1g + # We insert `, libegl1` before the trailing newline. + sed -i -E 's/^(Depends: .*[^,[:space:]])[[:space:]]*$/\1, libegl1/' "$control" + + dpkg-deb --root-owner-group -Zxz -b "$work/pkg" "$deb" >/dev/null + echo "Added libegl1 dep: $deb" + + rm -rf "$work" + trap - EXIT +done From dc45477deb43bd01a63b426394b528ee0eec47dc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 14:08:01 +0000 Subject: [PATCH 121/227] fix: don't glue quotes onto detected urls A bare host wrapped in quotes ("relay.momostr.pink") was detected with the opening quote attached, so the rendered link read `"relay.momostr.pink` and pointed at a host that does not exist. The mirror case was also wrong: a quoted url with a path/query/fragment kept the closing quote, because those readers only stop on a space. Quotes are not host characters, so they now end the current token exactly like a space does in readDefault (covering the leading quote and a quote glued to a previous word, e.g. `href="www.google.com"`), and they were added to CANNOT_BEGIN_URLS_WITH / CANNOT_END_URLS_WITH so a trailing quote read as part of a path, query or fragment is stripped on readEnd. The set covers the ascii quotes plus the typographic family, including the guillemets below the international-character threshold that the ascii boundary rule never cut. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GKCAegYMF9V9Nb8FHcMvT7 --- .../richtext/RichTextParserQuotedUrlTest.kt | 76 +++++++++++++++++++ .../commons/richtext/UrlParserTest.kt | 28 +++++++ .../urldetector/detection/UrlDetector.kt | 40 +++++++++- .../urldetector/detection/UriDetectionTest.kt | 32 ++++++++ 4 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserQuotedUrlTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserQuotedUrlTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserQuotedUrlTest.kt new file mode 100644 index 0000000000..595e395bbe --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserQuotedUrlTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A quoted host name (`this bridge-relay "relay.momostr.pink" doesn't appear`) used to be + * detected with the opening quote glued onto it, so the rendered link read + * `"relay.momostr.pink` and pointed at a host that doesn't exist. Quotes are not host + * characters, so they must be left in the surrounding text on both sides. + */ +class RichTextParserQuotedUrlTest { + private fun segmentsOf(text: String) = + RichTextParser() + .parseText(text, EmptyTagList, null) + .paragraphs + .flatMap { it.words } + + @Test + fun quotedSchemelessUrlKeepsQuotesOutOfTheLink() { + val segments = + segmentsOf( + "It seems like this bridge-relay \"relay.momostr.pink\" doesn't appear in the feed", + ).filterIsInstance() + + assertEquals(listOf("relay.momostr.pink"), segments.map { it.segmentText }) + } + + @Test + fun quotedUrlWithSchemeKeepsQuotesOutOfTheLink() { + val segments = + segmentsOf( + "the docs are at \"https://example.com/some/page?a=b\" if you need them", + ).filterIsInstance() + + assertEquals(listOf("https://example.com/some/page?a=b"), segments.map { it.segmentText }) + } + + @Test + fun quotedRelayUrlKeepsQuotesOutOfTheLink() { + val segments = + segmentsOf("add \"wss://relay.momostr.pink\" to your list") + .filterIsInstance() + + assertEquals(listOf("wss://relay.momostr.pink"), segments.map { it.segmentText }) + } + + @Test + fun apostrophesInProseDontCreateLinks() { + val segments = segmentsOf("it doesn't appear until you go into the authors' accounts") + + assertEquals(emptyList(), segments.filterIsInstance()) + assertEquals(emptyList(), segments.filterIsInstance()) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt index f1444b88cc..c0c858b47d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt @@ -349,6 +349,34 @@ class UrlParserTest { Urls(withScheme = setOf("https://test.com")), ) + @Test + fun testQuotedRelayName() = + test( + "It seems like this bridge-relay \"relay.momostr.pink\" doesn't appear in the feed at all", + Urls(withoutScheme = setOf("relay.momostr.pink")), + ) + + @Test + fun testSingleQuotedRelayName() = + test( + "It seems like this bridge-relay 'relay.momostr.pink' doesn't appear in the feed at all", + Urls(withoutScheme = setOf("relay.momostr.pink")), + ) + + @Test + fun testQuotedUrlWithScheme() = + test( + "the docs are at \"https://example.com/some/page?a=b\" if you need them", + Urls(withScheme = setOf("https://example.com/some/page?a=b")), + ) + + @Test + fun testQuotedRelayUrl() = + test( + "add \"wss://relay.momostr.pink\" to your list", + Urls(relayUrls = setOf("wss://relay.momostr.pink")), + ) + @Test fun testBlossom() { val blossom = "blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt index cf2e8a5f4c..ae05dee3da 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt @@ -91,7 +91,17 @@ class UrlDetector( while (!reader.eof()) { // read the next char to process. when (val curr = reader.read()) { - ' ' -> { + // A quote can never be part of a host name, so a note that wraps a bare domain in + // quotes (`the relay "relay.example.com" is down`) must not glue the opening quote + // onto the url. Quotes therefore end the current token exactly like a space does. + // Kept as literals (instead of `in QUOTES`) so this hot branch stays a tableswitch; + // the list must mirror [QUOTES], which the punctuation round-trip test enforces. + ' ', '"', '\'', '`', + '\u00AB', '\u00BB', + '\u2018', '\u2019', '\u201A', '\u201B', + '\u201C', '\u201D', '\u201E', '\u201F', + '\u2039', '\u203A', + -> { // space found; if we have a scheme, attempt to read the domain before resetting if (buffer.isNotEmpty() && hasScheme) { reader.goBack() @@ -719,6 +729,30 @@ class UrlDetector( "$it//" } + /** + * Quotes never belong to a url. The opening side is already dropped when [readDefault] + * breaks the token on them, but the closing side can still be swallowed by the path, + * query or fragment readers (which only stop on a space), so it is stripped on [readEnd]. + */ + val QUOTES = + setOf( + '"', + '\'', + '`', + '\u00AB', + '\u00BB', + '\u2018', + '\u2019', + '\u201A', + '\u201B', + '\u201C', + '\u201D', + '\u201E', + '\u201F', + '\u2039', + '\u203A', + ) + val CANNOT_BEGIN_URLS_WITH = setOf( ',', @@ -734,7 +768,7 @@ class UrlDetector( '\u3002', '\uFF0E', '\uFF61', - ) + ) + QUOTES val CANNOT_END_URLS_WITH = setOf( @@ -752,6 +786,6 @@ class UrlDetector( '\u3002', '\uFF0E', '\uFF61', - ) + ) + QUOTES } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt index 76ffc005e8..4a4c45fdc8 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt @@ -882,6 +882,38 @@ class UriDetectionTest { runTest("visit example.com,", "example.com") } + @Test + fun testQuotedUrlsDropTheQuotes() { + // a bare domain wrapped in quotes must not glue the opening quote onto the host. + runTest( + "It seems like this bridge-relay \"relay.momostr.pink\" doesn't appear in the feed", + "relay.momostr.pink", + ) + + UrlDetector.QUOTES.forEach { quote -> + runTest("$quote relay.momostr.pink $quote", "relay.momostr.pink") + runTest("${quote}relay.momostr.pink$quote", "relay.momostr.pink") + runTest("${quote}wss://relay.momostr.pink$quote", "wss://relay.momostr.pink") + + // the closing quote is swallowed by the path/query/fragment readers, which only stop + // on a space, so it has to be stripped at the end of the url instead. + runTest("say ${quote}https://example.com/foo$quote out", "https://example.com/foo") + runTest("say ${quote}https://example.com/foo?a=b$quote out", "https://example.com/foo?a=b") + runTest("say ${quote}https://example.com/foo#b$quote out", "https://example.com/foo#b") + + // a quote glued to the previous word is a boundary too: `href="www.google.com"`. + runTest("href=${quote}www.google.com$quote", "www.google.com") + } + } + + @Test + fun testApostropheStillEndsTheHostForGroupInviteLinks() { + // NIP-29 invite links are `'`; UrlParser recovers the suffix from the + // content, so the detector must keep reporting the relay url alone. + runTest("wss://relay.example.com'groupid", "wss://relay.example.com") + runTest("wss://relay.example.com'groupid?code=xyz", "wss://relay.example.com") + } + private fun runTest( text: String, vararg expected: String?, From 5c5644405482cb515d98b8a97c7362b1e679a757 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 14:28:24 +0000 Subject: [PATCH 122/227] fix: strip the whole punctuation tail from a detected url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit follow-up to the quote fix. The path, query and fragment readers only stop on a space, and readEnd dropped a single trailing delimiter, so a quoted link that closed a sentence kept its quote: He linked "https://example.com/some/path". -> https://example.com/some/path" (see "https://example.com/some/path") -> https://example.com/some/path" readEnd now strips the tail in a loop. The balance check runs on every round, so a url that legitimately ends in a matched closer still stops the strip: `[link](…/Bitcoin_(disambiguation)).` keeps `(disambiguation)` and drops the `).` that belongs to the sentence. Differential run over a 4000-string corpus against the previous commit: 8 rows change, every one of them the removal of extra trailing punctuation. No url is gained, lost or truncated mid-string. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GKCAegYMF9V9Nb8FHcMvT7 --- .../urldetector/detection/UrlDetector.kt | 10 ++++++-- .../urldetector/detection/UriDetectionTest.kt | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt index ae05dee3da..c353f462f9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt @@ -659,8 +659,14 @@ class UrlDetector( // if the url is valid and greater then 0 if (state == ReadEndState.ValidUrl && buffer.isNotEmpty()) { var url = buffer.toString() - val last = url.lastOrNull() - if (last != null && last in CANNOT_END_URLS_WITH && !url.endsOnBalancedCloser(last)) { + // Strips the whole punctuation tail, not just its last character: the path, query and + // fragment readers only stop on a space, so a quoted link closing a sentence arrives + // here as `https://host/path".` and a single drop would leave the quote glued on. + // Each round re-checks the balance, so a url that legitimately ends in a matched + // closer (`…/Bitcoin_(disambiguation)`) still stops the strip. + while (true) { + val last = url.lastOrNull() ?: break + if (last !in CANNOT_END_URLS_WITH || url.endsOnBalancedCloser(last)) break url = url.dropLast(1) } if (url.isNotEmpty()) urlList.add(currentUrlMarker.createUrl(url)) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt index 4a4c45fdc8..da2c80c169 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt @@ -906,6 +906,29 @@ class UriDetectionTest { } } + @Test + fun testWholePunctuationTailIsStripped() { + // The path/query/fragment readers only stop on a space, so a quoted link that closes a + // sentence reaches readEnd as `https://host/path".` — every trailing delimiter has to go, + // not just the last one. + runTest("He linked \"https://example.com/some/path\".", "https://example.com/some/path") + runTest("(see \"https://example.com/some/path\")", "https://example.com/some/path") + runTest("read \"https://example.com/a?b=c\", then go", "https://example.com/a?b=c") + runTest("\"https://example.com/x\"!", "https://example.com/x") + runTest("\"https://example.com/x#frag\"...", "https://example.com/x#frag") + runTest("wait... example.com/path...", "example.com/path") + + // a balanced closer still stops the strip, even behind a longer tail. + runTest( + "(see https://en.wikipedia.org/wiki/Bitcoin_(disambiguation))", + "https://en.wikipedia.org/wiki/Bitcoin_(disambiguation)", + ) + runTest( + "[link](https://en.wikipedia.org/wiki/Bitcoin_(disambiguation)).", + "https://en.wikipedia.org/wiki/Bitcoin_(disambiguation)", + ) + } + @Test fun testApostropheStillEndsTheHostForGroupInviteLinks() { // NIP-29 invite links are `'`; UrlParser recovers the suffix from the From 9ac033effcf5964457984561f53cf34be22ce7fd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:41:29 +0000 Subject: [PATCH 123/227] fix(ime): use the keyboard-aware back handler in the post composers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread reply composer (and every other full-screen draft-saving editor) still consumed back with a raw `BackHandler`, so `KeyboardAwareBackHandler` — added for exactly this case — only protected the three chat composers. Popping the screen while the keyboard is still up races the predictive-back window animation against the IME close animation. When the window animation wins, the IME `WindowInsetsAnimationCompat` is cancelled before its terminal zero frame reaches Compose, the shared `WindowInsets.ime` holder stays "animating", and every `Modifier.imePadding()` freezes at keyboard height — the keyboard vanishes but its padding stays behind, even after leaving the screen. Switching these composers to `KeyboardAwareBackHandler` lets the first back (or back-swipe) fall through to the system, which dismisses the keyboard with its own animation that completes cleanly; the next back saves the draft and pops as before. The top bar's cancel arrow remains an always-available exit. Covers `ShortNotePostScreen` (which also backs `PollPostScreen`), `GenericCommentPostScreen`, `LongFormPostScreen`, `NewProductScreen`, `NewPublicMessageScreen`, `NewGoalScreen`, `NewWorkoutScreen` and `AwardBadgeScreen`. `VoiceReplyScreen` keeps the plain handler — it has no text input or `imePadding()`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN --- .../ui/note/nip22Comments/GenericCommentPostScreen.kt | 4 ++-- .../ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt | 4 ++-- .../loggedIn/discover/nip23LongForm/LongFormPostScreen.kt | 4 ++-- .../loggedIn/discover/nip99Classifieds/NewProductScreen.kt | 4 ++-- .../amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt | 4 ++-- .../ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt | 4 ++-- .../notifications/publicMessages/NewPublicMessageScreen.kt | 4 ++-- .../amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 6baef9f1a2..aec22cb911 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.nip22Comments -import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box @@ -67,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture @@ -177,7 +177,7 @@ fun GenericCommentPostScreen( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt index 2172e18945..bdc768cc3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -52,6 +51,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.components.Nip05OrPubkeyLine import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.note.UserPicture @@ -88,7 +88,7 @@ fun AwardBadgeScreen( onDispose { userSuggestions.reset() } } - BackHandler { + KeyboardAwareBackHandler { nav.popBack() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 2465929d40..12cba33c60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm -import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -96,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer @@ -149,7 +149,7 @@ fun LongFormPostScreen( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 2e95142599..8cd67d7b46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds -import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -53,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -141,7 +141,7 @@ fun NewProductScreen( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index db7e95f3be..a4bc2c6bfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import android.annotation.SuppressLint import android.content.Intent import android.net.Uri -import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -93,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.getActivity +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -235,7 +235,7 @@ internal fun NewPostScreenInner( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt index 5a6348cb6d..f7aabcd6f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -48,6 +47,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -81,7 +81,7 @@ fun NewGoalScreen( accountViewModel: AccountViewModel, nav: INav, ) { - BackHandler { + KeyboardAwareBackHandler { goalViewModel.cancel() nav.popBack() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 56e1382b6a..f9e277665c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages -import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Column @@ -64,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -134,7 +134,7 @@ fun NewPublicMessageScreen( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt index a3de2013d0..5bc98f4cdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts -import androidx.activity.compose.BackHandler import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -61,6 +60,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -80,7 +80,7 @@ fun NewWorkoutScreen( postViewModel.init(accountViewModel) postViewModel.prefill(prefill) - BackHandler { + KeyboardAwareBackHandler { postViewModel.cancel() nav.popBack() } From 42a91ffb790842f23f42dbd9dd4a2f0a7f319dfd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 5 Aug 2026 15:50:33 +0000 Subject: [PATCH 124/227] SyncCoverage: one band interval cannot speak for several kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A band held ONE created_at interval per (relay, filter). For a filter naming several kinds that is a claim no walk can support: ask for `kinds: [0, 30382]`, find profiles going back years and score cards only from last month, and the band records 2020..now for the pair. The next run then skips that whole interior for BOTH — so score cards written inside it are never asked for again, and nothing anywhere says so. A long-lived kind vouched for a short-lived one. Band.spans is now per kind. Each carries only the evidence actually collected for it, so the profile kind keeps its wide interval and the score kind keeps its narrow one, and legs() re-opens the interior for the second while still skipping it for the first. Three things keep the cost of that where it was: - legs() REGROUPS kinds by the windows they want. Identical coverage — the common case, and the only case until they diverge — collapses back into one ask, so a filter that produced two legs still produces two rather than two per kind. Only a kind whose evidence genuinely differs earns its own. - A finished reconcile needs no per-kind evidence and is given none: negentropy compares the filter's whole id set in one pass, so it covers every kind in the filter or none. Only the PAGED path changed. - Filters naming no kinds keep a single span under ALL_KINDS, which is the same claim as before, correctly scoped to the case where it is the only claim available. record() takes observedByKind, and SyncCoverage.observe() accumulates it as events arrive — replacing the pair of hand-rolled vars each caller kept, and moving the per-event isPlausible guard in with it. A paged walk over a MULTI-kind filter that supplies none earns no band at all, loudly, once: attributing one interval to every kind is exactly the over-claim this removes, and a band that over-claims skips events silently, which is worse than re-reading them. Single-kind filters are untouched — there the aggregate always was the per-kind answer. The state file gains a per-kind `spans` object and keeps `min`/`max` as the outer edges, so a rollback to a binary from before this reads the file and behaves as it always did. A file written BEFORE this loads its one interval under ALL_KINDS — the old, wider claim, kept rather than discarded because discarding it would re-download every upstream's corpus once on upgrade. The first per-kind walk replaces it. All 26 existing SyncCoverage tests pass unchanged, which is the evidence that single-kind behaviour did not move. The five new ones were checked against the pre-fix rule reinstated in place: the two behavioural ones fail there and pass here. Co-Authored-By: Claude Opus 5 --- .../geode/mirror/MirrorWorker.kt | 15 ++ .../geode/mirror/SyncCoverageFile.kt | 47 +++- .../relay/client/accessories/SyncCoverage.kt | 210 +++++++++++++++--- .../client/accessories/SyncCoverageTest.kt | 120 ++++++++++ 4 files changed, 353 insertions(+), 39 deletions(-) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt index c98e050e37..4d1411593b 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt @@ -498,6 +498,12 @@ class MirrorWorker( val syncStartedAt = TimeUtils.now() var seenMin: Long? = null var seenMax: Long? = null + // Per KIND as well as in aggregate: one interval for a + // multi-kind filter lets a long-lived kind vouch for a + // short-lived one, and the band then skips the interior for + // both. The aggregate is still tracked because the reconcile + // path records against the leg's floor, not per kind. + val seenByKind = mutableMapOf() fun observe(event: Event) { // Same containment as the live path: even a trusted @@ -509,6 +515,7 @@ class MirrorWorker( seenMin = minOf(seenMin ?: event.createdAt, event.createdAt) seenMax = maxOf(seenMax ?: event.createdAt, event.createdAt) } + SyncCoverage.observe(seenByKind, event.kind, event.createdAt) handoff.trySendBlocking(event) } else { filtered.incrementAndGet() @@ -537,6 +544,7 @@ class MirrorWorker( } catch (e: NegentropySyncException) { seenMin = null seenMax = null + seenByKind.clear() // The watchdog matches negentropySync's default rather // than fetchAllPages' shorter one: a paged catch-up // sits behind the same slow upstreams. @@ -558,6 +566,13 @@ class MirrorWorker( seenMin, seenMax?.coerceAtMost(syncStartedAt), paged = true, + // Capped the same way the aggregate is: one + // future-dated event must not lift a kind's ceiling + // past what was actually asked for. + observedByKind = + seenByKind.mapValues { (_, span) -> + SyncCoverage.Span(span.min, span.max.coerceAtMost(syncStartedAt)) + }, ) } else { val legFloor = leg.since ?: initialSince diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt index b31c51a60d..58b0b23310 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt @@ -100,8 +100,7 @@ class SyncCoverageFile( root.mapValues { (_, v) -> val o = v.jsonObject SyncCoverage.Band( - o.getValue("min").jsonPrimitive.long, - o.getValue("max").jsonPrimitive.long, + spansOf(o), o["complete"]?.jsonPrimitive?.boolean ?: false, o["fullAt"]?.jsonPrimitive?.long ?: 0L, ) @@ -112,6 +111,30 @@ class SyncCoverageFile( } } + /** + * The per-kind spans, or the single pre-split span read as covering every + * kind under [SyncCoverage.ALL_KINDS]. + * + * A file written before coverage was tracked per kind carries only + * `min`/`max`, and that is exactly the over-wide claim per-kind spans + * exist to stop — so it is loaded as what it always meant rather than + * discarded, and the first paged walk that reports per kind replaces it. + * Dropping it instead would re-download every upstream's corpus once on + * upgrade, which is the cost bands exist to avoid. + */ + private fun spansOf(o: JsonObject): Map { + o["spans"]?.jsonObject?.let { spans -> + return spans.entries.associate { (kind, v) -> + val span = v.jsonObject + kind.toInt() to SyncCoverage.Span(span.getValue("min").jsonPrimitive.long, span.getValue("max").jsonPrimitive.long) + } + } + return mapOf( + SyncCoverage.ALL_KINDS to + SyncCoverage.Span(o.getValue("min").jsonPrimitive.long, o.getValue("max").jsonPrimitive.long), + ) + } + @Synchronized private fun save() { runCatching { @@ -121,10 +144,30 @@ class SyncCoverageFile( put( key, buildJsonObject { + // min/max are the outer edges across every + // kind, and are written for two readers: a + // human debugging why an upstream re-synced, + // and a ROLLBACK — a binary from before spans + // were per kind reads these and behaves as it + // always did, rather than failing to parse. put("min", band.minCreatedAt) put("max", band.maxCreatedAt) put("complete", band.complete) put("fullAt", band.fullAt) + put( + "spans", + buildJsonObject { + band.spans.forEach { (kind, span) -> + put( + kind.toString(), + buildJsonObject { + put("min", span.min) + put("max", span.max) + }, + ) + } + }, + ) }, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt index 35ba9b330a..4e6de68696 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap @@ -64,23 +65,55 @@ class SyncCoverage( private val now: () -> Long = { TimeUtils.now() }, private val onChange: () -> Unit = {}, ) { + /** A covered `created_at` interval, inclusive at both ends. */ + data class Span( + val min: Long, + val max: Long, + ) { + fun widen(other: Span) = Span(minOf(min, other.min), maxOf(max, other.max)) + } + /** * What is already covered for one (relay, filter) pair. * + * [spans] is PER KIND, and that is the whole point of it. A band used to + * hold one interval for the entire filter, which is a claim no multi-kind + * walk can support: ask for `kinds: [0, 30382]`, see profiles back to 2020 + * and score cards only from 2025, and the band reads 2020..2026 — so the + * next run skips 2020..2025 for BOTH, and the score cards in that interior + * are never asked for again. A long-lived kind vouched for a short-lived + * one. Per kind, each carries only the evidence actually collected for it. + * + * Filters that name no kinds at all cannot be split, so they keep a single + * span under [ALL_KINDS] — the same claim as before, correctly scoped to + * the case where it is the only claim available. + * * [complete] is the difference between "we walked this span" (a paged * fetch) and "we are in sync below this point" (a finished negentropy * reconcile, which compared the whole range). Only a complete band may - * skip its older leg. + * skip its older leg. It is a property of the BAND rather than of a span: + * a reconcile compares the filter's whole id set at once, so it either + * covers every kind in it or none. * * [fullAt] is when the last pass that started from nothing finished — the * clock for the periodic re-walk. */ data class Band( - val minCreatedAt: Long, - val maxCreatedAt: Long, + val spans: Map, val complete: Boolean = false, val fullAt: Long = 0, - ) + ) { + /** The outer edges across every kind — for logging and for the file's compatibility fields. */ + val minCreatedAt: Long get() = spans.values.minOfOrNull { it.min } ?: 0 + val maxCreatedAt: Long get() = spans.values.maxOfOrNull { it.max } ?: 0 + + /** Widen each kind by its counterpart, keeping kinds only one side knows. */ + fun widen(other: Band): Band { + val merged = spans.toMutableMap() + for ((kind, span) in other.spans) merged[kind] = merged[kind]?.widen(span) ?: span + return Band(merged, complete || other.complete, fullAt) + } + } private val bands = ConcurrentMap() @@ -114,31 +147,68 @@ class SyncCoverage( // Time for another full pass: relays gain old events, and without // this the band's claim is never re-tested. if (isStale(band)) return listOf(filter) - val legs = mutableListOf() + if (band.spans.isEmpty()) return listOf(filter) - // Older: up to and including the band's floor, but not past the - // filter's (or, when the filter has no `since`, the caller's - // [floor] — a sync window the filter itself must not carry, or it - // would change the band's key every run). A complete band compared - // its whole range already, but only down to the floor it ran - // against: a caller now reaching deeper — a raised backfill window - // — re-opens the span below the band. + val kinds = filter.kinds + if (kinds.isNullOrEmpty()) { + // Nothing to split by. One span, exactly as before. + return windows(filter, band.spans[ALL_KINDS], band.complete, floor) + .map { (since, until) -> filter.copy(since = since, until = until) } + } + + // Per kind, then REGROUPED by the windows each one wants. Kinds whose + // coverage agrees — the overwhelmingly common case, and the only case + // at all until they diverge — collapse back into one ask, so a filter + // that used to produce two legs still produces two rather than two per + // kind. Only a kind whose evidence genuinely differs earns its own. + val byWindows = LinkedHashMap>, MutableList>() + for (kind in kinds) { + // ALL_KINDS as the fallback: a band written before coverage was + // tracked per kind, restored from such a file. It carries the old, + // wider claim for every kind — the behaviour this replaces — and + // self-corrects on the first paged walk that reports per kind. + val span = band.spans[kind] ?: band.spans[ALL_KINDS] + byWindows.getOrPut(windows(filter, span, band.complete, floor)) { mutableListOf() }.add(kind) + } + return byWindows.flatMap { (windows, group) -> + windows.map { (since, until) -> filter.copy(kinds = group, since = since, until = until) } + } + } + + /** + * The `(since, until)` pairs still outstanding for ONE span — the leg + * arithmetic, with the filter's own bounds applied and nothing else. + * A null [span] means no evidence at all, so the whole filter is wanted. + */ + private fun windows( + filter: Filter, + span: Span?, + complete: Boolean, + floor: Long?, + ): List> { + if (span == null) return listOf(filter.since to filter.until) + val out = mutableListOf>() + + // Older: up to and including the span's floor, but not past the + // filter's (or, when the filter has no `since`, the caller's [floor] — + // a sync window the filter itself must not carry, or it would change + // the band's key every run). A complete band compared its whole range + // already, but only down to the floor it ran against: a caller now + // reaching deeper — a raised backfill window — re-opens the span below. val since = filter.since ?: floor val wantsOlder = - if (band.complete) { - since != null && since < band.minCreatedAt + if (complete) { + since != null && since < span.min } else { - since == null || band.minCreatedAt >= since + since == null || span.min >= since } - if (wantsOlder) { - legs.add(filter.copy(until = minOf(band.minCreatedAt, filter.until ?: Long.MAX_VALUE))) - } + if (wantsOlder) out.add(filter.since to minOf(span.min, filter.until ?: Long.MAX_VALUE)) - // Newer: from the band's ceiling on, but not past the filter's. - if (filter.until == null || band.maxCreatedAt <= filter.until) { - legs.add(filter.copy(since = maxOf(band.maxCreatedAt, filter.since ?: Long.MIN_VALUE))) + // Newer: from the span's ceiling on, but not past the filter's. + if (filter.until == null || span.max <= filter.until) { + out.add(maxOf(span.max, filter.since ?: Long.MIN_VALUE) to filter.until) } - return legs + return out } /** @@ -162,21 +232,59 @@ class SyncCoverage( observedMax: Long?, paged: Boolean, reconciledThrough: Long? = null, + observedByKind: Map? = null, ) { if (reconciledThrough != null) { - put(url, filter, observedMin ?: reconciledThrough, reconciledThrough, complete = true) + // A reconcile compares the filter's whole id set in one pass, so + // the span it earns is the same for every kind the filter names — + // no per-kind evidence needed or possible. + val span = Span(observedMin ?: reconciledThrough, reconciledThrough) + put(url, filter, kindsOf(filter).associateWith { span }, complete = true) return } if (!paged) return + + if (observedByKind != null) { + // Guarded per span for the same reason the aggregate is below. + val plausible = + observedByKind.filterValues { + isPlausible(it.min, now()) && isPlausible(it.max, now()) + } + if (plausible.isEmpty()) return + put(url, filter, plausible, complete = false) + return + } + + // No per-kind evidence. For a filter naming one kind (or none) the + // aggregate IS the per-kind answer and nothing is lost. For a filter + // naming several it is not: attributing one interval to all of them is + // exactly the over-claim [Band.spans] exists to stop, and a band that + // over-claims skips events silently — strictly worse than re-reading + // them. So record nothing and say why, once. The caller resumes as if + // it had no band, which is where it was before bands existed. + val kinds = kindsOf(filter) + if (kinds.size > 1) { + if (!warnedAboutUnattributed) { + warnedAboutUnattributed = true + Log.w("SyncCoverage") { + "paged record for a ${kinds.size}-kind filter with no per-kind spans — no band recorded, so this " + + "walk will not resume. Pass observedByKind (see SyncCoverage.observe) to earn one." + } + } + return + } // Guarded even though callers should filter with [isPlausible] per // event: a 1970 floor or a far-future ceiling would make the band // claim the whole timeline, and the leg outside it would ask for a // range nothing can be in, forever. if (observedMin == null || observedMax == null) return if (!isPlausible(observedMin, now()) || !isPlausible(observedMax, now())) return - put(url, filter, observedMin, observedMax, complete = false) + put(url, filter, kinds.associateWith { Span(observedMin, observedMax) }, complete = false) } + /** The kinds a band is keyed by: the filter's, or [ALL_KINDS] when it names none. */ + private fun kindsOf(filter: Filter): List = filter.kinds?.takeIf { it.isNotEmpty() } ?: listOf(ALL_KINDS) + /** * Widen (or reset) the band. A pass that ran because the previous band * had gone stale REPLACES it: it re-walked the whole filter, so its own @@ -185,22 +293,12 @@ class SyncCoverage( private fun put( url: NormalizedRelayUrl, filter: Filter, - min: Long, - max: Long, + spans: Map, complete: Boolean, ) { - val fresh = Band(min, max, complete, now()) + val fresh = Band(spans, complete, now()) bands.merge(key(url, filter), fresh) { old, new -> - if (isStale(old)) { - new - } else { - Band( - minOf(old.minCreatedAt, new.minCreatedAt), - maxOf(old.maxCreatedAt, new.maxCreatedAt), - old.complete || new.complete, - old.fullAt, - ) - } + if (isStale(old)) new else old.widen(new) } onChange() } @@ -276,7 +374,45 @@ class SyncCoverage( return "${url.url} $fingerprint" } + // One line per process, not per walk: the point is to tell a caller it has + // not been migrated, and repeating it every leg would bury the log it is + // trying to be read in. + private var warnedAboutUnattributed = false + companion object { + /** + * The span key for a filter that names no kinds, and the fallback for + * a band restored from a file written before spans were per kind. + * Negative because NIP-01 kinds are not. + */ + const val ALL_KINDS = -1 + + /** + * Widen [into] with one event's stamp, so a caller can accumulate the + * per-kind evidence [record] wants as events arrive: + * + * val seen = mutableMapOf() + * ... onEvent { SyncCoverage.observe(seen, it.kind, it.createdAt) } + * coverage.record(url, filter, …, paged = true, observedByKind = seen) + * + * Implausible stamps are dropped here rather than by each caller — + * per EVENT, never over a leg's aggregate, because one misdated event + * among hundreds of thousands would otherwise discard the whole band. + * + * Not synchronized: it replaces a pair of plain `var`s at each call + * site and is meant for the same single-consumer callback. + */ + fun observe( + into: MutableMap, + kind: Int, + createdAt: Long, + now: Long = TimeUtils.now(), + ) { + if (!isPlausible(createdAt, now)) return + val one = Span(createdAt, createdAt) + into[kind] = into[kind]?.widen(one) ?: one + } + // More filter instances than any deliberate configuration holds; only // a caller rebuilding filters per cycle ever reaches it. private const val MAX_FINGERPRINTS = 1_000 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt index 53120624a4..87cac2c977 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt @@ -382,4 +382,124 @@ class SyncCoverageTest { val copy = Filter(kinds = listOf(30382), authors = (1..500).map { it.toString(16).padStart(64, '0') }) assertEquals(1_700_001_000L, c.band(relay, copy)?.minCreatedAt, "identity caching must not change the key") } + + // ---- per-kind spans: one interval cannot speak for several kinds ------- + + private val mixed = Filter(kinds = listOf(0, 30382)) + + /** Does any leg still ask [kind] about the instant [at]? */ + private fun reaches( + legs: List, + kind: Int, + at: Long, + ) = legs.any { + (it.kinds?.contains(kind) ?: true) && + (it.since ?: Long.MIN_VALUE) <= at && + at <= (it.until ?: Long.MAX_VALUE) + } + + @Test + fun `a long-lived kind no longer vouches for a short-lived one`() { + // THE BUG. Ask for profiles and score cards together: the relay has + // profiles going back years and score cards only from last month. One + // interval per band recorded 2020..now for the pair, and the next run + // skipped that whole interior for BOTH — so score cards written inside + // it were never asked for again, and nothing anywhere said so. + val c = SyncCoverage() + c.record( + relay, + mixed, + null, + null, + paged = true, + observedByKind = + mapOf( + 0 to SyncCoverage.Span(1_600_000_000L, 1_700_000_000L), + 30382 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L), + ), + ) + val legs = c.legs(relay, mixed) + + assertTrue(!reaches(legs, 0, 1_650_000_000L), "kind 0 really was walked there — do not re-read it") + assertTrue(reaches(legs, 30382, 1_650_000_000L), "kind 30382 never was, and must still be asked") + // Both keep the ground they actually earned. + assertTrue(!reaches(legs, 30382, 1_695_000_000L), "…but not its own covered interior") + assertTrue(reaches(legs, 0, 1_500_000_000L), "and both still reach below everything walked") + } + + @Test + fun `kinds whose coverage agrees stay a single ask`() { + // The cost control. Splitting per kind would turn two legs into two + // per kind on every filter, which is the common case made worse to fix + // the rare one. Kinds are regrouped by the windows they want, so + // identical coverage collapses back to exactly what it was before. + val c = SyncCoverage() + val span = SyncCoverage.Span(1_690_000_000L, 1_700_000_000L) + c.record(relay, mixed, null, null, paged = true, observedByKind = mapOf(0 to span, 30382 to span)) + + val legs = c.legs(relay, mixed) + assertEquals(2, legs.size, "two legs, not two per kind") + assertEquals(listOf(0, 30382), legs[0].kinds, "and both kinds ride in one ask") + } + + @Test + fun `a multi-kind paged walk with no per-kind evidence earns no band`() { + // The caller did not say which kind it saw where, so the only band + // available is the over-wide one. Refused: a band that over-claims + // skips events silently, which is worse than re-reading them. The + // walk resumes from nothing, exactly as it did before bands existed. + val c = SyncCoverage() + c.record(relay, mixed, 1_690_000_000L, 1_700_000_000L, paged = true) + + assertNull(c.band(relay, mixed)) + assertEquals(listOf(mixed), c.legs(relay, mixed)) + + // A filter naming ONE kind is unaffected: there, the aggregate IS the + // per-kind answer and nothing was ever ambiguous about it. + c.record(relay, profiles, 1_690_000_000L, 1_700_000_000L, paged = true) + assertEquals(2, c.legs(relay, profiles).size) + } + + @Test + fun `a finished reconcile covers every kind the filter names`() { + // Negentropy compares the filter's whole id set in one pass, so it + // either covers every kind in it or none — no per-kind evidence needed, + // and none invented. + val c = SyncCoverage() + c.record(relay, mixed, null, null, paged = false, reconciledThrough = 1_700_000_000L) + + assertEquals(setOf(0, 30382), c.band(relay, mixed)!!.spans.keys) + val legs = c.legs(relay, mixed) + assertEquals(1, legs.size, "complete: no older leg, and one shared newer one") + assertEquals(1_700_000_000L, legs[0].since) + } + + @Test + fun `a band restored from a pre-split file still narrows every kind`() { + // Files written before spans were per kind carry one interval. It is + // the old, wider claim — loaded as what it always meant rather than + // discarded, because discarding it would re-download every upstream's + // corpus once on upgrade. The first per-kind walk replaces it. + val seed = SyncCoverage() + // A plausible span, or record() correctly drops it and there is no key to read. + seed.record(relay, mixed, null, null, paged = true, observedByKind = mapOf(0 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L))) + val key = seed.export().keys.single() + + val restored = SyncCoverage() + restored.restore( + mapOf( + key to + SyncCoverage.Band( + mapOf(SyncCoverage.ALL_KINDS to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)), + complete = false, + fullAt = now(), + ), + ), + ) + + val legs = restored.legs(relay, mixed) + assertEquals(2, legs.size, "one shared pair of legs, which is the old behaviour exactly") + assertEquals(listOf(0, 30382), legs[0].kinds) + assertEquals(1_690_000_000L, legs[0].until) + } } From 74145ee8f3ceda3785c3591e43ff26b9a616dfa2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 5 Aug 2026 16:08:56 +0000 Subject: [PATCH 125/227] Code-review fixes: two ways per-kind spans could be recorded and not used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both found in the review pass over the previous commit, both the same shape — a band written that no lookup can reach, or reaches wrongly. - Spans for kinds the filter never named were stored as given. Inert for legs(), which only looks up the filter's own kinds, but NOT for Band.minCreatedAt — and that is what SyncCoverageFile writes as its rollback-compat `min`/`max`. A relay answering with more than it was asked for (or a caller whose containment check runs against a different filter than the band is keyed by) would push that floor below anything the filter's kinds support, so a binary from before per-kind spans would read the file and over-claim. The fix, undone through the compatibility path it added. - observedByKind on a filter that names NO kinds was stored per kind, while legs() for such a filter reads only ALL_KINDS. The band was recorded, persisted, and never consulted: a resume that silently did not resume. Collapsed to the union, which is the only claim a kind-less filter can make. Why these were not in the initial diff: both live where the new per-kind path meets an OLD assumption — that record()'s input is already scoped to the filter, and that a band's keys are always the filter's kinds. Neither held once callers began supplying the map themselves. Co-Authored-By: Claude Opus 5 --- .../relay/client/accessories/SyncCoverage.kt | 26 ++++++++- .../client/accessories/SyncCoverageTest.kt | 57 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt index 4e6de68696..74bcddbb3d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt @@ -251,7 +251,31 @@ class SyncCoverage( isPlausible(it.min, now()) && isPlausible(it.max, now()) } if (plausible.isEmpty()) return - put(url, filter, plausible, complete = false) + val named = filter.kinds + val spans = + if (named.isNullOrEmpty()) { + // A filter naming no kinds cannot be split, so [legs] reads + // ALL_KINDS and nothing else. Storing what the walk saw per + // kind would record a band no lookup can ever reach — it + // would exist and do nothing. Collapse to the union, which + // is the only claim such a filter can make. + mapOf(ALL_KINDS to plausible.values.reduce { a, b -> a.widen(b) }) + } else { + // Only kinds the filter NAMES. A relay may answer with more + // than it was asked for, and a caller whose containment + // check runs against a different filter than the band is + // keyed by passes those straight through. Keeping them + // would be inert for [legs] — which looks up the filter's + // own kinds — but NOT for [Band.minCreatedAt], which the + // state file writes as its rollback-compat `min`/`max`. An + // off-filter kind seen further back would widen those past + // anything the filter's kinds support, so a binary from + // before per-kind spans would read that file and + // over-claim: this fix undone through the compat path. + plausible.filterKeys { it in named } + } + if (spans.isEmpty()) return + put(url, filter, spans, complete = false) return } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt index 87cac2c977..3c26e1891e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt @@ -502,4 +502,61 @@ class SyncCoverageTest { assertEquals(listOf(0, 30382), legs[0].kinds) assertEquals(1_690_000_000L, legs[0].until) } + + @Test + fun `a kind the filter never asked for cannot widen the band`() { + // A relay may answer with more than it was asked for. Those spans are + // inert for legs(), which only looks up the filter's own kinds — but + // NOT for Band.minCreatedAt, which the state file writes as its + // rollback-compat min/max. Left in, a stray kind seen further back + // would widen that past anything the filter's kinds support, and a + // binary from before per-kind spans would read the file and over-claim. + val c = SyncCoverage() + c.record( + relay, + profiles, + null, + null, + paged = true, + observedByKind = + mapOf( + 0 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L), + // never asked for, and much older + 1 to SyncCoverage.Span(1_600_000_000L, 1_610_000_000L), + ), + ) + + val band = c.band(relay, profiles)!! + assertEquals(setOf(0), band.spans.keys, "only the kind the filter names") + assertEquals(1_690_000_000L, band.minCreatedAt, "…so the compat floor stays honest") + } + + @Test + fun `per-kind evidence on a filter naming no kinds collapses to one span`() { + // Such a filter cannot be split, so legs() reads ALL_KINDS and nothing + // else. Storing per-kind spans here would record a band no lookup can + // reach — present in the file, doing nothing. + val anyKind = Filter(authors = listOf("a".repeat(64))) + val c = SyncCoverage() + c.record( + relay, + anyKind, + null, + null, + paged = true, + observedByKind = + mapOf( + 0 to SyncCoverage.Span(1_690_000_000L, 1_695_000_000L), + 30382 to SyncCoverage.Span(1_697_000_000L, 1_700_000_000L), + ), + ) + + val band = c.band(relay, anyKind)!! + assertEquals(setOf(SyncCoverage.ALL_KINDS), band.spans.keys) + assertEquals(1_690_000_000L, band.spans.getValue(SyncCoverage.ALL_KINDS).min, "the union, not one of them") + assertEquals(1_700_000_000L, band.spans.getValue(SyncCoverage.ALL_KINDS).max) + // …and it is actually USED, which is the half that was silently missing. + assertEquals(2, c.legs(relay, anyKind).size) + assertEquals(1_690_000_000L, c.legs(relay, anyKind)[0].until) + } } From a3fac4fc085a04a3da344e53c8001d4bdb3c146b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 5 Aug 2026 12:07:27 -0400 Subject: [PATCH 126/227] Suspend the incoming-message chain down to SubscriptionListener.onEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consumer that cannot suspend has to block, and blocking here deadlocks the whole client. Measured on a mirror built against this library, twice, ~13 minutes after each start: all 64 shared coroutine workers parked in `runBlocking` beneath `trySendBlocking`, called from the websocket message callback. The consumer draining that channel needed threads from the same pool to reach its store, so it could never make room, so the producers never woke. Every stream, the health reporter, all of it stopped, at 2% CPU with a healthy, idle backend. A full queue was the symptom; producers eating the threads the drain needed was the cause. The coroutine context was already there — BasicOkHttpWebSocket has always processed messages inside `scope.launch { for (message in incomingMessages) }` — so the only thing forcing a blocking hand-off was that the hops in between were declared non-suspend. Now they are not: WebSocketListener.onMessage RelayConnectionListener.onIncomingMessage PoolRequests/PoolCounts/PoolEventOutbox.onIncomingMessage SubscriptionListener.onEvent fetchAllPages / negentropy accessories' onEvent parameter A consumer that fills its buffer now suspends and releases its thread rather than holding it, which is the same reasoning BasicOkHttpWebSocket already documents for keeping its own channel UNLIMITED so a slow consumer cannot block OkHttp reader threads. This extends it one layer down. BLE is the one transport whose callback genuinely cannot suspend — the platform hands notifications to a plain callback — so BleNostrClient gets the same treatment the websocket transport already had: an UNLIMITED hand-off channel so the BLE stack is never blocked, drained by ONE coroutine so message order survives the boundary. Tests that drove these entry points directly now do so from `runTest`, or from `runBlocking` where the call sits inside a raw thread or Runnable that models a platform callback. Co-Authored-By: Claude Opus 5 (1M context) --- .../napplet/NappletLiveSubscriptions.kt | 2 +- .../amethyst/service/ClinkDebitPayer.kt | 2 +- .../amethyst/service/ClinkOfferPayer.kt | 2 +- .../diagnostics/BootRelayDiagnostics.kt | 2 +- .../diagnostics/DmRelayDiagnosticsLogger.kt | 2 +- .../eoseManagers/PerUniqueIdEoseManager.kt | 2 +- .../PerUserAndFollowListEoseManager.kt | 2 +- .../eoseManagers/PerUserEoseManager.kt | 2 +- .../SingleSubNoEoseCacheEoseManager.kt | 2 +- .../notifyCommand/model/NotifyCoordinator.kt | 2 +- .../AccountFollowsLoaderSubAssembler.kt | 2 +- .../AccountNotificationsHistoryEoseManager.kt | 2 +- .../NwcNotificationsEoseManager.kt | 2 +- .../AccountGiftWrapsHistoryEoseManager.kt | 2 +- .../user/watchers/UserWatcherSubAssembler.kt | 2 +- .../speedLogger/RelaySpeedLogger.kt | 2 +- .../resourceusage/RelayUsageListener.kt | 2 +- .../ChatroomNip04HistorySubAssembler.kt | 2 +- .../ConcordChannelHistoryFilterAssembler.kt | 2 +- ...elayGroupOpenChatHistoryFilterAssembler.kt | 2 +- ...yGroupOpenThreadsHistoryFilterAssembler.kt | 2 +- .../ChatroomListNip04HistorySubAssembler.kt | 2 +- .../datasource/ChessFeedFilterSubAssembler.kt | 2 +- .../loggedIn/relays/eventsync/EventSync.kt | 2 +- .../com/vitorpamplona/amethyst/cli/Context.kt | 2 +- .../amethyst/cli/commands/GeochatCommands.kt | 2 +- .../amethyst/cli/commands/NipCommand.kt | 2 +- .../amethyst/cli/commands/NostrConnect.kt | 2 +- .../amethyst/cli/commands/SubscribeCommand.kt | 2 +- .../nip64Chess/ChessRelayFetchHelper.kt | 2 +- .../assemblers/FeedMetadataCoordinator.kt | 6 +- .../eoseManagers/PerKeyEoseManager.kt | 2 +- .../eoseManagers/SingleSubEoseManager.kt | 2 +- .../relays/health/RelayHealthListener.kt | 2 +- .../service/broadcast/BroadcastTracker.kt | 4 +- .../amethyst/commons/wot/OutboxDispatcher.kt | 4 +- .../nip64Chess/ChessEventBroadcaster.kt | 2 +- .../relayClient/paging/WindowLoadTracker.kt | 2 +- .../relays/health/RelayLatencyListener.kt | 2 +- .../nip17Dm/DmInboxRelayResolverOutboxTest.kt | 2 +- .../commons/wot/OutboxDispatcherTest.kt | 2 +- .../vitorpamplona/amethyst/desktop/Main.kt | 4 +- .../desktop/account/AccountManager.kt | 2 +- .../desktop/followpacks/FollowPacksState.kt | 2 +- .../desktop/followpacks/MetadataPrefetch.kt | 2 +- .../desktop/followpacks/ui/FromThePackFeed.kt | 2 +- .../followpacks/ui/RenderFollowPackCard.kt | 2 +- .../desktop/network/RelayConnectionManager.kt | 4 +- .../search/DesktopRelayUserSearchDelegate.kt | 2 +- .../subscriptions/ChessSubscription.kt | 2 +- .../DesktopRelaySubscriptionsCoordinator.kt | 4 +- .../subscriptions/SubscriptionUtils.kt | 2 +- .../desktop/ui/ImportFollowListDialog.kt | 4 +- .../amethyst/desktop/ui/NoteActions.kt | 4 +- .../desktop/ui/chats/ChatroomListState.kt | 2 +- .../desktop/benchmark/LaunchScenario.kt | 2 +- .../testrelay/LaunchFixtureRelayTest.kt | 2 +- .../testrelay/SubscribeBeforeConnectTest.kt | 2 +- .../geode/mirror/MirrorWorker.kt | 2 +- .../geode/GracefulShutdownTest.kt | 2 +- .../com/vitorpamplona/geode/KtorRelayTest.kt | 2 +- .../geode/Nip01ComplianceTest.kt | 12 +- .../vitorpamplona/geode/Nip09DeletionTest.kt | 2 +- .../geode/Nip77NegentropyTest.kt | 2 +- .../mirror/MirrorWorkerTrustOriginTest.kt | 2 +- .../vitorpamplona/geode/perf/LoadBenchmark.kt | 6 +- .../geode/testing/SubscriptionTesting.kt | 2 +- .../graperank/GrapeRankCrawler.kt | 2 +- .../nip01Core/relay/client/NostrClient.kt | 2 +- .../accessories/AdaptiveRelayLimiter.kt | 2 +- .../client/accessories/EventCollector.kt | 2 +- .../client/accessories/NostrClientCountExt.kt | 4 +- .../NostrClientFetchAllPagesExt.kt | 6 +- .../NostrClientFetchAllWithHooksExt.kt | 2 +- .../accessories/NostrClientFetchFirstExt.kt | 2 +- .../NostrClientNegentropyFanOutExt.kt | 2 +- .../NostrClientNegentropySyncExt.kt | 14 +- .../accessories/NostrClientPublishExt.kt | 2 +- .../RelayInsertConfirmationCollector.kt | 2 +- .../relay/client/accessories/RelayLogger.kt | 2 +- .../relay/client/accessories/RelayNotifier.kt | 2 +- .../relay/client/auth/RelayAuthenticator.kt | 2 +- .../client/counts/RelayActiveCountStates.kt | 2 +- .../relay/client/limits/RelayLimitsTracker.kt | 2 +- .../listeners/RedirectConnectionListener.kt | 2 +- .../listeners/RelayConnectionListener.kt | 2 +- .../nip01Core/relay/client/pool/PoolCounts.kt | 2 +- .../relay/client/pool/PoolEventOutbox.kt | 2 +- .../relay/client/pool/PoolRequests.kt | 2 +- .../nip01Core/relay/client/pool/RelayPool.kt | 2 +- .../relay/client/reqs/DynamicSubscription.kt | 2 +- .../client/reqs/NostrClientFetchAsFlowExt.kt | 2 +- .../reqs/NostrClientSubscribeAsFlowExt.kt | 2 +- .../client/reqs/RelayActiveRequestStates.kt | 2 +- .../relay/client/reqs/StaticSubscription.kt | 2 +- .../relay/client/reqs/SubscriptionListener.kt | 2 +- .../relay/client/reqs/stats/RelayReqStats.kt | 2 +- .../client/single/basic/BasicRelayClient.kt | 2 +- .../standalone/StandaloneRelayClient.kt | 2 +- .../relay/client/stats/RelayStats.kt | 2 +- .../relay/sockets/WebSocketListener.kt | 2 +- .../server/NostrConnectSignerService.kt | 2 +- .../reachability/RelayObserver.kt | 2 +- .../reachability/RelayProber.kt | 2 +- .../nip77Negentropy/NegentropyManager.kt | 2 +- .../quartz/nipBEBle/relay/BleMeshManager.kt | 2 +- .../quartz/nipBEBle/relay/BleNostrClient.kt | 40 +- .../client/limits/RelayLimitsTrackerTest.kt | 93 +++-- .../client/pool/PoolEventOutboxAuthTest.kt | 106 ++--- .../client/pool/PoolRequestsRefusalTest.kt | 194 ++++----- .../inprocess/InProcessWebSocketTest.kt | 4 +- .../server/NostrConnectSignerServiceTest.kt | 2 +- .../reachability/RelayObserverTest.kt | 390 +++++++++--------- .../reachability/RelayProberFlowTest.kt | 178 ++++---- .../relay/NostrClientManualSubTest.kt | 2 +- .../relay/NostrClientRepeatSubTest.kt | 2 +- .../relay/PoolRequestsConcurrencyTest.kt | 109 ++--- .../RelayAuthenticatorReauthOnClosedTest.kt | 2 +- .../client/NegentropyRejectionFallbackTest.kt | 41 +- .../relay/prodbench/ByIdFetchBenchmark.kt | 2 +- .../relay/prodbench/DispatchStageBenchmark.kt | 19 +- .../prodbench/NegentropyMultiRelayLiveTest.kt | 2 +- .../relay/prodbench/NegentropyStallRepro.kt | 2 +- .../prodbench/ProductionReceiverBenchmark.kt | 2 +- 124 files changed, 770 insertions(+), 682 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt index 8429a0b721..1e33c16323 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt @@ -80,7 +80,7 @@ class NappletLiveSubscriptions { val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt index 664b55ed61..3ea9d1678d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt @@ -113,7 +113,7 @@ object ClinkDebitPayer { val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt index 28a0d64fb0..a5911dc6c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -85,7 +85,7 @@ object ClinkOfferPayer { val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/BootRelayDiagnostics.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/BootRelayDiagnostics.kt index 1966bac786..054f6f855e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/BootRelayDiagnostics.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/BootRelayDiagnostics.kt @@ -158,7 +158,7 @@ class BootRelayDiagnostics( } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt index 4aefae6ba6..07f13fbd0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt @@ -112,7 +112,7 @@ class DmRelayDiagnosticsLogger( Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt index 0427380423..61c297887e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt @@ -78,7 +78,7 @@ abstract class PerUniqueIdEoseManager( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt index cc55f02d7a..7f53cbd0aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt @@ -90,7 +90,7 @@ abstract class PerUserAndFollowListEoseManager( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt index b904bf4ab9..c89d53a6e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt @@ -77,7 +77,7 @@ abstract class PerUserEoseManager( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt index d4df1e5deb..a80357e283 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt @@ -53,7 +53,7 @@ abstract class SingleSubNoEoseCacheEoseManager( } } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt index 92b40d456e..c7e146b5bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt @@ -78,7 +78,7 @@ class NotifyCoordinator( } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 08ec932e0e..dcda63f127 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -116,7 +116,7 @@ class AccountFollowsLoaderSubAssembler( newEose(TimeUtils.now(), relay, forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt index 485f8fb9f9..5f15938130 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt @@ -193,7 +193,7 @@ class AccountNotificationsHistoryEoseManager( // cursors so a late callback can't move another account's cursors. newEose runs regardless. val myCursors = key.account.notificationHistory return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt index 384c89df06..97098d765e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt @@ -124,7 +124,7 @@ class NwcNotificationsEoseManager( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt index 02d351be88..911e6fb132 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt @@ -115,7 +115,7 @@ class AccountGiftWrapsHistoryEoseManager( // cursors so a late callback can't move another account's cursors. newEose runs regardless. val myCursors = key.account.chatroomList.giftWrapHistory return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt index d695cd8e7e..f5381a9be1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -74,7 +74,7 @@ class UserWatcherSubAssembler( newEose(relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt index 66718a9ea1..32942920eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt @@ -42,7 +42,7 @@ class RelaySpeedLogger( private val clientListener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt index a8ba773138..5d97ef56cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt @@ -48,7 +48,7 @@ class RelayUsageListener( } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt index 4cbe77bffb..c9e30794a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt @@ -116,7 +116,7 @@ class ChatroomNip04HistorySubAssembler( // so a late callback can't move another room's cursors. newEose (framework bookkeeping) runs anyway. val myCursors = cursorsFor(key) return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt index 2720cb1d77..a6cb65f165 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt @@ -170,7 +170,7 @@ class ConcordChannelHistorySubAssembler( // cursors so a late callback can't move another channel's cursors. newEose runs regardless. val myCursors = cursorsFor(key) return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt index 7b6f7bbcac..f75b814bf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt @@ -126,7 +126,7 @@ class RelayGroupOpenChatHistorySubAssembler( // cursors so a late callback can't move another group's cursors. newEose runs regardless. val myCursors = cursorsFor(key) return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt index ddbef10a9a..cb2d8cc4b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt @@ -123,7 +123,7 @@ class RelayGroupOpenThreadsHistorySubAssembler( // cursors so a late callback can't move another group's cursors. newEose runs regardless. val myCursors = cursorsFor(key) return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt index bf7f2fee05..60a41ede35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt @@ -108,7 +108,7 @@ class ChatroomListNip04HistorySubAssembler( // cursors so a late callback can't move another account's cursors. newEose runs regardless. val myCursors = key.account.chatroomList.nip04History return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt index 01616b65a6..0780530666 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt @@ -70,7 +70,7 @@ class ChessFeedFilterSubAssembler( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt index 653003ecf4..0918f1a13b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt @@ -456,7 +456,7 @@ class EventSync( } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index b36cb843e3..02b482bebf 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -668,7 +668,7 @@ class Context( val filters = relays.associateWith { listOf(responseFilter) } val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt index 2547025b63..22c74904ce 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt @@ -133,7 +133,7 @@ object GeochatCommands { val subId = newSubId() val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt index 786eefed70..21c69fabf9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt @@ -167,7 +167,7 @@ object NipCommand { val remaining = SEARCH_RELAYS.toMutableSet() val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt index 33d51fe403..03629f9922 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt @@ -118,7 +118,7 @@ object NostrConnect { val subId = newSubId() val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt index b8afce7b90..5779bacf6c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt @@ -81,7 +81,7 @@ object SubscribeCommand { val subId = newSubId() val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessRelayFetchHelper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessRelayFetchHelper.kt index f45c499791..2a9c5a407c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessRelayFetchHelper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessRelayFetchHelper.kt @@ -100,7 +100,7 @@ class ChessRelayFetchHelper( val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt index 659ca97dcd..2260b279a5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt @@ -99,7 +99,7 @@ class FeedMetadataCoordinator( val listener = if (onEvent != null) { object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -295,7 +295,7 @@ class FeedMetadataCoordinator( val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -371,7 +371,7 @@ class FeedMetadataCoordinator( val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt index b609d52dc2..101216858c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/PerKeyEoseManager.kt @@ -90,7 +90,7 @@ abstract class PerKeyEoseManager( newEose(queryState, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt index fd2c6f4922..da0c3c66d9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubEoseManager.kt @@ -86,7 +86,7 @@ abstract class SingleSubEoseManager( newEose(relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthListener.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthListener.kt index b90d9e1502..6e564e356e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthListener.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthListener.kt @@ -44,7 +44,7 @@ class RelayHealthListener( store.recordConnect(relay.url, TimeUtils.now()) } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/broadcast/BroadcastTracker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/broadcast/BroadcastTracker.kt index e9f8166cc6..01f4c8a9fb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/broadcast/BroadcastTracker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/broadcast/BroadcastTracker.kt @@ -118,7 +118,7 @@ class BroadcastTracker { } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, @@ -294,7 +294,7 @@ class BroadcastTracker { } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt index 24b6401cb3..7374a54337 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt @@ -371,7 +371,7 @@ class OutboxDispatcher( val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -415,7 +415,7 @@ class OutboxDispatcher( val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessEventBroadcaster.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessEventBroadcaster.kt index e8630a810a..47a66c3f6c 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessEventBroadcaster.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessEventBroadcaster.kt @@ -90,7 +90,7 @@ class ChessEventBroadcaster( val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/WindowLoadTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/WindowLoadTracker.kt index 345fe58f18..80837de516 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/WindowLoadTracker.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/WindowLoadTracker.kt @@ -212,7 +212,7 @@ fun WindowLoadTracker.trackingListener(forward: (NormalizedRelayUrl, List filter.kinds?.forEach { kind -> script[kind to relay]?.forEach { event -> - listener?.onEvent(event, isLive = false, relay = relay, forFilters = null) + kotlinx.coroutines.runBlocking { listener?.onEvent(event, isLive = false, relay = relay, forFilters = null) } } } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt index 9238b5ce1d..5fe6483199 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt @@ -171,7 +171,7 @@ class OutboxDispatcherTest { filterList.forEach { filter -> filter.kinds?.forEach { kind -> script[kind to relay]?.forEach { event -> - listener?.onEvent(event, isLive = false, relay = relay, forFilters = null) + kotlinx.coroutines.runBlocking { listener?.onEvent(event, isLive = false, relay = relay, forFilters = null) } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index c9966e3442..3c5faabd88 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1786,7 +1786,7 @@ fun MainContent( filters = listOf(filter), listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: com.vitorpamplona.quartz.nip01Core.core.Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -1845,7 +1845,7 @@ fun MainContent( relays = outbox, listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: com.vitorpamplona.quartz.nip01Core.core.Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 549847e8b0..6eb0354925 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -249,7 +249,7 @@ class AccountManager internal constructor( } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt index 80e88355c0..cb506f3282 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt @@ -164,7 +164,7 @@ class FollowPacksState( private fun subscribeToDiscovery() { val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: com.vitorpamplona.quartz.nip01Core.core.Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/MetadataPrefetch.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/MetadataPrefetch.kt index 240056ddf1..bdeb93104c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/MetadataPrefetch.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/MetadataPrefetch.kt @@ -45,7 +45,7 @@ fun RelayConnectionManager.subscribeMetadataFor( val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = pubkeys) val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FromThePackFeed.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FromThePackFeed.kt index f9b1efc816..de661f4b36 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FromThePackFeed.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FromThePackFeed.kt @@ -92,7 +92,7 @@ fun FromThePackFeed( listOf(filter), listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/RenderFollowPackCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/RenderFollowPackCard.kt index ae82ca7876..3a1bc3f9b6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/RenderFollowPackCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/RenderFollowPackCard.kt @@ -102,7 +102,7 @@ fun RenderFollowPackCard( listOf(filter), listener = object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: com.vitorpamplona.quartz.nip01Core.core.Event, isLive: Boolean, relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt index 9e07c9eb21..73057a1619 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt @@ -200,7 +200,7 @@ open class RelayConnectionManager( filters = filterMap, listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -264,7 +264,7 @@ open class RelayConnectionManager( } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/search/DesktopRelayUserSearchDelegate.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/search/DesktopRelayUserSearchDelegate.kt index a58d2bad74..5b74f8eccc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/search/DesktopRelayUserSearchDelegate.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/search/DesktopRelayUserSearchDelegate.kt @@ -69,7 +69,7 @@ class DesktopRelayUserSearchDelegate( relays = relays, listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt index 0d0c645669..6b6602d57f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt @@ -96,7 +96,7 @@ class DesktopChessSubscriptionController( relays = state.relays, listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index ffa8f497ec..3a315d6507 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -303,7 +303,7 @@ class DesktopRelaySubscriptionsCoordinator( val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -428,7 +428,7 @@ class DesktopRelaySubscriptionsCoordinator( val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt index b9fa38e75b..7fe5bfb77e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt @@ -81,7 +81,7 @@ fun rememberSubscription( relays = cfg.relays, listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ImportFollowListDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ImportFollowListDialog.kt index 52d3983dfb..41b057c510 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ImportFollowListDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ImportFollowListDialog.kt @@ -225,7 +225,7 @@ fun ImportFollowListDialog( relays = relays, listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -277,7 +277,7 @@ fun ImportFollowListDialog( ), listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 1aa9f3a1fe..66770814d0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -858,7 +858,7 @@ private suspend fun fetchMetadataForUsers( relays = relays, listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -1666,7 +1666,7 @@ private suspend fun fetchUserLightningAddress( relays = relays, listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt index d636037723..2b18254d14 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt @@ -175,7 +175,7 @@ class ChatroomListState( val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt index 79fb062b0e..7edddfa185 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt @@ -139,7 +139,7 @@ object LaunchScenario { ), listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: com.vitorpamplona.quartz.nip01Core.core.Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelayTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelayTest.kt index 618704a8ed..95d9c8a8d1 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelayTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/LaunchFixtureRelayTest.kt @@ -73,7 +73,7 @@ class LaunchFixtureRelayTest { ), listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/SubscribeBeforeConnectTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/SubscribeBeforeConnectTest.kt index 2720941b42..b70fd89c9b 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/SubscribeBeforeConnectTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/SubscribeBeforeConnectTest.kt @@ -72,7 +72,7 @@ class SubscribeBeforeConnectTest { ), listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt index c98e050e37..0557f58afa 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt @@ -686,7 +686,7 @@ class MirrorWorker( val watermark = AtomicLong(initialSince) val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/GracefulShutdownTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/GracefulShutdownTest.kt index 7a55645b7f..3d568e2550 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/GracefulShutdownTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/GracefulShutdownTest.kt @@ -125,7 +125,7 @@ class GracefulShutdownTest { val gotEose = Channel(UNLIMITED) val listener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt index 1b8f84833a..b79f81bf51 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt @@ -389,7 +389,7 @@ class KtorRelayTest { "close-test", mapOf(server.url.normalizeRelayUrl() to listOf(Filter(kinds = listOf(1)))), object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: com.vitorpamplona.quartz.nip01Core.core.Event, isLive: Boolean, relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt index 4851956a13..7f69de4c89 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt @@ -276,7 +276,7 @@ class Nip01ComplianceTest : RelayClientTest() { "sub-A", mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))), object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -297,7 +297,7 @@ class Nip01ComplianceTest : RelayClientTest() { "sub-B", mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(4)))), object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -385,7 +385,7 @@ class Nip01ComplianceTest : RelayClientTest() { "live-1", mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))), object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -424,7 +424,7 @@ class Nip01ComplianceTest : RelayClientTest() { "live-2", mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))), object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -465,7 +465,7 @@ class Nip01ComplianceTest : RelayClientTest() { "eph-1", mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(20_001)))), object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -534,7 +534,7 @@ class Nip01ComplianceTest : RelayClientTest() { relayB to listOf(Filter(kinds = listOf(1))), ), object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt index c4e801665a..914da8e385 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt @@ -80,7 +80,7 @@ class Nip09DeletionTest { subId, mapOf(relayUrl to listOf(filter)), object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip77NegentropyTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip77NegentropyTest.kt index edff7aeb69..1b156225ca 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip77NegentropyTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip77NegentropyTest.kt @@ -96,7 +96,7 @@ class Nip77NegentropyTest { compression: Boolean, ) {} - override fun onMessage(text: String) { + override suspend fun onMessage(text: String) { incoming.trySend(text) } diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorWorkerTrustOriginTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorWorkerTrustOriginTest.kt index 28d7701ebc..634331fdf9 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorWorkerTrustOriginTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorWorkerTrustOriginTest.kt @@ -99,7 +99,7 @@ class MirrorWorkerTrustOriginTest { override fun connect() { connected = true out.onOpen(0, false) - out.onMessage(frame) + kotlinx.coroutines.runBlocking { out.onMessage(frame) } } override fun disconnect() { diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/perf/LoadBenchmark.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/perf/LoadBenchmark.kt index 771a12784a..7c4acddd4b 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/perf/LoadBenchmark.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/perf/LoadBenchmark.kt @@ -258,7 +258,7 @@ class LoadBenchmark { "fanout-$i", mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: com.vitorpamplona.quartz.nip01Core.core.Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -436,7 +436,7 @@ class LoadBenchmark { "fanout-$i", mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: com.vitorpamplona.quartz.nip01Core.core.Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -568,7 +568,7 @@ class LoadBenchmark { ), ), object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: com.vitorpamplona.quartz.nip01Core.core.Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt index d2c7ec2d04..fc50d6ef09 100644 --- a/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt +++ b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt @@ -72,7 +72,7 @@ suspend fun NostrClient.collectUntilEoseMulti( subId, mapOf(relay to filters), object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankCrawler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankCrawler.kt index 70ef709b72..4cba6e0c9f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankCrawler.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankCrawler.kt @@ -1489,7 +1489,7 @@ class GrapeRankCrawler( val lastEvt = AtomicLong(-1) val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 8378d8140a..08e39201ad 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -329,7 +329,7 @@ class NostrClient( listeners.forEach { it.onSent(relay, cmdStr, cmd, success) } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/AdaptiveRelayLimiter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/AdaptiveRelayLimiter.kt index 0fcb87907b..be2547ea97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/AdaptiveRelayLimiter.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/AdaptiveRelayLimiter.kt @@ -137,7 +137,7 @@ class AdaptiveRelayLimiter( if (wait > 0) delay(wait) } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt index 7c97ec3211..1964d465b5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt @@ -37,7 +37,7 @@ class EventCollector( ) { private val clientListener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt index 0f19d9d75c..66e502740c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt @@ -59,7 +59,7 @@ suspend fun INostrClient.count( val listener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, @@ -117,7 +117,7 @@ suspend fun INostrClient.count( val listener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index f43aed278f..f7b4834515 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -94,7 +94,7 @@ suspend fun INostrClient.fetchAllPages( filters: List, idleTimeoutMs: Long = 30_000L, onNewPage: ((Long) -> Unit)? = null, - onEvent: (Event) -> Unit, + onEvent: suspend (Event) -> Unit, ): Int { var until: Long? = null var totalEvents = 0 @@ -172,7 +172,7 @@ suspend fun INostrClient.fetchAllPages( try { val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, @@ -320,7 +320,7 @@ suspend fun INostrClient.fetchAllPages( filters: List, idleTimeoutMs: Long = 30_000L, onNewPage: ((Long) -> Unit)? = null, - onEvent: (Event) -> Unit, + onEvent: suspend (Event) -> Unit, ): Int = fetchAllPages( relay = RelayUrlNormalizer.normalize(relay), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt index f3726f5fe2..62d13ce5e9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt @@ -102,7 +102,7 @@ suspend fun INostrClient.fetchAllWithHooks( val doneReasons = HashMap() val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt index 2a80fcfa45..07a3f18e22 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt @@ -96,7 +96,7 @@ suspend fun INostrClient.fetchFirst( val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt index cce9d24bf1..7ac08647f5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt @@ -83,7 +83,7 @@ suspend fun negentropySyncFanOut( idleTimeoutMs: Long = 120_000L, reconcileConcurrency: Int = 2, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, - onEvent: (Event) -> Unit, + onEvent: suspend (Event) -> Unit, ): NegentropyFanOutResult { require(clients.isNotEmpty()) { "at least one client is required" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt index 89360facbf..611720876a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt @@ -163,7 +163,7 @@ suspend fun INostrClient.negentropySync( idBufferBatches: Int = maxConcurrentReqs * 4, localEntries: List = emptyList(), onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, - onEvent: (Event) -> Unit, + onEvent: suspend (Event) -> Unit, ): NegentropySyncResult { val need = AtomicInt(0) val windows = AtomicInt(0) @@ -242,7 +242,7 @@ suspend fun INostrClient.negentropySync( idBufferBatches: Int = maxConcurrentReqs * 4, localEntries: List = emptyList(), onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, - onEvent: (Event) -> Unit, + onEvent: suspend (Event) -> Unit, ): NegentropySyncResult = negentropySync( relay = RelayUrlNormalizer.normalize(relay), @@ -309,14 +309,14 @@ suspend fun INostrClient.negentropySyncOrFetch( idBufferBatches: Int = maxConcurrentReqs * 4, localEntries: List = emptyList(), onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, - onEvent: (Event) -> Unit, + onEvent: suspend (Event) -> Unit, ): NegentropyOrFetchResult { val seen = HashSet() var delivered = 0 // Shared dedup + cap across both phases. Returns true if the event was new and // delivered. Both phases run sequentially, so no concurrent access. - fun accept(event: Event): Boolean { + suspend fun accept(event: Event): Boolean { if ((maxEvents <= 0 || delivered < maxEvents) && seen.add(event.id)) { delivered++ onEvent(event) @@ -364,7 +364,7 @@ suspend fun INostrClient.negentropySyncOrFetch( idBufferBatches: Int = maxConcurrentReqs * 4, localEntries: List = emptyList(), onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, - onEvent: (Event) -> Unit, + onEvent: suspend (Event) -> Unit, ): NegentropyOrFetchResult = negentropySyncOrFetch( relay = RelayUrlNormalizer.normalize(relay), @@ -876,7 +876,7 @@ private suspend fun INostrClient.reconcileStreaming( if (relay.url == targetUrl) clock.bump() } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, @@ -1103,7 +1103,7 @@ internal suspend fun INostrClient.fetchByIds( val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt index c1aa8c14a2..fc725dbe84 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientPublishExt.kt @@ -132,7 +132,7 @@ suspend fun INostrClient.publishAndCollectResults( } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt index 5014eada6f..6b536de30b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt @@ -37,7 +37,7 @@ class RelayInsertConfirmationCollector( ) { private val clientListener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index 78b304edae..86313d9a48 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -50,7 +50,7 @@ class RelayLogger( private val clientListener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt index 65f2c6aae8..1853aec5e3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt @@ -40,7 +40,7 @@ class RelayNotifier( private val clientListener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index 1c5bc662de..b45e652421 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -102,7 +102,7 @@ class RelayAuthenticator( private val clientListener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt index dba6888dc7..d03077ba2e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt @@ -45,7 +45,7 @@ class RelayActiveCountStates( queryStates.put(relay.url, CountQueryState()) } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt index a8ffa37d63..65f61e8057 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTracker.kt @@ -76,7 +76,7 @@ class RelayLimitsTracker( private val clientListener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectConnectionListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectConnectionListener.kt index 5d7fba5ffc..75e6ce6363 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectConnectionListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectConnectionListener.kt @@ -48,7 +48,7 @@ open class RedirectConnectionListener( listener.onSent(relay, cmdStr, cmd, success) } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt index feefbb532f..8f70a9cc11 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt @@ -53,7 +53,7 @@ interface RelayConnectionListener { /** * New error */ - fun onIncomingMessage( + suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt index 2d9a44ea31..97c48dc5fa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt @@ -123,7 +123,7 @@ class PoolCounts { } } - fun onIncomingMessage( + suspend fun onIncomingMessage( relay: IRelayClient, msg: Message, ) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt index c249d2aa3e..928cfd0bc2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt @@ -178,7 +178,7 @@ class PoolEventOutbox { } } - fun onIncomingMessage( + suspend fun onIncomingMessage( relay: NormalizedRelayUrl, msg: Message, ) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index 7d4b08315b..7c87d14712 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -238,7 +238,7 @@ class PoolRequests( /** * When a new message is received by the relay, updates the sub */ - fun onIncomingMessage( + suspend fun onIncomingMessage( relay: IRelayClient, msg: Message, ) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index 22c2e0003b..62fe531d86 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -248,7 +248,7 @@ class RelayPool( listener.onDisconnected(relay) } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/DynamicSubscription.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/DynamicSubscription.kt index 1821f4ad86..f6eeb770dd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/DynamicSubscription.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/DynamicSubscription.kt @@ -34,7 +34,7 @@ class DynamicSubscription( SubscriptionHandle { val subId = RandomInstance.randomChars(10) - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientFetchAsFlowExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientFetchAsFlowExt.kt index 1cda8b7e7c..6dd2ccaae5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientFetchAsFlowExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientFetchAsFlowExt.kt @@ -63,7 +63,7 @@ fun INostrClient.fetchAsFlow( val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientSubscribeAsFlowExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientSubscribeAsFlowExt.kt index 50b6af68ab..4d7736ac71 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientSubscribeAsFlowExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientSubscribeAsFlowExt.kt @@ -69,7 +69,7 @@ fun INostrClient.subscribeAsFlow( val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt index a51d2f1df2..f75dfbe365 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt @@ -46,7 +46,7 @@ class RelayActiveRequestStates( subStates[relay.url] = RequestSubscriptionState() } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/StaticSubscription.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/StaticSubscription.kt index 1e87560adb..d33e9ff080 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/StaticSubscription.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/StaticSubscription.kt @@ -35,7 +35,7 @@ class StaticSubscription( SubscriptionHandle { val subId = RandomInstance.randomChars(10) - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt index 45f614237d..15486f55af 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/SubscriptionListener.kt @@ -30,7 +30,7 @@ interface SubscriptionListener { forFilters: List?, ) {} - fun onEvent( + suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt index 2da8b3ca6d..0dcc1291df 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt @@ -37,7 +37,7 @@ class RelayReqStats( private val clientListener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 5005e63c9c..d0f1b7bc16 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -156,7 +156,7 @@ open class BasicRelayClient( listener.onConnected(this@BasicRelayClient, pingMillis, compression) } - override fun onMessage(text: String) { + override suspend fun onMessage(text: String) { try { val msg = decoder.decode(text) listener.onIncomingMessage(this@BasicRelayClient, text, msg) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt index 5063f03319..52f7947b2a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt @@ -66,7 +66,7 @@ class StandaloneRelayClient( syncFilters() } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt index b6548e1a2c..73b41bd000 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt @@ -83,7 +83,7 @@ class RelayStats( get(relay.url).addBytesSent(cmdStr.bytesUsedInMemory()) } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt index fef0f36a6b..4f4cdc522b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebSocketListener.kt @@ -30,7 +30,7 @@ interface WebSocketListener { compression: Boolean, ) - fun onMessage(text: String) + suspend fun onMessage(text: String) fun onClosed( code: Int, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index 4be37a0b84..bc473bcaf1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -183,7 +183,7 @@ class NostrConnectSignerService( val subId = newSubId() val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt index 1e293e3d05..d9d40ec0f5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserver.kt @@ -183,7 +183,7 @@ class RelayObserver : RelayConnectionListener { } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt index 48d33d9185..711c38551a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt @@ -254,7 +254,7 @@ class RelayProber( val subId = newSubId() val subListener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyManager.kt index 3984c1436d..83ad948534 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropyManager.kt @@ -94,7 +94,7 @@ class NegentropyManager( relay.sendIfConnected(session.close()) } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleMeshManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleMeshManager.kt index 9e33fb9b91..504d0ff9aa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleMeshManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleMeshManager.kt @@ -260,7 +260,7 @@ class BleMeshManager( private inner class ClientConnectionListener( val peerUuid: String, ) : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrClient.kt index 1a0771d2e7..2f71344a25 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/relay/BleNostrClient.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nipBEBle.relay import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nipBEBle.BleConfig @@ -31,8 +32,14 @@ import com.vitorpamplona.quartz.nipBEBle.protocol.BleChunkAssembler import com.vitorpamplona.quartz.nipBEBle.protocol.BleMessageChunker import com.vitorpamplona.quartz.nipBEBle.transport.BleTransport import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi @@ -72,6 +79,31 @@ class BleNostrClient( private val sendQueue = Channel>(Channel.UNLIMITED) private val isSending = AtomicBoolean(false) + /** Parsed messages waiting to reach the suspending listener — see [onChunkReceived]. */ + private val incoming = Channel>(Channel.UNLIMITED) + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private val pump = + scope.launch { + for ((raw, msg) in incoming) { + try { + listener.onIncomingMessage(this@BleNostrClient, raw, msg) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // One peer's bad message must not end the pump for the rest. + Log.e("BleNostrClient", "Failure handling message from ${peer.deviceUuid}: $raw", e) + } + } + } + + /** Stops the [pump]; the client is unusable afterwards, like a closed socket. */ + fun release() { + incoming.close() + scope.cancel() + } + override fun isConnected(): Boolean = connected override fun needsToReconnect(): Boolean = !connected @@ -144,7 +176,13 @@ class BleNostrClient( try { val msg = OptimizedJsonMapper.fromJsonToMessage(message) - listener.onIncomingMessage(this, message, msg) + // The platform hands BLE notifications to a callback that cannot + // suspend, and the listener chain now does — so the message is + // handed off rather than delivered here. Same shape the websocket + // transport already uses: UNLIMITED so this callback never blocks + // the BLE stack, drained by ONE coroutine so message order survives + // the boundary. + incoming.trySend(message to msg) } catch (e: Exception) { Log.e("BleNostrClient", "Failed to parse message from ${peer.deviceUuid}: $message", e) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt index 6547aeb9bb..39dae9ad12 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/limits/RelayLimitsTrackerTest.kt @@ -70,67 +70,72 @@ class RelayLimitsTrackerTest { } @Test - fun cachesLimitsPerRelay() { - val (limits, listener) = setup() - val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + fun cachesLimitsPerRelay() = + kotlinx.coroutines.test.runTest { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) - assertNull(limits.get(relay.url), "No limits before any LIMITS message") + assertNull(limits.get(relay.url), "No limits before any LIMITS message") - listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = true, maxLimit = 200)) + listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = true, maxLimit = 200)) - assertEquals(true, limits.get(relay.url)?.canWrite) - assertEquals(200, limits.get(relay.url)?.maxLimit) - assertEquals(limits.get(relay.url), limits.limitsFlow.value[relay.url]) - } + assertEquals(true, limits.get(relay.url)?.canWrite) + assertEquals(200, limits.get(relay.url)?.maxLimit) + assertEquals(limits.get(relay.url), limits.limitsFlow.value[relay.url]) + } @Test - fun laterLimitsReplaceEarlierOnes() { - val (limits, listener) = setup() - val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + fun laterLimitsReplaceEarlierOnes() = + kotlinx.coroutines.test.runTest { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) - listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = false, maxLimit = 200)) - listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = true, maxLimit = 500)) + listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = false, maxLimit = 200)) + listener.onIncomingMessage(relay, "", LimitsMessage(canWrite = true, maxLimit = 500)) - // A relay re-advertises LIMITS when rights change (e.g. after AUTH flips can_write). - assertEquals(true, limits.get(relay.url)?.canWrite) - assertEquals(500, limits.get(relay.url)?.maxLimit) - } + // A relay re-advertises LIMITS when rights change (e.g. after AUTH flips can_write). + assertEquals(true, limits.get(relay.url)?.canWrite) + assertEquals(500, limits.get(relay.url)?.maxLimit) + } @Test - fun tracksLimitsForDistinctRelaysIndependently() { - val (limits, listener) = setup() - val relayA = FakeRelayClient(NormalizedRelayUrl("wss://a.example/")) - val relayB = FakeRelayClient(NormalizedRelayUrl("wss://b.example/")) + fun tracksLimitsForDistinctRelaysIndependently() = + kotlinx.coroutines.test.runTest { + val (limits, listener) = setup() + val relayA = FakeRelayClient(NormalizedRelayUrl("wss://a.example/")) + val relayB = FakeRelayClient(NormalizedRelayUrl("wss://b.example/")) - listener.onIncomingMessage(relayA, "", LimitsMessage(maxLimit = 100)) - listener.onIncomingMessage(relayB, "", LimitsMessage(maxLimit = 999)) + listener.onIncomingMessage(relayA, "", LimitsMessage(maxLimit = 100)) + listener.onIncomingMessage(relayB, "", LimitsMessage(maxLimit = 999)) - assertEquals(100, limits.get(relayA.url)?.maxLimit) - assertEquals(999, limits.get(relayB.url)?.maxLimit) - assertEquals(2, limits.snapshot().size) - } + assertEquals(100, limits.get(relayA.url)?.maxLimit) + assertEquals(999, limits.get(relayB.url)?.maxLimit) + assertEquals(2, limits.snapshot().size) + } @Test - fun dropsCachedLimitsOnDisconnect() { - val (limits, listener) = setup() - val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + fun dropsCachedLimitsOnDisconnect() = + kotlinx.coroutines.test.runTest { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) - listener.onIncomingMessage(relay, "", LimitsMessage(canRead = true)) - assertTrue(limits.get(relay.url) != null) + listener.onIncomingMessage(relay, "", LimitsMessage(canRead = true)) + assertTrue(limits.get(relay.url) != null) - listener.onDisconnected(relay) - assertNull(limits.get(relay.url), "Limits are connection-scoped and cleared on disconnect") - assertTrue(limits.snapshot().isEmpty()) - } + listener.onDisconnected(relay) + assertNull(limits.get(relay.url), "Limits are connection-scoped and cleared on disconnect") + assertTrue(limits.snapshot().isEmpty()) + } @Test - fun ignoresNonLimitsMessages() { - val (limits, listener) = setup() - val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + fun ignoresNonLimitsMessages() = + kotlinx.coroutines.test.runTest { + val (limits, listener) = setup() + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) - listener.onIncomingMessage(relay, "", EoseMessage("sub1")) + listener.onIncomingMessage(relay, "", EoseMessage("sub1")) - assertNull(limits.get(relay.url)) - assertTrue(limits.snapshot().isEmpty()) - } + assertNull(limits.get(relay.url)) + assertTrue(limits.snapshot().isEmpty()) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxAuthTest.kt index f8250eb9a1..8d0fa9b9df 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxAuthTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxAuthTest.kt @@ -62,13 +62,13 @@ class PoolEventOutboxAuthTest { relays.forEach { onSent(it, EventCmd(event)) } } - private fun PoolEventOutbox.nak( + private suspend fun PoolEventOutbox.nak( event: Event, relay: NormalizedRelayUrl, message: String, ) = onIncomingMessage(relay, OkMessage(event.id, false, message)) - private fun PoolEventOutbox.ok( + private suspend fun PoolEventOutbox.ok( event: Event, relay: NormalizedRelayUrl, ) = onIncomingMessage(relay, OkMessage(event.id, true, "")) @@ -104,67 +104,71 @@ class PoolEventOutboxAuthTest { } @Test - fun authRequiredResetsTheTriesBudgetAcrossManyResends() { - val outbox = PoolEventOutbox() - val ev = event("ff".repeat(32)) + fun authRequiredResetsTheTriesBudgetAcrossManyResends() = + kotlinx.coroutines.test.runTest { + val outbox = PoolEventOutbox() + val ev = event("ff".repeat(32)) - outbox.publish(ev, setOf(relay)) // first try + outbox.publish(ev, setOf(relay)) // first try - // A flapping relay / slow AUTH handshake re-pumps the still-pending event many more times - // than the 4-try cap, each NAK'd auth-required. newTry() (the send path) grows `tries` and - // is not auth-aware, so unless auth-required resets the retry budget these sends would trip - // Tries.isDone() and drop the event (with a spurious give-up) before AUTH ever lands. - repeat(8) { i -> - assertNull(outbox.onSent(relay, EventCmd(ev)), "must not give up on re-pump $i") - outbox.nak(ev, relay, "auth-required: authenticate first") + // A flapping relay / slow AUTH handshake re-pumps the still-pending event many more times + // than the 4-try cap, each NAK'd auth-required. newTry() (the send path) grows `tries` and + // is not auth-aware, so unless auth-required resets the retry budget these sends would trip + // Tries.isDone() and drop the event (with a spurious give-up) before AUTH ever lands. + repeat(8) { i -> + assertNull(outbox.onSent(relay, EventCmd(ev)), "must not give up on re-pump $i") + outbox.nak(ev, relay, "auth-required: authenticate first") + } + assertEquals(setOf(relay), outbox.pendingRelaysFor(ev.id)) + + // AUTH finally completes -> the event delivers. + outbox.ok(ev, relay) + assertNull(outbox.pendingRelaysFor(ev.id)) } - assertEquals(setOf(relay), outbox.pendingRelaysFor(ev.id)) - - // AUTH finally completes -> the event delivers. - outbox.ok(ev, relay) - assertNull(outbox.pendingRelaysFor(ev.id)) - } @Test - fun terminalRejectionStillDiscardsImmediately() { - val outbox = PoolEventOutbox() - val ev = event("cc".repeat(32)) + fun terminalRejectionStillDiscardsImmediately() = + kotlinx.coroutines.test.runTest { + val outbox = PoolEventOutbox() + val ev = event("cc".repeat(32)) - outbox.publish(ev, setOf(relay)) - outbox.nak(ev, relay, "invalid: bad signature") + outbox.publish(ev, setOf(relay)) + outbox.nak(ev, relay, "invalid: bad signature") - assertNull(outbox.pendingRelaysFor(ev.id)) - } + assertNull(outbox.pendingRelaysFor(ev.id)) + } @Test - fun givesUpAndSignalsAfterExhaustingTryBudget() { - val outbox = PoolEventOutbox() - val ev = event("ee".repeat(32)) + fun givesUpAndSignalsAfterExhaustingTryBudget() = + kotlinx.coroutines.test.runTest { + val outbox = PoolEventOutbox() + val ev = event("ee".repeat(32)) - // markAsSending + first onSent (1 try). Tries budget is >3 tries. - outbox.publish(ev, setOf(relay)) - // attempts 2, 3 stay under budget and signal nothing. - assertNull(outbox.onSent(relay, EventCmd(ev))) - assertNull(outbox.onSent(relay, EventCmd(ev))) - // the 4th attempt exhausts the budget -> event is returned (gave up) and dropped. - assertEquals(ev.id, outbox.onSent(relay, EventCmd(ev))?.id) - assertNull(outbox.pendingRelaysFor(ev.id)) - } + // markAsSending + first onSent (1 try). Tries budget is >3 tries. + outbox.publish(ev, setOf(relay)) + // attempts 2, 3 stay under budget and signal nothing. + assertNull(outbox.onSent(relay, EventCmd(ev))) + assertNull(outbox.onSent(relay, EventCmd(ev))) + // the 4th attempt exhausts the budget -> event is returned (gave up) and dropped. + assertEquals(ev.id, outbox.onSent(relay, EventCmd(ev))?.id) + assertNull(outbox.pendingRelaysFor(ev.id)) + } @Test - fun ordinaryTransientFailureStillBounded() { - val outbox = PoolEventOutbox() - val ev = event("dd".repeat(32)) + fun ordinaryTransientFailureStillBounded() = + kotlinx.coroutines.test.runTest { + val outbox = PoolEventOutbox() + val ev = event("dd".repeat(32)) - outbox.publish(ev, setOf(relay)) - // 3 non-auth error responses exhaust the retry budget. Responses only - // accumulate here; the drop happens on the next send attempt. - repeat(3) { outbox.nak(ev, relay, "error: rate-limited") } - assertEquals(setOf(relay), outbox.pendingRelaysFor(ev.id)) + outbox.publish(ev, setOf(relay)) + // 3 non-auth error responses exhaust the retry budget. Responses only + // accumulate here; the drop happens on the next send attempt. + repeat(3) { outbox.nak(ev, relay, "error: rate-limited") } + assertEquals(setOf(relay), outbox.pendingRelaysFor(ev.id)) - // The next resend attempt observes the exhausted budget and drops the event - // (unlike auth-required, which never poisons the budget). - outbox.onSent(relay, EventCmd(ev)) - assertNull(outbox.pendingRelaysFor(ev.id)) - } + // The next resend attempt observes the exhausted budget and drops the event + // (unlike auth-required, which never poisons the budget). + outbox.onSent(relay, EventCmd(ev)) + assertNull(outbox.pendingRelaysFor(ev.id)) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsRefusalTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsRefusalTest.kt index 2e26da853a..91f5c03c4e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsRefusalTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsRefusalTest.kt @@ -75,123 +75,129 @@ class PoolRequestsRefusalTest { return sent } - private fun close( + private suspend fun close( pool: PoolRequests, subId: String, reason: String, ) = pool.onIncomingMessage(FakeRelayClient(relay), ClosedMessage(subId, reason)) @Test - fun stopsReplayingAThriceRefusedFilterAcrossReconnects() { - val pool = PoolRequests(maxRefusalsBeforeSuppress = 3) - pool.addOrUpdate("sub", mapOf(relay to plainFilter()), null) + fun stopsReplayingAThriceRefusedFilterAcrossReconnects() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests(maxRefusalsBeforeSuppress = 3) + pool.addOrUpdate("sub", mapOf(relay to plainFilter()), null) - // Under the threshold, each reconnect still replays the REQ (giving the relay a chance). - repeat(3) { attempt -> - val sent = reconnectAndSync(pool) - assertEquals(1, sent.filterIsInstance().size, "reconnect #$attempt should replay the REQ") - close(pool, "sub", "unsupported: too many filters") + // Under the threshold, each reconnect still replays the REQ (giving the relay a chance). + repeat(3) { attempt -> + val sent = reconnectAndSync(pool) + assertEquals(1, sent.filterIsInstance().size, "reconnect #$attempt should replay the REQ") + close(pool, "sub", "unsupported: too many filters") + } + + // Once the same filter has been refused [maxRefusalsBeforeSuppress] times, stop replaying it. + val suppressed = reconnectAndSync(pool) + assertTrue(suppressed.filterIsInstance().isEmpty(), "a thrice-refused filter must not be replayed again") } - // Once the same filter has been refused [maxRefusalsBeforeSuppress] times, stop replaying it. - val suppressed = reconnectAndSync(pool) - assertTrue(suppressed.filterIsInstance().isEmpty(), "a thrice-refused filter must not be replayed again") - } - @Test - fun aMeaningfulFilterChangeReEnablesTheReq() { - val pool = PoolRequests(maxRefusalsBeforeSuppress = 2) - pool.addOrUpdate("sub", mapOf(relay to plainFilter(1)), null) + fun aMeaningfulFilterChangeReEnablesTheReq() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests(maxRefusalsBeforeSuppress = 2) + pool.addOrUpdate("sub", mapOf(relay to plainFilter(1)), null) - repeat(2) { - reconnectAndSync(pool) - close(pool, "sub", "unsupported: too many filters") - } - assertTrue(reconnectAndSync(pool).filterIsInstance().isEmpty(), "refused filter is suppressed") + repeat(2) { + reconnectAndSync(pool) + close(pool, "sub", "unsupported: too many filters") + } + assertTrue(reconnectAndSync(pool).filterIsInstance().isEmpty(), "refused filter is suppressed") - // The app changes the subscription's filter (different kind) — the relay may now accept it. - pool.addOrUpdate("sub", mapOf(relay to plainFilter(30023)), null) - assertEquals(1, reconnectAndSync(pool).filterIsInstance().size, "a changed filter must be tried again") - } - - @Test - fun aSearchOnlyRelayStopsReceivingPlainReqsFromEveryNewSubOnOneConnection() { - // The search.nos.today case: 6 different subscriptions, one connection, each a - // distinct plain feed filter the relay CLOSES with `error: search filter is required`. - // Per-filter memory can't help (the filters differ); the relay-wide block must. - val pool = PoolRequests(relayRefusals = RelayReqRefusals(threshold = 2)) - - val sent = mutableListOf>() // subId -> did a REQ go out - - fun mountSub( - subId: String, - filter: List, - ) { - val affected = pool.addOrUpdate(subId, mapOf(relay to filter), null) - var reqSent = false - pool.sendToRelayIfChanged(subId, affected) { _, cmd -> if (cmd is ReqCmd) reqSent = true } - sent.add(subId to reqSent) - close(pool, subId, "error: search filter is required") + // The app changes the subscription's filter (different kind) — the relay may now accept it. + pool.addOrUpdate("sub", mapOf(relay to plainFilter(30023)), null) + assertEquals(1, reconnectAndSync(pool).filterIsInstance().size, "a changed filter must be tried again") } - mountSub("sub1", plainFilter(1)) - mountSub("sub2", plainFilter(2)) - mountSub("sub3", plainFilter(3)) - mountSub("sub4", plainFilter(4)) - - assertTrue(sent[0].second && sent[1].second, "the first two plain subs are sent (learning the relay is search-only)") - assertTrue(!sent[2].second && !sent[3].second, "after two refusals, further plain subs are not sent to a search-only relay") - } - @Test - fun aCapabilityBlockedRelayIsDroppedFromDesiredRelays() { - // The socket-closing half: once a relay is capability-blocked and no desired sub can - // use it, it leaves the desired-relay set so the pool disconnects it. - val pool = PoolRequests(relayRefusals = RelayReqRefusals(threshold = 2)) - pool.addOrUpdate("sub", mapOf(relay to plainFilter()), null) - assertTrue(relay in pool.desiredRelays.value, "the relay is wanted before it refuses anything") + fun aSearchOnlyRelayStopsReceivingPlainReqsFromEveryNewSubOnOneConnection() = + kotlinx.coroutines.test.runTest { + // The search.nos.today case: 6 different subscriptions, one connection, each a + // distinct plain feed filter the relay CLOSES with `error: search filter is required`. + // Per-filter memory can't help (the filters differ); the relay-wide block must. + val pool = PoolRequests(relayRefusals = RelayReqRefusals(threshold = 2)) - close(pool, "sub", "error: search filter is required") - assertTrue(relay in pool.desiredRelays.value, "one refusal doesn't drop it yet") + val sent = mutableListOf>() // subId -> did a REQ go out - close(pool, "sub", "error: search filter is required") - assertTrue(relay !in pool.desiredRelays.value, "a search-only relay with only plain subs is dropped (socket closes)") - } + suspend fun mountSub( + subId: String, + filter: List, + ) { + val affected = pool.addOrUpdate(subId, mapOf(relay to filter), null) + var reqSent = false + pool.sendToRelayIfChanged(subId, affected) { _, cmd -> if (cmd is ReqCmd) reqSent = true } + sent.add(subId to reqSent) + close(pool, subId, "error: search filter is required") + } - @Test - fun aSearchOnlyRelayStaysWantedWhileASearchSubNeedsIt() { - val pool = PoolRequests(relayRefusals = RelayReqRefusals(threshold = 2)) - pool.addOrUpdate("plain", mapOf(relay to plainFilter()), null) - pool.addOrUpdate("search", mapOf(relay to listOf(Filter(kinds = listOf(1), search = "nostr"))), null) + mountSub("sub1", plainFilter(1)) + mountSub("sub2", plainFilter(2)) + mountSub("sub3", plainFilter(3)) + mountSub("sub4", plainFilter(4)) - close(pool, "plain", "error: search filter is required") - close(pool, "plain", "error: search filter is required") - - assertTrue(relay in pool.desiredRelays.value, "the relay stays wanted: a search sub still has a usable filter for it") - } - - @Test - fun authRequiredAndRateLimitedAreNeverSuppressed() { - val pool = PoolRequests(maxRefusalsBeforeSuppress = 2) - pool.addOrUpdate("sub", mapOf(relay to plainFilter()), null) - - repeat(4) { - reconnectAndSync(pool) - close(pool, "sub", MachineReadablePrefix.AUTH_REQUIRED.format("authenticate first")) + assertTrue(sent[0].second && sent[1].second, "the first two plain subs are sent (learning the relay is search-only)") + assertTrue(!sent[2].second && !sent[3].second, "after two refusals, further plain subs are not sent to a search-only relay") } - assertEquals(1, reconnectAndSync(pool).filterIsInstance().size, "auth-required must keep replaying (auth resolves it)") - val pool2 = PoolRequests(maxRefusalsBeforeSuppress = 2) - pool2.addOrUpdate("sub", mapOf(relay to plainFilter()), null) - repeat(4) { + @Test + fun aCapabilityBlockedRelayIsDroppedFromDesiredRelays() = + kotlinx.coroutines.test.runTest { + // The socket-closing half: once a relay is capability-blocked and no desired sub can + // use it, it leaves the desired-relay set so the pool disconnects it. + val pool = PoolRequests(relayRefusals = RelayReqRefusals(threshold = 2)) + pool.addOrUpdate("sub", mapOf(relay to plainFilter()), null) + assertTrue(relay in pool.desiredRelays.value, "the relay is wanted before it refuses anything") + + close(pool, "sub", "error: search filter is required") + assertTrue(relay in pool.desiredRelays.value, "one refusal doesn't drop it yet") + + close(pool, "sub", "error: search filter is required") + assertTrue(relay !in pool.desiredRelays.value, "a search-only relay with only plain subs is dropped (socket closes)") + } + + @Test + fun aSearchOnlyRelayStaysWantedWhileASearchSubNeedsIt() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests(relayRefusals = RelayReqRefusals(threshold = 2)) + pool.addOrUpdate("plain", mapOf(relay to plainFilter()), null) + pool.addOrUpdate("search", mapOf(relay to listOf(Filter(kinds = listOf(1), search = "nostr"))), null) + + close(pool, "plain", "error: search filter is required") + close(pool, "plain", "error: search filter is required") + + assertTrue(relay in pool.desiredRelays.value, "the relay stays wanted: a search sub still has a usable filter for it") + } + + @Test + fun authRequiredAndRateLimitedAreNeverSuppressed() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests(maxRefusalsBeforeSuppress = 2) + pool.addOrUpdate("sub", mapOf(relay to plainFilter()), null) + + repeat(4) { + reconnectAndSync(pool) + close(pool, "sub", MachineReadablePrefix.AUTH_REQUIRED.format("authenticate first")) + } + assertEquals(1, reconnectAndSync(pool).filterIsInstance().size, "auth-required must keep replaying (auth resolves it)") + + val pool2 = PoolRequests(maxRefusalsBeforeSuppress = 2) + pool2.addOrUpdate("sub", mapOf(relay to plainFilter()), null) + repeat(4) { + pool2.onConnecting(relay) + val sent = mutableListOf() + pool2.syncState(relay) { sent.add(it) } + pool2.onIncomingMessage(FakeRelayClient(relay), ClosedMessage("sub", MachineReadablePrefix.RATE_LIMITED.format("slow down"))) + } pool2.onConnecting(relay) val sent = mutableListOf() pool2.syncState(relay) { sent.add(it) } - pool2.onIncomingMessage(FakeRelayClient(relay), ClosedMessage("sub", MachineReadablePrefix.RATE_LIMITED.format("slow down"))) + assertEquals(1, sent.filterIsInstance().size, "rate-limited must keep replaying (the limiter spaces it out)") } - pool2.onConnecting(relay) - val sent = mutableListOf() - pool2.syncState(relay) { sent.add(it) } - assertEquals(1, sent.filterIsInstance().size, "rate-limited must keep replaying (the limiter spaces it out)") - } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocketTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocketTest.kt index 2b9cda14cd..4874586ebd 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocketTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocketTest.kt @@ -74,7 +74,7 @@ class InProcessWebSocketTest { callbacks.trySend("open") } - override fun onMessage(text: String) { + override suspend fun onMessage(text: String) { callbacks.trySend("message") } @@ -132,7 +132,7 @@ class InProcessWebSocketTest { ) { } - override fun onMessage(text: String) { + override suspend fun onMessage(text: String) { // Answer the AUTH challenge immediately, the way // RelayAuthenticator does. The socket must be fully // wired by the time any server frame is delivered, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt index 12320fcfc5..3dcf86afd1 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -141,7 +141,7 @@ class NostrConnectSignerServiceTest { published.add(event) } - fun deliver(event: Event) { + suspend fun deliver(event: Event) { listener?.onEvent(event, isLive = true, relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!, forFilters = null) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt index 6522dd5c64..efa6af497a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayObserverTest.kt @@ -73,256 +73,276 @@ class RelayObserverTest { // ---- what we measured --------------------------------------------------- @Test - fun `rtt-open is the transport handshake rather than our own queueing`() { - // pingMillis is receivedResponseAtMillis - sentRequestAtMillis: it starts - // when the upgrade request goes out, so it excludes time the call spent - // queued in the client's dispatcher. Timing the enqueue instead published - // our own backlog as the relay's latency — a median of 33.5 SECONDS on a - // 16,507-relay fan-out, against a true minimum of 140ms — into the field - // aggregators rank relays by. - val o = RelayObserver() - o.onConnected(client(url), 140, false) - assertEquals(140L, o.only().rttOpenMs) - } + fun `rtt-open is the transport handshake rather than our own queueing`() = + kotlinx.coroutines.test.runTest { + // pingMillis is receivedResponseAtMillis - sentRequestAtMillis: it starts + // when the upgrade request goes out, so it excludes time the call spent + // queued in the client's dispatcher. Timing the enqueue instead published + // our own backlog as the relay's latency — a median of 33.5 SECONDS on a + // 16,507-relay fan-out, against a true minimum of 140ms — into the field + // aggregators rank relays by. + val o = RelayObserver() + o.onConnected(client(url), 140, false) + assertEquals(140L, o.only().rttOpenMs) + } @Test - fun `a handshake the transport could not time publishes no time`() { - val o = RelayObserver() - o.onConnected(client(url), 0, false) + fun `a handshake the transport could not time publishes no time`() = + kotlinx.coroutines.test.runTest { + val o = RelayObserver() + o.onConnected(client(url), 0, false) - val obs = o.only() - assertTrue(obs.reachable, "it opened, and that much is known") - assertNull(obs.rttOpenMs, "unmeasurable is not zero") - } + val obs = o.only() + assertTrue(obs.reachable, "it opened, and that much is known") + assertNull(obs.rttOpenMs, "unmeasurable is not zero") + } @Test - fun `an opened connection is timed rather than assumed`() { - val o = RelayObserver() - o.onConnecting(client(url)) - o.onConnected(client(url), 1, true) + fun `an opened connection is timed rather than assumed`() = + kotlinx.coroutines.test.runTest { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) - val obs = o.only() - assertTrue(obs.reachable) - assertNotNull(obs.rttOpenMs, "rtt-open must be measured — aggregators rank on it") - assertNull(obs.error) - } + val obs = o.only() + assertTrue(obs.reachable) + assertNotNull(obs.rttOpenMs, "rtt-open must be measured — aggregators rank on it") + assertNull(obs.error) + } @Test - fun `the read clock runs from the first REQ to the first EOSE`() { - val o = RelayObserver() - o.onConnecting(client(url)) - o.onConnected(client(url), 1, true) - o.onSent(client(url), "", ReqCmd("sub", emptyList()), true) - o.onIncomingMessage(client(url), "", EoseMessage("sub")) + fun `the read clock runs from the first REQ to the first EOSE`() = + kotlinx.coroutines.test.runTest { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + o.onSent(client(url), "", ReqCmd("sub", emptyList()), true) + o.onIncomingMessage(client(url), "", EoseMessage("sub")) - assertNotNull(o.only().rttReadMs) - } + assertNotNull(o.only().rttReadMs) + } @Test - fun `the write clock runs from the first EVENT to its OK`() { - val o = RelayObserver() - o.onConnecting(client(url)) - o.onConnected(client(url), 1, true) - o.onIncomingMessage(client(url), "", OkMessage("id", true, "")) + fun `the write clock runs from the first EVENT to its OK`() = + kotlinx.coroutines.test.runTest { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + o.onIncomingMessage(client(url), "", OkMessage("id", true, "")) - assertNull(o.collectUnreported().single().rttWriteMs, "an OK with nothing sent behind it times nothing") - } + assertNull(o.collectUnreported().single().rttWriteMs, "an OK with nothing sent behind it times nothing") + } @Test - fun `a non-REQ command does not start the read clock`() { - val o = RelayObserver() - o.onConnecting(client(url)) - o.onConnected(client(url), 1, true) - o.onSent(client(url), "", CloseCmd("sub"), true) - o.onIncomingMessage(client(url), "", EoseMessage("sub")) + fun `a non-REQ command does not start the read clock`() = + kotlinx.coroutines.test.runTest { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + o.onSent(client(url), "", CloseCmd("sub"), true) + o.onIncomingMessage(client(url), "", EoseMessage("sub")) - assertNull(o.only().rttReadMs) - } + assertNull(o.only().rttReadMs) + } // ---- what we refuse to claim -------------------------------------------- @Test - fun `a connection that never opened records the reason and no latency`() { - val o = RelayObserver() - o.onConnecting(client(url)) - o.onCannotConnect(client(url), "Expected HTTP 101 response but was '503 Service Unavailable'") + fun `a connection that never opened records the reason and no latency`() = + kotlinx.coroutines.test.runTest { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onCannotConnect(client(url), "Expected HTTP 101 response but was '503 Service Unavailable'") - val obs = o.only() - assertFalse(obs.reachable) - assertNull(obs.rttOpenMs, "nothing opened, so there is nothing to time") - assertTrue(obs.error!!.contains("503")) - } + val obs = o.only() + assertFalse(obs.reachable) + assertNull(obs.rttOpenMs, "nothing opened, so there is nothing to time") + assertTrue(obs.error!!.contains("503")) + } @Test - fun `a relay that answered stays answered through a later failure`() { - // A relay that worked a minute ago and blipped now is not the same thing - // as one that never answered, and only the writer decides which record - // that becomes. A single failure must not erase the success under it. - val o = RelayObserver() - o.onConnecting(client(url)) - o.onConnected(client(url), 1, true) - o.onCannotConnect(client(url), "connection reset") + fun `a relay that answered stays answered through a later failure`() = + kotlinx.coroutines.test.runTest { + // A relay that worked a minute ago and blipped now is not the same thing + // as one that never answered, and only the writer decides which record + // that becomes. A single failure must not erase the success under it. + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + o.onCannotConnect(client(url), "connection reset") - assertTrue(o.only().reachable, "one bad minute must not bury a relay that answered") - } + assertTrue(o.only().reachable, "one bad minute must not bury a relay that answered") + } @Test - fun `a reconnect clears the previous attempt's error`() { - val o = RelayObserver() - o.onConnecting(client(url)) - o.onCannotConnect(client(url), "timeout") - o.onConnecting(client(url)) + fun `a reconnect clears the previous attempt's error`() = + kotlinx.coroutines.test.runTest { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onCannotConnect(client(url), "timeout") + o.onConnecting(client(url)) - assertNull(o.only().error, "a stale error would report a live relay as broken forever") - } + assertNull(o.only().error, "a stale error would report a live relay as broken forever") + } // ---- AUTH, which is why an anonymous crawl finds a relay empty ------------ @Test - fun `a demand for AUTH is recorded from either shape`() { - val challenged = RelayObserver() - challenged.onIncomingMessage(client(url), "", AuthMessage("challenge")) - assertTrue(challenged.only().authRequired) + fun `a demand for AUTH is recorded from either shape`() = + kotlinx.coroutines.test.runTest { + val challenged = RelayObserver() + challenged.onIncomingMessage(client(url), "", AuthMessage("challenge")) + assertTrue(challenged.only().authRequired) - val closed = RelayObserver() - closed.onIncomingMessage(client(url), "", ClosedMessage("sub", "auth-required: subscribers only")) - val obs = closed.only() - assertTrue(obs.authRequired) - assertEquals("auth-required", obs.closedReason) - } + val closed = RelayObserver() + closed.onIncomingMessage(client(url), "", ClosedMessage("sub", "auth-required: subscribers only")) + val obs = closed.only() + assertTrue(obs.authRequired) + assertEquals("auth-required", obs.closedReason) + } @Test - fun `a CLOSED that is not about auth is categorised rather than misread`() { - val o = RelayObserver() - o.onIncomingMessage(client(url), "", ClosedMessage("sub", "rate-limited: slow down")) + fun `a CLOSED that is not about auth is categorised rather than misread`() = + kotlinx.coroutines.test.runTest { + val o = RelayObserver() + o.onIncomingMessage(client(url), "", ClosedMessage("sub", "rate-limited: slow down")) - val obs = o.only() - assertEquals("rate-limited", obs.closedReason) - assertFalse(obs.authRequired, "only an auth refusal means auth is required") - } + val obs = o.only() + assertEquals("rate-limited", obs.closedReason) + assertFalse(obs.authRequired, "only an auth refusal means auth is required") + } // ---- publishing bookkeeping --------------------------------------------- @Test - fun `an unchanged relay is not re-reported but its measurement survives`() { - // Re-writing a record refreshes its freshness window, so a relay nobody - // re-measured must be left out. But the measurement itself has to stay: - // a long-lived socket fires onConnected once, and if publishing erased - // it, the relays we know best would be the ones we could never describe - // again. - val o = RelayObserver() - o.onConnecting(client(url)) - o.onConnected(client(url), 1, true) + fun `an unchanged relay is not re-reported but its measurement survives`() = + kotlinx.coroutines.test.runTest { + // Re-writing a record refreshes its freshness window, so a relay nobody + // re-measured must be left out. But the measurement itself has to stay: + // a long-lived socket fires onConnected once, and if publishing erased + // it, the relays we know best would be the ones we could never describe + // again. + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) - val first = o.collectUnreported().single() - assertNotNull(first.rttOpenMs) - assertEquals(0, o.collectUnreported().size, "nothing new to say") + val first = o.collectUnreported().single() + assertNotNull(first.rttOpenMs) + assertEquals(0, o.collectUnreported().size, "nothing new to say") - o.onIncomingMessage(client(url), "", NoticeMessage("slow down")) - val second = o.collectUnreported().single() - assertEquals(first.rttOpenMs, second.rttOpenMs, "the last real measurement still stands") - } + o.onIncomingMessage(client(url), "", NoticeMessage("slow down")) + val second = o.collectUnreported().single() + assertEquals(first.rttOpenMs, second.rttOpenMs, "the last real measurement still stands") + } // ---- findings from outside the websocket client ------------------------ @Test - fun `a probe failure is published even though nothing was dialled`() { - // The cheap checks that decide NOT to open a websocket are exactly the - // ones that learn a relay is gone. Without a way in, a listener-only - // observer reports on the small minority it happened to connect to — - // 104 records out of a 16,507-relay list — which is not a census. - val o = RelayObserver() - o.record(url, reachable = false, error = "nodename nor servname provided") + fun `a probe failure is published even though nothing was dialled`() = + kotlinx.coroutines.test.runTest { + // The cheap checks that decide NOT to open a websocket are exactly the + // ones that learn a relay is gone. Without a way in, a listener-only + // observer reports on the small minority it happened to connect to — + // 104 records out of a 16,507-relay list — which is not a census. + val o = RelayObserver() + o.record(url, reachable = false, error = "nodename nor servname provided") - val obs = o.only() - assertFalse(obs.reachable) - assertEquals("nodename nor servname provided", obs.error) - assertNull(obs.rttOpenMs, "a failed probe times nothing") - } + val obs = o.only() + assertFalse(obs.reachable) + assertEquals("nodename nor servname provided", obs.error) + assertNull(obs.rttOpenMs, "a failed probe times nothing") + } @Test - fun `a probe that connected reports its measured time or none at all`() { - val timed = RelayObserver() - timed.record(url, reachable = true, rttOpenMs = 42) - assertEquals(42L, timed.only().rttOpenMs) + fun `a probe that connected reports its measured time or none at all`() = + kotlinx.coroutines.test.runTest { + val timed = RelayObserver() + timed.record(url, reachable = true, rttOpenMs = 42) + assertEquals(42L, timed.only().rttOpenMs) - val untimed = RelayObserver() - untimed.record(url, reachable = true) - val obs = untimed.only() - assertTrue(obs.reachable) - assertNull(obs.rttOpenMs, "reachable without a timing must not invent one") - } + val untimed = RelayObserver() + untimed.record(url, reachable = true) + val obs = untimed.only() + assertTrue(obs.reachable) + assertNull(obs.rttOpenMs, "reachable without a timing must not invent one") + } @Test - fun `a failed probe does not demote a relay that already answered`() { - // Same rule the connection path follows: one bad probe is not death, and - // only the writer decides what record a mixed history becomes. - val o = RelayObserver() - o.onConnecting(client(url)) - o.onConnected(client(url), 1, true) - o.record(url, reachable = false, error = "connect timeout") + fun `a failed probe does not demote a relay that already answered`() = + kotlinx.coroutines.test.runTest { + // Same rule the connection path follows: one bad probe is not death, and + // only the writer decides what record a mixed history becomes. + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + o.record(url, reachable = false, error = "connect timeout") - assertTrue(o.only().reachable, "it answered; a later probe failure does not erase that") - } + assertTrue(o.only().reachable, "it answered; a later probe failure does not erase that") + } @Test - fun `an out-of-band finding is reported once like any other`() { - val o = RelayObserver() - o.record(url, reachable = false, error = "refused") - assertEquals(1, o.collectUnreported().size) - assertEquals(0, o.collectUnreported().size, "nothing new to say") - } + fun `an out-of-band finding is reported once like any other`() = + kotlinx.coroutines.test.runTest { + val o = RelayObserver() + o.record(url, reachable = false, error = "refused") + assertEquals(1, o.collectUnreported().size) + assertEquals(0, o.collectUnreported().size, "nothing new to say") + } @Test - fun `each relay is observed on its own`() { - val o = RelayObserver() - o.onConnecting(client(url)) - o.onConnected(client(url), 1, true) - o.onConnecting(client(other)) - o.onCannotConnect(client(other), "nodename nor servname provided") + fun `each relay is observed on its own`() = + kotlinx.coroutines.test.runTest { + val o = RelayObserver() + o.onConnecting(client(url)) + o.onConnected(client(url), 1, true) + o.onConnecting(client(other)) + o.onCannotConnect(client(other), "nodename nor servname provided") - val byUrl = o.collectUnreported().associateBy { it.url } - assertTrue(byUrl.getValue(url).reachable) - assertFalse(byUrl.getValue(other).reachable) - } + val byUrl = o.collectUnreported().associateBy { it.url } + assertTrue(byUrl.getValue(url).reachable) + assertFalse(byUrl.getValue(other).reachable) + } // ---- the run-level summary (what RelayDiagnostics used to give) ----------- @Test - fun `the summary tallies feedback across every relay`() { - val o = RelayObserver() - assertFalse(o.hadFeedback()) + fun `the summary tallies feedback across every relay`() = + kotlinx.coroutines.test.runTest { + val o = RelayObserver() + assertFalse(o.hadFeedback()) - o.onIncomingMessage(client(url), "", AuthMessage("c1")) - o.onIncomingMessage(client(other), "", AuthMessage("c2")) - o.onIncomingMessage(client(url), "", ClosedMessage("s", "rate-limited: slow")) - o.onIncomingMessage(client(other), "", ClosedMessage("s", "rate-limited: slow")) - o.onIncomingMessage(client(url), "", NoticeMessage("too many REQs")) + o.onIncomingMessage(client(url), "", AuthMessage("c1")) + o.onIncomingMessage(client(other), "", AuthMessage("c2")) + o.onIncomingMessage(client(url), "", ClosedMessage("s", "rate-limited: slow")) + o.onIncomingMessage(client(other), "", ClosedMessage("s", "rate-limited: slow")) + o.onIncomingMessage(client(url), "", NoticeMessage("too many REQs")) - assertTrue(o.hadFeedback()) - val s = o.summary() - assertEquals(2L, s["auth_challenges"]) - assertEquals(2, s["auth_required_relays"]) - assertEquals(mapOf("rate-limited" to 2L), s["closed_by_reason"]) - assertEquals(1L, s["notices"]) - } + assertTrue(o.hadFeedback()) + val s = o.summary() + assertEquals(2L, s["auth_challenges"]) + assertEquals(2, s["auth_required_relays"]) + assertEquals(mapOf("rate-limited" to 2L), s["closed_by_reason"]) + assertEquals(1L, s["notices"]) + } @Test - fun `the summary outlives publishing`() { - // It answers "how did this run go", which must not be reset by the - // unrelated act of writing records out. - val o = RelayObserver() - o.onIncomingMessage(client(url), "", AuthMessage("c")) - o.collectUnreported() + fun `the summary outlives publishing`() = + kotlinx.coroutines.test.runTest { + // It answers "how did this run go", which must not be reset by the + // unrelated act of writing records out. + val o = RelayObserver() + o.onIncomingMessage(client(url), "", AuthMessage("c")) + o.collectUnreported() - assertTrue(o.hadFeedback(), "a flush must not erase the run's tally") - assertEquals(1L, o.summary()["auth_challenges"]) - } + assertTrue(o.hadFeedback(), "a flush must not erase the run's tally") + assertEquals(1L, o.summary()["auth_challenges"]) + } @Test - fun `a machine-readable prefix is extracted or falls back to other`() { - assertEquals("auth-required", RelayObserver.prefixOf("auth-required: come back signed")) - assertEquals("other", RelayObserver.prefixOf("just some prose")) - assertEquals("other", RelayObserver.prefixOf("")) - } + fun `a machine-readable prefix is extracted or falls back to other`() = + kotlinx.coroutines.test.runTest { + assertEquals("auth-required", RelayObserver.prefixOf("auth-required: come back signed")) + assertEquals("other", RelayObserver.prefixOf("just some prose")) + assertEquals("other", RelayObserver.prefixOf("")) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt index 8593489ab2..1cfdfcea00 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt @@ -85,7 +85,7 @@ class RelayProberFlowTest { } /** Plays a relay's OK answer for the published event to every armed listener. */ - fun answerOk( + suspend fun answerOk( relay: NormalizedRelayUrl, success: Boolean, message: String, @@ -235,13 +235,14 @@ class RelayProberFlowTest { } @Test - fun writeTestEventIsEphemeralAndSelfExpiring() { - val template = RelayProbeWriteTest.build(createdAt = 5000) + fun writeTestEventIsEphemeralAndSelfExpiring() = + kotlinx.coroutines.test.runTest { + val template = RelayProbeWriteTest.build(createdAt = 5000) - assertEquals(20166, template.kind) - assertTrue(template.kind in 20000..29999, "the write probe must be an ephemeral kind") - assertTrue(listOf("expiration", "5060") in template.tags.map { it.toList() }) - } + assertEquals(20166, template.kind) + assertTrue(template.kind in 20000..29999, "the write probe must be an ephemeral kind") + assertTrue(listOf("expiration", "5060") in template.tags.map { it.toList() }) + } // ------------------------------------------------------------------ // readWriteCheck — honest read + write measurements, nothing claimed @@ -358,108 +359,117 @@ class RelayProberFlowTest { private fun tagsOf(template: EventTemplate<*>) = template.tags.map { it.toList() } @Test - fun reachableVerdictTemplateCarriesLivenessAndNetwork() { - val template = - RelayProber - .Verdict(fast, reachable = true, rttOpenMs = 150, rttEoseMs = 480, error = null) - .toDiscoveryEventTemplate(createdAt = 1000) + fun reachableVerdictTemplateCarriesLivenessAndNetwork() = + kotlinx.coroutines.test.runTest { + val template = + RelayProber + .Verdict(fast, reachable = true, rttOpenMs = 150, rttEoseMs = 480, error = null) + .toDiscoveryEventTemplate(createdAt = 1000) - val tags = tagsOf(template) - assertEquals(30166, template.kind) - assertEquals(1000, template.createdAt) - assertTrue(listOf("d", fast.url) in tags) - assertTrue(listOf("n", "clearnet") in tags) - assertTrue(listOf("rtt-open", "150") in tags) - // rtt-eose is wave-relative (dial + queue + read) — never published as rtt-read. - assertNull(tags.firstOrNull { it[0] == "rtt-read" }) - } + val tags = tagsOf(template) + assertEquals(30166, template.kind) + assertEquals(1000, template.createdAt) + assertTrue(listOf("d", fast.url) in tags) + assertTrue(listOf("n", "clearnet") in tags) + assertTrue(listOf("rtt-open", "150") in tags) + // rtt-eose is wave-relative (dial + queue + read) — never published as rtt-read. + assertNull(tags.firstOrNull { it[0] == "rtt-read" }) + } @Test - fun deadVerdictTemplateHasNoRttOpen() { - val template = - RelayProber - .Verdict(silent, reachable = false, rttOpenMs = -1, rttEoseMs = -1, error = "cannot:timeout") - .toDiscoveryEventTemplate() + fun deadVerdictTemplateHasNoRttOpen() = + kotlinx.coroutines.test.runTest { + val template = + RelayProber + .Verdict(silent, reachable = false, rttOpenMs = -1, rttEoseMs = -1, error = "cannot:timeout") + .toDiscoveryEventTemplate() - val tags = tagsOf(template) - assertTrue(listOf("d", silent.url) in tags) - // Liveness is the PRESENCE of rtt-open; a dead record must not carry one. - assertNull(tags.firstOrNull { it[0] == "rtt-open" }) - } + val tags = tagsOf(template) + assertTrue(listOf("d", silent.url) in tags) + // Liveness is the PRESENCE of rtt-open; a dead record must not carry one. + assertNull(tags.firstOrNull { it[0] == "rtt-open" }) + } @Test - fun reachableWithoutMeasuredLatencyWritesZeroFlag() { - val template = - RelayProber - .Verdict(fast, reachable = true, rttOpenMs = -1, rttEoseMs = 300, error = null) - .toDiscoveryEventTemplate() + fun reachableWithoutMeasuredLatencyWritesZeroFlag() = + kotlinx.coroutines.test.runTest { + val template = + RelayProber + .Verdict(fast, reachable = true, rttOpenMs = -1, rttEoseMs = 300, error = null) + .toDiscoveryEventTemplate() - // 0 = "reachable, latency not observed": the flag form, never an invented number. - assertTrue(listOf("rtt-open", "0") in tagsOf(template)) - } + // 0 = "reachable, latency not observed": the flag form, never an invented number. + assertTrue(listOf("rtt-open", "0") in tagsOf(template)) + } @Test - fun observedAuthWallBecomesARequirementTag() { - val template = - RelayProber - .Verdict(walled, reachable = true, rttOpenMs = 90, rttEoseMs = -1, error = "closed:auth-required: sign in") - .toDiscoveryEventTemplate() + fun observedAuthWallBecomesARequirementTag() = + kotlinx.coroutines.test.runTest { + val template = + RelayProber + .Verdict(walled, reachable = true, rttOpenMs = 90, rttEoseMs = -1, error = "closed:auth-required: sign in") + .toDiscoveryEventTemplate() - assertTrue(listOf("R", "auth") in tagsOf(template)) - } + assertTrue(listOf("R", "auth") in tagsOf(template)) + } @Test - fun policyClosedIsNotAnAuthRequirement() { - val template = - RelayProber - .Verdict(walled, reachable = true, rttOpenMs = 90, rttEoseMs = -1, error = "closed:blocked: not welcome") - .toDiscoveryEventTemplate() + fun policyClosedIsNotAnAuthRequirement() = + kotlinx.coroutines.test.runTest { + val template = + RelayProber + .Verdict(walled, reachable = true, rttOpenMs = 90, rttEoseMs = -1, error = "closed:blocked: not welcome") + .toDiscoveryEventTemplate() - assertNull(tagsOf(template).firstOrNull { it[0] == "R" }) - } + assertNull(tagsOf(template).firstOrNull { it[0] == "R" }) + } @Test - fun readWriteResultsBecomeRttTags() { - val verdict = RelayProber.Verdict(fast, reachable = true, rttOpenMs = 100, rttEoseMs = 300, error = null) - val readWrite = RelayProber.ReadWriteVerdict(fast, rttReadMs = 40, rttWriteMs = 55, writeAccepted = true, writeMessage = "") + fun readWriteResultsBecomeRttTags() = + kotlinx.coroutines.test.runTest { + val verdict = RelayProber.Verdict(fast, reachable = true, rttOpenMs = 100, rttEoseMs = 300, error = null) + val readWrite = RelayProber.ReadWriteVerdict(fast, rttReadMs = 40, rttWriteMs = 55, writeAccepted = true, writeMessage = "") - val tags = tagsOf(verdict.toDiscoveryEventTemplate(readWrite = readWrite)) - assertTrue(listOf("rtt-read", "40") in tags) - assertTrue(listOf("rtt-write", "55") in tags) - } + val tags = tagsOf(verdict.toDiscoveryEventTemplate(readWrite = readWrite)) + assertTrue(listOf("rtt-read", "40") in tags) + assertTrue(listOf("rtt-write", "55") in tags) + } @Test - fun unobservedReadWriteSidesStayUntagged() { - val verdict = RelayProber.Verdict(fast, reachable = true, rttOpenMs = 100, rttEoseMs = -1, error = null) - val readWrite = RelayProber.ReadWriteVerdict(fast, rttReadMs = -1, rttWriteMs = -1, writeAccepted = null, writeMessage = null) + fun unobservedReadWriteSidesStayUntagged() = + kotlinx.coroutines.test.runTest { + val verdict = RelayProber.Verdict(fast, reachable = true, rttOpenMs = 100, rttEoseMs = -1, error = null) + val readWrite = RelayProber.ReadWriteVerdict(fast, rttReadMs = -1, rttWriteMs = -1, writeAccepted = null, writeMessage = null) - val tags = tagsOf(verdict.toDiscoveryEventTemplate(readWrite = readWrite)) - assertNull(tags.firstOrNull { it[0] == "rtt-read" }) - assertNull(tags.firstOrNull { it[0] == "rtt-write" }) - } + val tags = tagsOf(verdict.toDiscoveryEventTemplate(readWrite = readWrite)) + assertNull(tags.firstOrNull { it[0] == "rtt-read" }) + assertNull(tags.firstOrNull { it[0] == "rtt-write" }) + } @Test - fun writeRejectionReasonsBecomeRequirementTags() { - val verdict = RelayProber.Verdict(walled, reachable = true, rttOpenMs = 100, rttEoseMs = -1, error = null) + fun writeRejectionReasonsBecomeRequirementTags() = + kotlinx.coroutines.test.runTest { + val verdict = RelayProber.Verdict(walled, reachable = true, rttOpenMs = 100, rttEoseMs = -1, error = null) - val pow = RelayProber.ReadWriteVerdict(walled, -1, 30, writeAccepted = false, writeMessage = "pow: 28 bits needed") - assertTrue(listOf("R", "pow") in tagsOf(verdict.toDiscoveryEventTemplate(readWrite = pow))) + val pow = RelayProber.ReadWriteVerdict(walled, -1, 30, writeAccepted = false, writeMessage = "pow: 28 bits needed") + assertTrue(listOf("R", "pow") in tagsOf(verdict.toDiscoveryEventTemplate(readWrite = pow))) - val auth = RelayProber.ReadWriteVerdict(walled, -1, 30, writeAccepted = false, writeMessage = "auth-required: sign in") - assertTrue(listOf("R", "auth") in tagsOf(verdict.toDiscoveryEventTemplate(readWrite = auth))) + val auth = RelayProber.ReadWriteVerdict(walled, -1, 30, writeAccepted = false, writeMessage = "auth-required: sign in") + assertTrue(listOf("R", "auth") in tagsOf(verdict.toDiscoveryEventTemplate(readWrite = auth))) - val blocked = RelayProber.ReadWriteVerdict(walled, -1, 30, writeAccepted = false, writeMessage = "blocked: not welcome") - assertNull(tagsOf(verdict.toDiscoveryEventTemplate(readWrite = blocked)).firstOrNull { it[0] == "R" }) - } + val blocked = RelayProber.ReadWriteVerdict(walled, -1, 30, writeAccepted = false, writeMessage = "blocked: not welcome") + assertNull(tagsOf(verdict.toDiscoveryEventTemplate(readWrite = blocked)).firstOrNull { it[0] == "R" }) + } @Test - fun onionRelayIsTaggedTor() { - val onion = RelayUrlNormalizer.normalize("ws://someonionaddressabcdefghijklmnop.onion") - val template = - RelayProber - .Verdict(onion, reachable = true, rttOpenMs = 900, rttEoseMs = -1, error = null) - .toDiscoveryEventTemplate() + fun onionRelayIsTaggedTor() = + kotlinx.coroutines.test.runTest { + val onion = RelayUrlNormalizer.normalize("ws://someonionaddressabcdefghijklmnop.onion") + val template = + RelayProber + .Verdict(onion, reachable = true, rttOpenMs = 900, rttEoseMs = -1, error = null) + .toDiscoveryEventTemplate() - assertTrue(listOf("n", "tor") in tagsOf(template)) - } + assertTrue(listOf("n", "tor") in tagsOf(template)) + } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index 06354f98d2..50dfac2519 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -47,7 +47,7 @@ class NostrClientManualSubTest : RelayClientTest() { val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index 0c283d4314..7531b70068 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -71,7 +71,7 @@ class NostrClientRepeatSubTest : RelayClientTest() { val listener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/PoolRequestsConcurrencyTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/PoolRequestsConcurrencyTest.kt index 6d21d526e3..4add1aea15 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/PoolRequestsConcurrencyTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/PoolRequestsConcurrencyTest.kt @@ -78,69 +78,70 @@ class PoolRequestsConcurrencyTest { } @Test - fun concurrentEoseResendAndSubscribeSendExactlyOneReq() { - val url = RelayUrlNormalizer.normalize("ws://race/") - val subId = "shared-sub" - val filtersA = listOf(Filter(kinds = listOf(1))) - val filtersB = listOf(Filter(kinds = listOf(2))) - val listener = object : SubscriptionListener {} + fun concurrentEoseResendAndSubscribeSendExactlyOneReq() = + kotlinx.coroutines.test.runTest { + val url = RelayUrlNormalizer.normalize("ws://race/") + val subId = "shared-sub" + val filtersA = listOf(Filter(kinds = listOf(1))) + val filtersB = listOf(Filter(kinds = listOf(2))) + val listener = object : SubscriptionListener {} - // Many episodes so a regression that only sometimes doubles still trips. - repeat(300) { episode -> - val pool = PoolRequests() - val reqBCount = AtomicInteger(0) + // Many episodes so a regression that only sometimes doubles still trips. + repeat(300) { episode -> + val pool = PoolRequests() + val reqBCount = AtomicInteger(0) - fun countReqB(cmd: Command) { - if (cmd is ReqCmd && cmd.filters == filtersB) reqBCount.incrementAndGet() - } - - val fakeRelay = - FakeRelay(url) { cmd -> - // relay-reader auto-resend send path - countReqB(cmd) - pool.onSent(url, cmd) + fun countReqB(cmd: Command) { + if (cmd is ReqCmd && cmd.filters == filtersB) reqBCount.incrementAndGet() } - // Bring the sub to LIVE with filters A. - val setupRelays = pool.addOrUpdate(subId, mapOf(url to filtersA), listener) - pool.sendToRelayIfChanged(subId, setupRelays) { _, cmd -> pool.onSent(url, cmd) } - pool.onIncomingMessage(fakeRelay, EoseMessage(subId)) - - // The desired filters change to B (e.g. the next page of a paged download). - pool.addOrUpdate(subId, mapOf(url to filtersB), listener) - - val appProducedReq = CountDownLatch(1) - val readerDone = CountDownLatch(1) - - val appThread = - thread { - pool.sendToRelayIfChanged(subId, setOf(url)) { _, cmd -> + val fakeRelay = + FakeRelay(url) { cmd -> + // relay-reader auto-resend send path countReqB(cmd) - // App has produced its REQ(B); park before onSent so the - // subscription state is not yet advanced — the exact window - // the race needs. - appProducedReq.countDown() - readerDone.await() pool.onSent(url, cmd) } - } - val readerThread = - thread { - appProducedReq.await() - pool.onIncomingMessage(fakeRelay, EoseMessage(subId)) - readerDone.countDown() - } + // Bring the sub to LIVE with filters A. + val setupRelays = pool.addOrUpdate(subId, mapOf(url to filtersA), listener) + pool.sendToRelayIfChanged(subId, setupRelays) { _, cmd -> pool.onSent(url, cmd) } + pool.onIncomingMessage(fakeRelay, EoseMessage(subId)) - appThread.join() - readerThread.join() + // The desired filters change to B (e.g. the next page of a paged download). + pool.addOrUpdate(subId, mapOf(url to filtersB), listener) - assertEquals( - 1, - reqBCount.get(), - "episode $episode: exactly one REQ must be sent for the changed filters, " + - "never a duplicate from the app + reader race", - ) + val appProducedReq = CountDownLatch(1) + val readerDone = CountDownLatch(1) + + val appThread = + thread { + pool.sendToRelayIfChanged(subId, setOf(url)) { _, cmd -> + countReqB(cmd) + // App has produced its REQ(B); park before onSent so the + // subscription state is not yet advanced — the exact window + // the race needs. + appProducedReq.countDown() + readerDone.await() + pool.onSent(url, cmd) + } + } + + val readerThread = + thread { + appProducedReq.await() + kotlinx.coroutines.runBlocking { pool.onIncomingMessage(fakeRelay, EoseMessage(subId)) } + readerDone.countDown() + } + + appThread.join() + readerThread.join() + + assertEquals( + 1, + reqBCount.get(), + "episode $episode: exactly one REQ must be sent for the changed filters, " + + "never a duplicate from the app + reader race", + ) + } } - } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorReauthOnClosedTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorReauthOnClosedTest.kt index e10a5a7c32..e8ae39933b 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorReauthOnClosedTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorReauthOnClosedTest.kt @@ -100,7 +100,7 @@ class RelayAuthenticatorReauthOnClosedTest { .filterIsInstance() .last() .event - listener.onIncomingMessage(relay, "", OkMessage.accepted(newest.id)) + kotlinx.coroutines.runBlocking { listener.onIncomingMessage(relay, "", OkMessage.accepted(newest.id)) } } @Test diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NegentropyRejectionFallbackTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NegentropyRejectionFallbackTest.kt index 230674d4d0..006b8ffe16 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NegentropyRejectionFallbackTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NegentropyRejectionFallbackTest.kt @@ -91,11 +91,11 @@ class NegentropyRejectionFallbackTest { when { // The keep-alive REQ and any paging REQ: answer EOSE so the // subscription settles (paging then completes with 0 events). - msg.startsWith("[\"REQ\"") -> subIdOf(msg)?.let { out.onMessage("[\"EOSE\",\"$it\"]") } + msg.startsWith("[\"REQ\"") -> subIdOf(msg)?.let { kotlinx.coroutines.runBlocking { out.onMessage("[\"EOSE\",\"$it\"]") } } // The negentropy handshake: the relay refuses. msg.startsWith("[\"NEG-OPEN\"") -> { negOpens.incrementAndGet() - subIdOf(msg)?.let { out.onMessage(replyToNegOpen(it)) } + subIdOf(msg)?.let { kotlinx.coroutines.runBlocking { out.onMessage(replyToNegOpen(it)) } } } else -> Unit } @@ -140,25 +140,28 @@ class NegentropyRejectionFallbackTest { } @Test - fun strfryNegentropyDisabledFallsBackToPaging() { - negOpenRejectedBy { "[\"NOTICE\",\"ERROR: bad msg: negentropy disabled\"]" } - } + fun strfryNegentropyDisabledFallsBackToPaging() = + kotlinx.coroutines.test.runTest { + negOpenRejectedBy { "[\"NOTICE\",\"ERROR: bad msg: negentropy disabled\"]" } + } @Test - fun purplePagesUnknownEnvelopeFallsBackToPaging() { - negOpenRejectedBy { "[\"NOTICE\",\"failed to parse envelope: unknown envelope label\"]" } - } + fun purplePagesUnknownEnvelopeFallsBackToPaging() = + kotlinx.coroutines.test.runTest { + negOpenRejectedBy { "[\"NOTICE\",\"failed to parse envelope: unknown envelope label\"]" } + } @Test - fun rateLimitNegErrPagesWithoutSplitStorm() { - // A NEG-ERR that does NOT shrink with the window ("too many requests") must not - // be mistaken for a set-too-large overflow: doing so would binary-split the - // created_at range forever. Assert we page after exactly ONE NEG-OPEN. - val relay = negOpenRejectedBy { subId -> "[\"NEG-ERR\",\"$subId\",\"rate-limited: too many requests\"]" } - assertEquals( - 2, - relay.negOpens.get(), - "one NEG-OPEN per phase (sync + syncOrFetch), i.e. no window-split storm; got ${relay.negOpens.get()}", - ) - } + fun rateLimitNegErrPagesWithoutSplitStorm() = + kotlinx.coroutines.test.runTest { + // A NEG-ERR that does NOT shrink with the window ("too many requests") must not + // be mistaken for a set-too-large overflow: doing so would binary-split the + // created_at range forever. Assert we page after exactly ONE NEG-OPEN. + val relay = negOpenRejectedBy { subId -> "[\"NEG-ERR\",\"$subId\",\"rate-limited: too many requests\"]" } + assertEquals( + 2, + relay.negOpens.get(), + "one NEG-OPEN per phase (sync + syncOrFetch), i.e. no window-split storm; got ${relay.negOpens.get()}", + ) + } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ByIdFetchBenchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ByIdFetchBenchmark.kt index 6dd57131e0..9775a20308 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ByIdFetchBenchmark.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ByIdFetchBenchmark.kt @@ -136,7 +136,7 @@ class ByIdFetchBenchmark { val done = Channel(Channel.CONFLATED) val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/DispatchStageBenchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/DispatchStageBenchmark.kt index 8a64c4a0e5..ed0909257e 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/DispatchStageBenchmark.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/DispatchStageBenchmark.kt @@ -197,7 +197,7 @@ class DispatchStageBenchmark { continue } for (subId in subIds) { - client.onIncomingMessage(relayClient, "", EventMessage(subId, event)) + kotlinx.coroutines.runBlocking { client.onIncomingMessage(relayClient, "", EventMessage(subId, event)) } } } } @@ -259,7 +259,7 @@ class DispatchStageBenchmark { Thread { for (event in events) { for (subId in subIds) { - pool.onIncomingMessage(relayClient, EventMessage(subId, event)) + kotlinx.coroutines.runBlocking { pool.onIncomingMessage(relayClient, EventMessage(subId, event)) } } } } @@ -299,12 +299,13 @@ class DispatchStageBenchmark { } @Test - fun dispatchStageBenchmark() { - println("=== DISPATCH STAGE BENCHMARK (post-parse, pre-verify) ===") - println("cores=${Runtime.getRuntime().availableProcessors()} uniqueEvents=$UNIQUE_EVENTS subsPerRelay=$SUBS_PER_RELAY") + fun dispatchStageBenchmark() = + kotlinx.coroutines.test.runTest { + println("=== DISPATCH STAGE BENCHMARK (post-parse, pre-verify) ===") + println("cores=${Runtime.getRuntime().availableProcessors()} uniqueEvents=$UNIQUE_EVENTS subsPerRelay=$SUBS_PER_RELAY") - // warmup pass (JIT), then the measured pass - runAllVariants(print = false) - runAllVariants(print = true) - } + // warmup pass (JIT), then the measured pass + runAllVariants(print = false) + runAllVariants(print = true) + } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyMultiRelayLiveTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyMultiRelayLiveTest.kt index 1c72b53690..8d95420c6b 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyMultiRelayLiveTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyMultiRelayLiveTest.kt @@ -177,7 +177,7 @@ class NegentropyMultiRelayLiveTest { val listener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyStallRepro.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyStallRepro.kt index 04d6338582..c85e4fb80a 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyStallRepro.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyStallRepro.kt @@ -109,7 +109,7 @@ class NegentropyStallRepro { out.onOpen(pingMillis, usingCompression) } - override fun onMessage(text: String) { + override suspend fun onMessage(text: String) { val t = tagger(text) recvCounts.getOrPut(t) { AtomicInteger() }.incrementAndGet() if (t == "NEG-MSG") negMsgBytesIn.addAndGet(text.length.toLong()) diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ProductionReceiverBenchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ProductionReceiverBenchmark.kt index b226af0641..eca181e106 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ProductionReceiverBenchmark.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/ProductionReceiverBenchmark.kt @@ -370,7 +370,7 @@ class ProductionReceiverBenchmark { reqSentAt.putIfAbsent(relay, System.nanoTime() - startNanos) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, From bb95cad98b96591e45c249e56db1d1a4c05a78b7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 5 Aug 2026 17:09:45 +0000 Subject: [PATCH 127/227] Audit fixes: one clock per record, no shared mutable list, pin the file format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-audit pass over the branch. Nothing here changes what a band claims; these are the defects that pass tests and bite later. - record() read the clock twice PER KIND. A 40-kind map took 80 readings, and worse, a span's floor and ceiling were judged against two different instants — so a span could be accepted at one end and rejected at the other on a clock tick. One read, one instant, for the whole call. The aggregate path had the same double read and now shares it. - legs() handed the SAME MutableList instance to every Filter in a group, publishing its accumulator through a public return value. Filters are treated as immutable everywhere else; this keeps that true by construction rather than by nobody having tried yet. - The state file's round trip was asserted only for the fields, never for the behaviour. Three tests now pin it: per-kind spans survive a restart AND still narrow per kind afterwards; the ALL_KINDS sentinel survives its negative key through toString/toInt; and a pre-split file (min/max, no spans) loads as the claim it always was. Plus the rollback contract — `min`/`max` must remain the OUTER edges, since a binary from before per-kind spans reads those and would otherwise skip ground it has not covered. Checked and found sound, recorded so the next reader need not re-derive it: ConcurrentMap.snapshot() copies, so export() cannot be mutated under a writer; Band is immutable (widen() copies its map), so a shared Band across threads is safe; merge() keeps old.fullAt, preserving the re-walk clock across widening; and coveringWindow does NOT regress — a paged band gave >1 leg before this change too, and a reconciled band still collapses to one leg and narrows the shared snapshot. Co-Authored-By: Claude Opus 5 --- .../geode/mirror/SyncCoverageFileTest.kt | 132 ++++++++++++++++++ .../relay/client/accessories/SyncCoverage.kt | 15 +- 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFileTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFileTest.kt index 4a02507db8..5b6f0387d9 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFileTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFileTest.kt @@ -20,8 +20,17 @@ */ package com.vitorpamplona.geode.mirror +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.SyncCoverage import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put import java.io.File import kotlin.test.Test import kotlin.test.assertEquals @@ -127,4 +136,127 @@ class SyncCoverageFileTest { assertEquals(1_500L, clamped.since, "the band's ceiling wins over the window floor") assertEquals(2_000L, clamped.until) } + + @Test + fun `per-kind spans survive a restart, including the kindless sentinel`() { + // The file is the one place a per-kind band can be silently flattened + // back into the single interval it replaced, so the round trip is + // pinned rather than assumed — negative sentinel key included, since + // ALL_KINDS goes through toString()/toInt() like any other kind. + val mixed = Filter(kinds = listOf(0, 30382)) + val anyKind = Filter(authors = listOf("a".repeat(64))) + val f = tempFile() + SyncCoverageFile(f).use { + it.coverage.record( + relay, + mixed, + null, + null, + paged = true, + observedByKind = + mapOf( + 0 to SyncCoverage.Span(1_600_000_000L, 1_700_000_000L), + 30382 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L), + ), + ) + it.coverage.record( + relay, + anyKind, + null, + null, + paged = true, + observedByKind = mapOf(1 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)), + ) + } + + SyncCoverageFile(f).use { reopened -> + val band = reopened.coverage.band(relay, mixed)!! + assertEquals( + mapOf( + 0 to SyncCoverage.Span(1_600_000_000L, 1_700_000_000L), + 30382 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L), + ), + band.spans, + "each kind keeps its own evidence across the restart", + ) + // …and the restored band still narrows per kind, which is the half + // that would go unnoticed if only the fields round-tripped. + val legs = reopened.coverage.legs(relay, mixed) + assertEquals(4, legs.size, "the two kinds want different windows") + + assertEquals( + mapOf(SyncCoverage.ALL_KINDS to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)), + reopened.coverage.band(relay, anyKind)!!.spans, + "the kindless sentinel survives its negative key", + ) + } + } + + @Test + fun `a file written before per-kind spans loads as the claim it always was`() { + // Only min/max, no `spans` — what every deployed state file holds today. + // Discarding it would re-download each upstream's corpus once on + // upgrade, so it loads under ALL_KINDS and narrows every kind exactly + // as it did before, until the first per-kind walk replaces it. + val mixed = Filter(kinds = listOf(0, 30382)) + val f = tempFile() + val key = "${relay.url} ${mixed.toJson()}" + f.writeText( + Json.encodeToString( + JsonObject.serializer(), + buildJsonObject { + put( + key, + buildJsonObject { + put("min", 1_690_000_000L) + put("max", 1_700_000_000L) + put("complete", false) + put("fullAt", TimeUtils.now()) + }, + ) + }, + ), + ) + + SyncCoverageFile(f).use { reopened -> + val band = reopened.coverage.band(relay, mixed)!! + assertEquals(mapOf(SyncCoverage.ALL_KINDS to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)), band.spans) + val legs = reopened.coverage.legs(relay, mixed) + assertEquals(2, legs.size, "one shared pair of legs — the old behaviour, exactly") + assertEquals(listOf(0, 30382), legs[0].kinds) + } + } + + @Test + fun `a rolled-back reader still finds the outer edges it understands`() { + // A binary from before per-kind spans reads `min`/`max` and ignores + // `spans`. Those fields must therefore still be written, and must be + // the OUTER edges — anything narrower would make the old reader skip + // ground it has not covered. + val mixed = Filter(kinds = listOf(0, 30382)) + val f = tempFile() + SyncCoverageFile(f).use { + it.coverage.record( + relay, + mixed, + null, + null, + paged = true, + observedByKind = + mapOf( + 0 to SyncCoverage.Span(1_600_000_000L, 1_695_000_000L), + 30382 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L), + ), + ) + } + + val written = + Json + .parseToJsonElement(f.readText()) + .jsonObject.values + .single() + .jsonObject + assertEquals(1_600_000_000L, written.getValue("min").jsonPrimitive.long, "the oldest of any kind") + assertEquals(1_700_000_000L, written.getValue("max").jsonPrimitive.long, "the newest of any kind") + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt index 74bcddbb3d..3eb54ae9fd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt @@ -171,7 +171,12 @@ class SyncCoverage( byWindows.getOrPut(windows(filter, span, band.complete, floor)) { mutableListOf() }.add(kind) } return byWindows.flatMap { (windows, group) -> - windows.map { (since, until) -> filter.copy(kinds = group, since = since, until = until) } + // toList(): `group` is the mutable accumulator above, and handing + // the same instance to every Filter in the group would publish it + // through a public return value. Filters are treated as immutable + // everywhere else; this keeps that true by construction. + val kindsForGroup = group.toList() + windows.map { (since, until) -> filter.copy(kinds = kindsForGroup, since = since, until = until) } } } @@ -244,11 +249,15 @@ class SyncCoverage( } if (!paged) return + // Read ONCE. `now` is a clock call, and this was invoking it twice per + // entry — so a 40-kind map took 80 readings, and worse, a span's floor + // and ceiling were judged against two different instants. + val at = now() if (observedByKind != null) { // Guarded per span for the same reason the aggregate is below. val plausible = observedByKind.filterValues { - isPlausible(it.min, now()) && isPlausible(it.max, now()) + isPlausible(it.min, at) && isPlausible(it.max, at) } if (plausible.isEmpty()) return val named = filter.kinds @@ -302,7 +311,7 @@ class SyncCoverage( // claim the whole timeline, and the leg outside it would ask for a // range nothing can be in, forever. if (observedMin == null || observedMax == null) return - if (!isPlausible(observedMin, now()) || !isPlausible(observedMax, now())) return + if (!isPlausible(observedMin, at) || !isPlausible(observedMax, at)) return put(url, filter, kinds.associateWith { Span(observedMin, observedMax) }, complete = false) } From 8b4220019afbde5938a56010846b8bdbf3d65b68 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 17:40:03 +0000 Subject: [PATCH 128/227] fix(ime): close the two remaining stuck-padding paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two exits still popped a composer while the IME was mid-animation — the race that strands imePadding() at keyboard height app-wide. 1. Top-bar X and Post. KeyboardAwareBackHandler only guards the back gesture; ActionTopBar wired both buttons straight to nav.popBack(), with nothing dismissing the keyboard first. Tapping either while typing reproduced the original bug exactly. The earlier fix leaned on the back arrow as the "always-available exit" without noticing it was also a race source. 2. A ~250ms hole in the back gate. It read the animated WindowInsets.ime, which stays above zero for the whole close animation — a window in which the IME had already stopped consuming back but the handler was still disabled, so a second back fell through to the NavController and popped without ever running onBack. That silently dropped the draft the handler exists to save: nothing else saves it, onCleared() only closes the writing assistant and there is no autosave. Both are the same underlying requirement — serialize the IME and window animations instead of overlapping them — so both now route through one helper, rememberAfterKeyboardCloses(): keyboard down, the action runs inline and nothing changes; keyboard up, clear focus, hide, wait for the inset to actually reach zero, then act. The wait is bounded so a stale inset (the very failure being guarded) can never trap the user on screen, and re-entrant calls are dropped since the deferral widens the window for a double-tap on Post to fire twice. The back gate now reads WindowInsets.imeAnimationTarget, which flips to zero the moment the hide begins, so back keeps reaching onBack throughout the animation. Re-enabling that early means onBack can fire mid-animation, which is exactly what the helper absorbs. Not covered: this is verified by compile and the unit suite only. The race reproduces on release builds on a device, which this environment cannot run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN --- .../ui/navigation/bottombars/KeyboardState.kt | 106 +++++++++++++++--- .../ui/navigation/topbars/ActionTopBar.kt | 9 +- 2 files changed, 98 insertions(+), 17 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt index 1457927832..f576a3556c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt @@ -21,14 +21,24 @@ package com.vitorpamplona.amethyst.ui.navigation.bottombars import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imeAnimationTarget import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.atomic.AtomicBoolean enum class KeyboardState { Opened, @@ -59,28 +69,94 @@ fun keyboardAsState(): State { } } +/** How long to wait for the IME inset to reach zero before running the action anyway. */ +private const val IME_SETTLE_TIMEOUT_MS = 700L + /** - * A [BackHandler] that steps aside while the soft keyboard is on screen. + * Returns a runner that defers an action until the soft keyboard is fully off screen. * - * Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen. - * When that pop happens while the keyboard is still up, it races the predictive-back window - * animation against the IME's close animation. On release builds — fast enough that the window - * animation wins — the IME [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] - * is cancelled before its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime` - * holder stays "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard - * height until a later inset pass rebalances it (the "stuck IME padding" that survives leaving the - * screen). + * Popping a screen while the keyboard is still up races the window animation against the IME's + * close animation. On release builds — fast enough that the window animation wins — the IME + * [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] is cancelled before + * its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime` holder stays + * "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard height until a + * later inset pass rebalances it (the "stuck IME padding" that survives leaving the screen). * - * Gating on [keyboardAsState] fixes it: while the keyboard is visible we do NOT consume back, so the - * system dismisses the keyboard first with its own animation (which completes cleanly). The next - * back — keyboard already down — runs [onBack] as before. The top bar's back arrow stays an - * always-available exit, so this can never trap the user even if the inset reading were itself stale. + * Any exit that leaves a keyboard-bearing screen has to serialize the two animations rather than + * overlap them. With the keyboard already down the action runs inline — same frame, no behavior + * change. With it up we dismiss the keyboard, wait for the inset to actually reach zero, and only + * then act, so the IME animation always completes before the window animation begins. + * + * Re-entrant calls while an action is pending are dropped: the deferral widens the window in which + * a second tap on a Post/Save button would fire the action twice. + * + * [IME_SETTLE_TIMEOUT_MS] bounds the wait — if the inset never reports zero (precisely the failure + * this guards against) the action still runs, so a stale reading can never trap the user on screen. */ @Composable +fun rememberAfterKeyboardCloses(): (() -> Unit) -> Unit { + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + val keyboard = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + val pending = remember { AtomicBoolean(false) } + + return remember(density, imeInsets, keyboard, focusManager, scope, pending) { + { action: () -> Unit -> + if (imeInsets.getBottom(density) <= 0) { + action() + } else if (pending.compareAndSet(false, true)) { + // Clear focus first so nothing re-requests the IME as it retracts. + focusManager.clearFocus(true) + keyboard?.hide() + scope.launch { + try { + withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) { + snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 } + } + action() + } finally { + pending.set(false) + } + } + } + } + } +} + +/** + * A [BackHandler] that lets the system dismiss the soft keyboard before it consumes back. + * + * Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen, + * which is the pop-during-IME-animation race described on [rememberAfterKeyboardCloses]. While the + * keyboard is up we do NOT consume back, so the system dismisses it first with its own animation + * (which completes cleanly, and on recent Android follows the back gesture). The next back runs + * [onBack] as before. + * + * The gate reads [WindowInsets.imeAnimationTarget] — where the IME is *heading* — not the animated + * [WindowInsets.ime]. Gating on the animated value left a hole: it stays above zero for the whole + * close animation, ~250ms in which the IME has already stopped consuming back but this handler was + * still disabled, so a second back fell through to the NavController and popped the screen without + * ever running [onBack] — silently dropping the draft it exists to save. The target flips to zero + * the moment the hide begins, so back keeps reaching [onBack] throughout. + * + * Re-enabling that early means [onBack] can now fire mid-animation, so it is routed through + * [rememberAfterKeyboardCloses] to wait for the inset to settle before popping. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable fun KeyboardAwareBackHandler( enabled: Boolean = true, onBack: () -> Unit, ) { - val keyboardState by keyboardAsState() - BackHandler(enabled = enabled && keyboardState == KeyboardState.Closed, onBack = onBack) + val density = LocalDensity.current + val imeTarget = WindowInsets.imeAnimationTarget + val afterKeyboardCloses = rememberAfterKeyboardCloses() + + val keyboardIsStaying by remember(density, imeTarget) { + derivedStateOf { imeTarget.getBottom(density) > 0 } + } + + BackHandler(enabled = enabled && !keyboardIsStaying) { afterKeyboardCloses(onBack) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt index 84b19c084a..4bd0aba287 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.bottombars.rememberAfterKeyboardCloses import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding @@ -45,6 +46,10 @@ fun ActionTopBar( onPost: () -> Unit, additionalActions: @Composable (() -> Unit)? = null, ) { + // Both exits pop the screen, and on a composer the keyboard is up while typing — the same + // pop-during-IME-animation race the back gesture avoids, just reached by a tap instead. + val afterKeyboardCloses = rememberAfterKeyboardCloses() + ShorterTopAppBar( title = { if (titleRes != null) { @@ -60,7 +65,7 @@ fun ActionTopBar( navigationIcon = { CloseButton( modifier = HalfHorzPadding, - onPress = onCancel, + onPress = { afterKeyboardCloses(onCancel) }, ) }, actions = { @@ -70,7 +75,7 @@ fun ActionTopBar( Button( modifier = HalfHorzPadding, enabled = isActive(), - onClick = onPost, + onClick = { afterKeyboardCloses(onPost) }, ) { Text(text = stringRes(postRes)) } From d9ea3ef86adf0e2be5f2c8bf169461ce8563309e Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:33:02 +0000 Subject: [PATCH 129/227] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-hi-rIN/strings.xml | 1 + .../src/main/res/values-nl-rNL/strings.xml | 87 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 59eea919c8..077e171144 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -203,6 +203,7 @@ सफल संयोजनों का प्रतिशत पुनःप्रसारक के साथ ढूँढें तथा प्रयेक्ता जोडें पुनःप्रसारक जोडें + मान्य पुनःप्रसारक पता नहीं। एक जालावास नाम का उपयोग करें। अथवा कोष्ठकों में एक अंकीय जालपता उदाहरण [201:d0e:9ba5:8bbc::1]:8080 के जैसे। मेरा @सूचक नाम प्रदर्शन नाम मेरा प्रदर्शन नाम diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 0006542c2a..4519e8c254 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -203,6 +203,7 @@ Percentage succesvolle verbindingen met deze relay Zoek en voeg gebruiker toe Relay toevoegen + Geen geldig relay-adres. Gebruik een hostnaam, of een IP-adres tussen blokhaken (bijvoorbeeld [201:d0e:9ba5:8bbc::1]:8080). Mijn @naam Weergavenaam Mijn weergavenaam @@ -1929,14 +1930,95 @@ + + %1$s \u00b7 %2$d relay + %1$s \u00b7 %2$d relays + + Bladeren + Media + Hashtags + Onderwerpen + Gesprek + Zoeken + Ontbrekende events zoeken + Events observeren + Haalt events op via hun id waar iets op je scherm naar verwijst maar die je nog niet hebt — een quote, de note waarop een reactie antwoordt, de start van een discussie. + Houdt de events die nu in beeld staan in de gaten voor nieuwe antwoorden, reacties, reposts, zaps en rapportages, zodat de tellers bijwerken terwijl je leest. + Add-ons + Relay-info + Overig + Relaylijsten zoeken + Profielen observeren + Accountgegevens + Startfeed + Relay-groepen + + %1$d groep + %1$d groepen + + Vluchtige chats + Locatiechats + Livestream-chat + NIP-29-groepen waar je lid van bent. Elke groep staat op één host-relay, dus de app verbindt met elke relay die een groep van jou host. + Chatruimtes die geen geschiedenis bewaren — berichten bestaan alleen zolang je verbonden bent, dus deze blijven geabonneerd om überhaupt iets te ontvangen. + Locatiegebonden ruimtes voor de gebieden die je volgt, opgevraagd bij de relays die ze aanbieden. + Chat en zap-doelen die horen bij livestreams die je open hebt staan of volgt. + DM-inbox + Wallet + Nutzap-inbox + Mintoverzicht + Wallet Connect + Community-chats + Community-feeds + Je inbox-relays, plus een kleine wisselende steekproef van de relays waarop de mensen die je volgt plaatsen, voor het geval een vermelding ergens anders is afgeleverd. + Je DM-inbox-relays, waar gift-wrapped berichten worden afgeleverd. + De thuisrelay van elke chat die je open hebt staan of waar je lid van bent. + De relays waarop elke community zijn planes publiceert. + Groepsberichten en sleutelpakketten, op de relays van elke groep. + De relays van de ruimte, zolang die open is. + Je eigen profiel, instellingen en concepten, op je eigen relays. + Profielen van de mensen die nu in beeld zijn. + Zoekt uit naar welke relays iemand publiceert, zodat hun posts van de juiste plek kunnen worden opgehaald. + Volglijsten, gebruikt om je feed en je web-of-trust op te bouwen. + Rapportages die de mensen die je volgt schreven over de profielen die nu in beeld zijn, opgevraagd bij elke relay waarop die mensen plaatsen. + Rapportages van wie je volgt + Je eigen wallet-events, teruggelezen van de relays waarop je ze hebt gepubliceerd. + Luistert op je nutzap-relays plus je inbox- en DM-relays, zodat een betaling er niet langs kan glippen. + Kijkt op alle relays welke mints er bestaan en welke door mensen worden aanbevolen. + Meldingen van je verbonden wallet. + Actieve relay-abonnementen + + %1$d filter + %1$d filters + + + %1$d relay + %1$d relays + + + %1$d filter is nog niet toegewezen + %1$d filters zijn nog niet toegewezen + + %1$s \u00b7 %2$s + Niet toegewezen aan een account + Alles + Iedereen + Mensen die je volgt + Een gekozen lijst mensen + Gedempte mensen + Jouw communities + Een favoriete algo-feed + %1$d%% van alles + abonnementen filters relays verzoeken reqs verbindingen waarom diagnostiek + Posts van de mensen die je volgt, gelezen van de relays waarop ieder van hen publiceert. Verbinden met inbox-relays… Altijd-aan meldingsdienst Houdt een persistente verbinding met je inbox-relays voor directe melding. Toont een permanente notificatie. Gebruikt meer batterij maar zorgt dat je nooit een bericht mist. @@ -2218,8 +2300,10 @@ Alleen locatie-exclusief bericht Alleen volgers van de locatie zien dit bericht. Alleen hashtag-exclusief bericht + Externe inhoud Reageer op een website Reageer op een externe bron + Openen in browser %1$d min lezen %1$d nummer @@ -2531,6 +2615,9 @@ Verzenden naar… Nieuw bericht Nieuwe Highlight + Nieuwe afbeelding + Nieuwe Short + Nieuwe video Nieuwe Highlight Gemarkeerde tekst Wat viel je op? From dda0f0d9e8d6f414fe0a0b3c0332ce1f60279cee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 16:27:42 +0000 Subject: [PATCH 130/227] Make the left drawer configurable style: apply spotless to the configurable drawer code docs: record why the settings state holders are deliberately unkeyed refactor: address cleanup review of the configurable drawer fix: rename the shared picker section header to avoid an overload clash --- .../vitorpamplona/amethyst/model/Account.kt | 4 + .../amethyst/model/AccountSettings.kt | 14 + .../amethyst/model/AccountSyncedSettings.kt | 18 +- .../model/AccountSyncedSettingsInternal.kt | 9 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/bottombars/NavBarItem.kt | 81 +---- .../ui/navigation/drawer/DrawerContent.kt | 104 +++--- .../navigation/drawer/DrawerItemVisibility.kt | 104 ++++++ .../ui/navigation/drawer/DrawerSections.kt | 149 ++++++++ .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../ui/screen/loggedIn/AccountViewModel.kt | 10 + .../settings/BottomBarSettingsScreen.kt | 305 +++++----------- .../loggedIn/settings/DrawerSettingsScreen.kt | 263 ++++++++++++++ .../loggedIn/settings/DrawerSettingsState.kt | 80 +++++ .../screen/loggedIn/settings/NavPickerUi.kt | 330 ++++++++++++++++++ .../settings/SettingsCatalogBuilder.kt | 1 + amethyst/src/main/res/values/strings.xml | 11 + .../preferences/DrawerPersistenceTest.kt | 83 +++++ .../navigation/DrawerItemVisibilityTest.kt | 159 +++++++++ .../amethyst/navigation/DrawerSectionsTest.kt | 101 ++++++ 20 files changed, 1492 insertions(+), 338 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerItemVisibility.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NavPickerUi.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/DrawerPersistenceTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 70afe7bbfe..966c01cedf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -150,6 +150,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.quartz.buzz.threading.buzzThread import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply @@ -955,6 +956,9 @@ class Account( */ fun applyBottomBarItems(items: List): Boolean = settings.changeBottomBarItems(items) + /** The drawer counterpart of [applyBottomBarItems] — same synchronous-apply, publish-after contract. */ + fun applyHiddenDrawerItems(items: Set): Boolean = settings.changeHiddenDrawerItems(items) + suspend fun toggleChatroomPin(room: ChatroomKey) { settings.toggleChatroomPin(room) sendNewAppSpecificData() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index f2605013c1..9802633ddf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -501,6 +503,18 @@ class AccountSettings( return false } + fun changeHiddenDrawerItems(newItems: Set): Boolean { + // Sanitize on the way in as well as on the way out: a caller must never be able to persist + // Settings as hidden, which would leave no route back to the screen that hides rows. + val sanitized = DrawerItemVisibility.sanitize(newItems) + if (syncedSettings.navigation.hiddenDrawerItems.value != sanitized) { + syncedSettings.navigation.hiddenDrawerItems.tryEmit(sanitized) + saveAccountSettings() + return true + } + return false + } + /** The selected default spend rail across both NWC wallets and CLINK debits. */ fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index a33799dd40..5fa6adc7ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -25,6 +25,10 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames +import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -83,6 +87,7 @@ class AccountSyncedSettings( val navigation = AccountNavigationPreferences( MutableStateFlow(internalSettings.navigation.bottomBarItems), + MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))), ) fun toInternal(): AccountSyncedSettingsInternal = @@ -124,7 +129,11 @@ class AccountSyncedSettings( .map { it.id } .sorted(), ), - navigation = AccountNavigationPreferencesInternal(navigation.bottomBarItems.value), + navigation = + AccountNavigationPreferencesInternal( + navigation.bottomBarItems.value, + navigation.hiddenDrawerItems.value.toNames(), + ), ) fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) { @@ -221,6 +230,11 @@ class AccountSyncedSettings( if (navigation.bottomBarItems.value != newBottomBarItems) { navigation.bottomBarItems.tryEmit(newBottomBarItems) } + + val newHiddenDrawerItems = DrawerItemVisibility.sanitize(navBarItemsFromNames(syncedSettingsInternal.navigation.hiddenDrawerItems)) + if (navigation.hiddenDrawerItems.value != newHiddenDrawerItems) { + navigation.hiddenDrawerItems.tryEmit(newHiddenDrawerItems) + } } fun dontTranslateFromFilteredBySpokenLanguages(): Set = languages.dontTranslateFrom.value - getLanguagesSpokenByUser() @@ -322,6 +336,8 @@ class AccountMediaPreferences( @Stable class AccountNavigationPreferences( val bottomBarItems: MutableStateFlow>, + /** Drawer rows switched off by the user. Empty = the stock drawer; see DrawerItemVisibility. */ + val hiddenDrawerItems: MutableStateFlow>, ) @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 4f3f51ae95..32b3900cb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -170,6 +170,15 @@ class AccountNavigationPreferencesInternal( // favorite apps, and individual joined chats/groups). Defaulted so blobs // written before this field existed decode to the app's current defaults. var bottomBarItems: List = DefaultBottomBarEntries, + // The drawer (side menu) rows the user switched off, as NavBarItem *names*. + // Empty by default, which is what makes a newly shipped destination visible + // to everyone without a migration — see DrawerItemVisibility. + // + // Stored as strings rather than the enum on purpose: an id written by a + // newer client would fail the enum decoder and take the whole synced-settings + // blob down with it, so unknown names are dropped on read instead (the same + // approach AccountPoWPreferencesInternal.enabledCategories takes). + var hiddenDrawerItems: List = emptyList(), ) @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 1b10a93069..ee1ace2d8d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -263,6 +263,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MessagesSettingsScreen @@ -578,6 +579,7 @@ fun BuildNavigation( composableFromEnd { MessagesSettingsScreen(accountViewModel, nav) } composableFromEnd { AudioVisualizerSettingsScreen(accountViewModel, nav) } composableFromEnd { BottomBarSettingsScreen(accountViewModel, nav) } + composableFromEnd { DrawerSettingsScreen(accountViewModel, nav) } composableFromEnd { HomeTabsSettingsScreen(accountViewModel, nav) } composableFromEnd { ProfileUiSettingsScreen(accountViewModel, nav) } composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 51f545d95c..2209c1bec6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.navigation.bottombars -import android.os.Build import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -29,8 +28,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.serialization.Serializable /** - * Stable identifiers for every drawer destination that the user can pin to the bottom bar. - * Order in this enum has no semantic meaning — the user picks a subset and an order at runtime. + * Stable identifiers for every destination the navigation surfaces can show — the bottom bar pins a + * subset in a user-chosen order, the drawer lists them under fixed headings (see DrawerSections). + * Order in this enum has no semantic meaning. */ @Serializable enum class NavBarItem { @@ -84,6 +84,18 @@ enum class NavBarItem { FAVORITE_ALGO_FEEDS, } +private val NavBarItemsByName = NavBarItem.entries.associateBy { it.name } + +/** + * Parses persisted [NavBarItem] names, silently dropping any this build doesn't know — a settings + * blob synced from a newer client can name a destination that doesn't exist here yet, and that must + * degrade to "ignore this one row" rather than failing the decode of the whole blob. + */ +fun navBarItemsFromNames(names: Collection): Set = names.mapNotNullTo(mutableSetOf()) { NavBarItemsByName[it] } + +/** The inverse of [navBarItemsFromNames]; sorted so the serialized form is deterministic. */ +fun Set.toNames(): List = map { it.name }.sorted() + data class NavBarItemDef( val id: NavBarItem, val labelRes: Int, @@ -443,34 +455,6 @@ val DefaultBottomBarItems: List = /** The default bottom bar as unified entries (all built-in; favorites are added by the user). */ val DefaultBottomBarEntries: List = DefaultBottomBarItems.map { BottomBarEntry.BuiltIn(it) } -// Ordered membership lists for each drawer section. The drawer renders these by looking up -// each id in NavBarCatalog, so adding a new screen only requires editing the catalog + the -// matching section list below — not two separate files. -val DrawerNavigateItems: List = - listOf( - NavBarItem.HOME, - NavBarItem.MESSAGES, - NavBarItem.VIDEO, - NavBarItem.BROWSER, - NavBarItem.DISCOVER, - NavBarItem.NOTIFICATIONS, - ) - -val DrawerYouItems: List = - listOf( - NavBarItem.PROFILE, - NavBarItem.MY_LISTS, - NavBarItem.BOOKMARKS, - NavBarItem.WEB_BOOKMARKS, - NavBarItem.DRAFTS, - NavBarItem.SCHEDULED_POSTS, - NavBarItem.INTEREST_SETS, - NavBarItem.BLOSSOM_DATA, - NavBarItem.EMOJI_PACKS, - NavBarItem.WALLET, - NavBarItem.NOSTR_SIGNER, - ) - /** * A titled, collapsible group of selectable destinations in the bottom-bar settings picker. The * catalog's [linkedMapOf] insertion order is hand-maintained and reads as scattered in the flat @@ -568,38 +552,3 @@ val BottomBarCategories: List = ), ), ) - -val DrawerFeedsItems: List = - listOfNotNull( - NavBarItem.ARTICLES, - NavBarItem.PICTURES, - NavBarItem.SHORTS, - NavBarItem.LONGS, - NavBarItem.PODCAST_EPISODES, - NavBarItem.PODCASTS, - NavBarItem.MUSIC_TRACKS, - NavBarItem.MUSIC_PLAYLISTS, - NavBarItem.POLLS, - NavBarItem.PRODUCTS, - NavBarItem.WORKOUTS, - NavBarItem.GIT_REPOSITORIES, - NavBarItem.HIGHLIGHTS, - NavBarItem.LIVE_STREAMS, - NavBarItem.NESTS, - NavBarItem.COMMUNITIES, - NavBarItem.PUBLIC_CHATS, - NavBarItem.RELAY_GROUPS, - NavBarItem.CONCORD, - NavBarItem.GEOHASH_CHATS, - NavBarItem.CALENDARS, - NavBarItem.CALENDAR_COLLECTIONS, - NavBarItem.SOFTWARE_APPS, - // Favorites can be pinned as inline tabs that render on a cross-process surface - // (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason. - NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R }, - NavBarItem.NAPPLETS, - NavBarItem.NSITES, - NavBarItem.FOLLOW_PACKS, - NavBarItem.BADGES, - NavBarItem.EMOJI_SETS, - ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index f0221e563d..a179e627c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -63,6 +63,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -105,9 +106,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth -import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems -import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems -import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef @@ -584,42 +582,17 @@ fun ListContent( accountViewModel: AccountViewModel, nav: INav, ) { + // Per-account, synced through the NIP-78 app-specific data event, and edited on the + // Side Menu settings screen. Empty (the default) means the full stock drawer. + val hidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle() + Column(modifier) { - CatalogSection(R.string.drawer_section_you, DrawerYouItems, accountViewModel, nav) - CatalogSection(R.string.drawer_section_navigate, DrawerNavigateItems, accountViewModel, nav) - CatalogSection(R.string.drawer_section_feeds, DrawerFeedsItems, accountViewModel, nav) - - CollapsibleSection(title = R.string.drawer_section_create) { - NavigationRow( - title = R.string.share_hls_video, - icon = MaterialSymbols.SettingsInputAntenna, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.NewHlsVideo, - ) - - if (isDebug) { - NavigationRow( - title = R.string.route_chess, - icon = MaterialSymbols.ChessKnight, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Chess, - ) - } - } - - CollapsibleSection(title = R.string.drawer_section_system) { - IconRowRelays( - accountViewModel = accountViewModel, - onClick = { - nav.closeDrawer() - nav.nav(Route.EditRelays) - }, - ) - - NavBarCatalog[NavBarItem.SETTINGS]?.let { - CatalogNavigationRow(it, MaterialTheme.colorScheme.onBackground, accountViewModel, nav) + DrawerSections.forEach { section -> + // Keyed by section: hiding the last row of a section removes it from the drawer + // entirely, and without a key the sections below would slide up into its slots and + // inherit its CollapsibleSection expanded/collapsed state. + key(section.id) { + CatalogSection(section, hidden, accountViewModel, nav) } } @@ -634,22 +607,65 @@ fun ListContent( } } +/** The Create section's rows — composer entry points, none of which is a catalog destination. */ +@Composable +private fun CreateRows(nav: INav) { + NavigationRow( + title = R.string.share_hls_video, + icon = MaterialSymbols.SettingsInputAntenna, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.NewHlsVideo, + ) + + if (isDebug) { + NavigationRow( + title = R.string.route_chess, + icon = MaterialSymbols.ChessKnight, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Chess, + ) + } +} + /** - * Renders a drawer section by iterating [ids] and looking each one up in [NavBarCatalog]. - * Profile gets the primary-colored tint; every other item uses onBackground. + * Renders one drawer section: its fixed rows, if it has any, then the catalog rows the user hasn't + * switched off. Profile gets the primary-colored tint; every other item uses onBackground. + * + * A section with nothing left to show renders nothing at all — an empty, permanently collapsed + * heading is just noise. Two sections always have something: Create is entirely fixed rows, and + * System carries the relay-status row (not a catalog destination — it shows a live counter). */ @Composable fun CatalogSection( - titleRes: Int, - ids: List, + section: DrawerSection, + hidden: Set, accountViewModel: AccountViewModel, nav: INav, ) { val primary = MaterialTheme.colorScheme.primary val onBackground = MaterialTheme.colorScheme.onBackground - CollapsibleSection(title = titleRes) { - ids.forEach { id -> + val visible = remember(section, hidden) { DrawerItemVisibility.visibleItems(section, hidden) } + val hasFixedRows = section.id == DrawerSectionId.CREATE || section.id == DrawerSectionId.SYSTEM + if (visible.isEmpty() && !hasFixedRows) return + + CollapsibleSection(title = section.titleRes) { + when (section.id) { + DrawerSectionId.CREATE -> CreateRows(nav) + DrawerSectionId.SYSTEM -> + IconRowRelays( + accountViewModel = accountViewModel, + onClick = { + nav.closeDrawer() + nav.nav(Route.EditRelays) + }, + ) + else -> {} + } + + visible.forEach { id -> NavBarCatalog[id]?.let { def -> val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground if (def.id == NavBarItem.SCHEDULED_POSTS) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerItemVisibility.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerItemVisibility.kt new file mode 100644 index 0000000000..5c649a9a60 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerItemVisibility.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.drawer + +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem + +/** + * Which drawer rows the user cannot hide. + * + * Settings is the only one, and it is mandatory for a specific reason: it is the route back to the + * screen that hides rows in the first place. Hiding it would let a user lock themselves out of their + * own configuration. Everything else the drawer always shows — the profile header, the relay-status + * row, the account switcher and the version/QR footer — is fixed chrome rather than a catalog row, + * so it is present by construction and never appears in the hidden set. + */ +val MandatoryDrawerItems: Set = setOf(NavBarItem.SETTINGS) + +/** + * Pure show/hide rules for the drawer's catalog rows, kept free of Compose and Android so they are + * exercised directly by unit tests (DrawerItemVisibilityTest) rather than only through the UI. + * + * The per-account preference stores the **hidden** items rather than the visible ones. That choice is + * what makes a newly added destination appear for everyone automatically: a row nobody has ever + * hidden simply isn't in the set, so it renders. Storing the visible list instead would freeze each + * account's drawer at the moment they first touched the setting, and every later release would have + * to migrate saved lists to introduce a screen. + */ +object DrawerItemVisibility { + fun isVisible( + hidden: Set, + item: NavBarItem, + ): Boolean = item in MandatoryDrawerItems || item !in hidden + + /** Hides [item] if shown, shows it if hidden. Mandatory items never change (see [MandatoryDrawerItems]). */ + fun toggle( + hidden: Set, + item: NavBarItem, + ): Set = + when { + item in MandatoryDrawerItems -> hidden + item in hidden -> hidden - item + else -> hidden + item + } + + /** + * Drops mandatory rows from the set. The persistence layer is the single place this is enforced — + * it runs on decode, on an external sync, and on every write — so a value synced from another + * client (or from a build where the row wasn't mandatory yet) can't strand Settings as hidden. + * + * Ids that no section renders are deliberately *kept*: on a device where a row is gated off (see + * DrawerFeedsItems' API-30 gate on Favorite Apps) it matches nothing and costs nothing, and + * preserving it means editing the drawer on that device doesn't silently clear the choice the + * user made on another one. + */ + fun sanitize(hidden: Set): Set = hidden - MandatoryDrawerItems + + /** The rows of [section] to render, in the section's fixed order. */ + fun visibleItems( + section: DrawerSection, + hidden: Set, + ): List = section.items.filter { isVisible(hidden, it) } + + /** How many of [section]'s rows are currently hidden — shown on the collapsed section header. */ + fun hiddenCount( + section: DrawerSection, + hidden: Set, + ): Int = section.items.count { !isVisible(hidden, it) } + + /** Whether [section] has any row the user is allowed to switch off — gates its bulk actions. */ + fun hasHideableRows(section: DrawerSection): Boolean = section.items.any { it !in MandatoryDrawerItems } + + /** Hides every row of [section] that can be hidden, leaving the mandatory ones. */ + fun hideAll( + hidden: Set, + section: DrawerSection, + ): Set = hidden + section.items.filter { it !in MandatoryDrawerItems } + + /** Shows every row of [section] again. */ + fun showAll( + hidden: Set, + section: DrawerSection, + ): Set = hidden - section.items.toSet() + + /** Total hidden rows across every section — the count the settings screen shows at the top. */ + fun totalHidden(hidden: Set): Int = DrawerSections.sumOf { hiddenCount(it, hidden) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt new file mode 100644 index 0000000000..4e591f71f9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.drawer + +import android.os.Build +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem + +/** + * The drawer's layout: which destinations it lists, under which heading, in which order. + * + * One list drives two screens — [ListContent] renders the visible rows of each section, and the Side + * Menu settings screen renders the same sections as its show/hide catalog. Adding a destination to a + * section's list therefore surfaces it in the drawer *and* in its configuration screen without + * touching either, and DrawerSectionsTest fails the build if a newly added [NavBarCatalog] id isn't + * filed into exactly one section. + * + * Section order and within-section order are fixed and not user-editable: the drawer is a menu, and a + * menu whose headings move around is harder to learn, not easier. The only per-account choice is + * which rows are visible — see [DrawerItemVisibility]. + */ +@Immutable +data class DrawerSection( + val id: DrawerSectionId, + val titleRes: Int, + val icon: MaterialSymbol, + val items: List, +) + +/** + * Identifies a section for the handful of rendering rules that are specific to one. Matching on this + * rather than on a section's object identity keeps those rules working if the list is ever mapped or + * copied — a `DrawerSections.map { it.copy(...) }` would silently defeat an `===` check, with no + * compile error and nothing to fail a test. + */ +enum class DrawerSectionId { + YOU, + NAVIGATE, + FEEDS, + + /** Composer entry points. Carries no catalog destinations, so nothing in it is configurable. */ + CREATE, + + /** Also renders the relay-status row, which isn't a catalog destination (it shows a live counter). */ + SYSTEM, +} + +private val DrawerNavigateItems: List = + listOf( + NavBarItem.HOME, + NavBarItem.MESSAGES, + NavBarItem.VIDEO, + NavBarItem.BROWSER, + NavBarItem.DISCOVER, + NavBarItem.NOTIFICATIONS, + ) + +private val DrawerYouItems: List = + listOf( + NavBarItem.PROFILE, + NavBarItem.MY_LISTS, + NavBarItem.BOOKMARKS, + NavBarItem.WEB_BOOKMARKS, + NavBarItem.DRAFTS, + NavBarItem.SCHEDULED_POSTS, + NavBarItem.INTEREST_SETS, + NavBarItem.FAVORITE_ALGO_FEEDS, + NavBarItem.BLOSSOM_DATA, + NavBarItem.EMOJI_PACKS, + NavBarItem.WALLET, + NavBarItem.NOSTR_SIGNER, + ) + +private val DrawerFeedsItems: List = + listOfNotNull( + NavBarItem.ARTICLES, + NavBarItem.PICTURES, + NavBarItem.SHORTS, + NavBarItem.LONGS, + NavBarItem.PODCAST_EPISODES, + NavBarItem.PODCASTS, + NavBarItem.MUSIC_TRACKS, + NavBarItem.MUSIC_PLAYLISTS, + NavBarItem.POLLS, + NavBarItem.PRODUCTS, + NavBarItem.WORKOUTS, + NavBarItem.GIT_REPOSITORIES, + NavBarItem.HIGHLIGHTS, + NavBarItem.LIVE_STREAMS, + NavBarItem.NESTS, + NavBarItem.COMMUNITIES, + NavBarItem.PUBLIC_CHATS, + NavBarItem.RELAY_GROUPS, + NavBarItem.CONCORD, + NavBarItem.GEOHASH_CHATS, + NavBarItem.CALENDARS, + NavBarItem.CALENDAR_COLLECTIONS, + NavBarItem.SOFTWARE_APPS, + // Favorites can be pinned as inline tabs that render on a cross-process surface + // (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason. + NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R }, + NavBarItem.NAPPLETS, + NavBarItem.NSITES, + NavBarItem.FOLLOW_PACKS, + NavBarItem.BADGES, + NavBarItem.EMOJI_SETS, + ) + +val DrawerSections: List = + listOf( + DrawerSection(DrawerSectionId.YOU, R.string.drawer_section_you, MaterialSymbols.AccountCircle, DrawerYouItems), + DrawerSection(DrawerSectionId.NAVIGATE, R.string.drawer_section_navigate, MaterialSymbols.Home, DrawerNavigateItems), + DrawerSection(DrawerSectionId.FEEDS, R.string.drawer_section_feeds, MaterialSymbols.Subscriptions, DrawerFeedsItems), + DrawerSection(DrawerSectionId.CREATE, R.string.drawer_section_create, MaterialSymbols.Edit, emptyList()), + DrawerSection(DrawerSectionId.SYSTEM, R.string.drawer_section_system, MaterialSymbols.Settings, listOf(NavBarItem.SETTINGS)), + ) + +fun drawerSection(id: DrawerSectionId): DrawerSection = DrawerSections.first { it.id == id } + +/** + * Catalog ids deliberately absent from every [DrawerSections] list, with the reason. Only Favorite + * Apps qualifies: [DrawerFeedsItems] gates it on API 30+ (its inline tabs need SurfaceControlViewHost), + * so on older devices the row simply doesn't exist. DrawerSectionsTest allows exactly these to be + * missing, and fails on anything else — that's what keeps a newly added destination from silently + * skipping both the drawer and its settings screen. + */ +val SdkGatedDrawerItems: Set = setOf(NavBarItem.FAVORITE_APPS) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 399db4dc84..b1afa1c5b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -457,6 +457,8 @@ sealed class Route { @Serializable object BottomBarSettings : Route() + @Serializable object DrawerSettings : Route() + @Serializable object HomeTabsSettings : Route() @Serializable object ProfileUiSettings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 7963b56c27..f27b3215c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus @@ -1986,6 +1987,15 @@ class AccountViewModel( fun bottomBarItemsFlow(): StateFlow> = account.settings.syncedSettings.navigation.bottomBarItems + fun hiddenDrawerItemsFlow(): StateFlow> = account.settings.syncedSettings.navigation.hiddenDrawerItems + + /** Same ordering contract as [changeBottomBarItems]: apply on the caller's thread, publish off it. */ + fun changeHiddenDrawerItems(items: Set) { + if (account.applyHiddenDrawerItems(items)) { + launchSigner { account.sendNewAppSpecificData() } + } + } + fun changeBottomBarItems(items: List) { // Apply to the reactive flow synchronously on the caller (UI) thread so rapid edits stay // ordered — launchSigner dispatches on a multi-threaded pool, so wrapping the emit too would diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt index 7131c5c891..f23ace6d4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -22,11 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures @@ -50,7 +45,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -97,7 +91,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag @@ -116,11 +109,6 @@ private val ExpandableItems = /** Soft guidance, not a hard cap: a Material bottom bar reads best at ~5 tabs. */ private const val RECOMMENDED_SLOTS = 5 -// Reveal expandable sections by unrolling straight down from the top edge (the default AnimatedVisibility -// enter also expands horizontally from the bottom-end, which reads as a diagonal slide from the top-left). -private val SectionExpand = expandVertically(expandFrom = Alignment.Top) + fadeIn() -private val SectionCollapse = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut() - @Composable @Preview(device = "spec:width=2100px,height=2340px,dpi=440") fun BottomBarSettingsScreenPreview() { @@ -157,6 +145,13 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { // All pin/unpin/reorder logic lives in the holder (unit-tested); the composable only renders and // forwards events. Each persist republishes the account's NIP-78 settings event. syncFrom re-seeds // when the saved list changes elsewhere without clobbering a drag. + // + // Deliberately unkeyed. The holder captures this `accountViewModel` in its persist lambda, so a + // holder that outlived an account switch would write account A's edits to account B. It cannot: + // SetAccountCentricViewModelStore wraps the whole logged-in tree in `key(account.signer.pubKey)`, + // so a switch disposes this composable (and the NavController with it) and re-runs this remember + // against the new account's ViewModel. Keying on accountViewModel here would be a no-op that + // implies the subtree survives a switch — if that ever becomes true, this comment is the bug. val state = remember { BottomBarSettingsState(savedItems) { accountViewModel.changeBottomBarItems(it) } } LaunchedEffect(savedItems) { state.syncFrom(savedItems) } @@ -177,19 +172,12 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { // --- The editable bar: a real preview you drag to reorder and tap ✕ to remove from. --- EditableBarCard(state, pinned, accountViewModel) - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = { state.restoreDefault() }) { - Text(stringRes(R.string.bottom_bar_settings_restore_default)) - } - } + RestoreDefaultRow(onClick = { state.restoreDefault() }) Spacer(Modifier.height(4.dp)) // --- Available catalogue, grouped into collapsible category cards. --- - SectionHeader(title = stringRes(R.string.bottom_bar_settings_available)) + PickerSectionHeader(title = stringRes(R.string.bottom_bar_settings_available)) BottomBarCategories.forEach { category -> CategoryCard( @@ -217,60 +205,43 @@ private fun EditableBarCard( pinned: List, accountViewModel: AccountViewModel, ) { - val accent = MaterialTheme.colorScheme.primary - Surface( - shape = RoundedCornerShape(22.dp), - color = accent.copy(alpha = 0.07f), - border = BorderStroke(1.dp, accent.copy(alpha = 0.22f)), - modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 4.dp), - ) { - Column(Modifier.padding(14.dp)) { - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringRes(R.string.bottom_bar_settings_pinned), - style = MaterialTheme.typography.labelMedium, - color = accent, - fontWeight = FontWeight.Bold, - ) - Text( - text = "${pinned.size} / $RECOMMENDED_SLOTS", - style = MaterialTheme.typography.labelMedium, - color = if (pinned.size > RECOMMENDED_SLOTS) MaterialTheme.colorScheme.error else accent, - fontWeight = FontWeight.Bold, - ) - } - - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.background, - shadowElevation = 3.dp, - modifier = Modifier.fillMaxWidth(), - ) { - if (pinned.isEmpty()) { - Box(Modifier.fillMaxWidth().height(60.dp), contentAlignment = Alignment.Center) { - Text( - stringRes(R.string.bottom_bar_settings_pinned_empty), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - } else { - EditableBar(state, pinned, accountViewModel) - } - } - + PickerHeroCard( + title = stringRes(R.string.bottom_bar_settings_pinned), + trailing = { Text( - text = stringRes(R.string.bottom_bar_settings_reorder_hint), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), + text = "${pinned.size} / $RECOMMENDED_SLOTS", + style = MaterialTheme.typography.labelMedium, + color = if (pinned.size > RECOMMENDED_SLOTS) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, ) + }, + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.background, + shadowElevation = 3.dp, + modifier = Modifier.fillMaxWidth(), + ) { + if (pinned.isEmpty()) { + Box(Modifier.fillMaxWidth().height(60.dp), contentAlignment = Alignment.Center) { + Text( + stringRes(R.string.bottom_bar_settings_pinned_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } else { + EditableBar(state, pinned, accountViewModel) + } } + + Text( + text = stringRes(R.string.bottom_bar_settings_reorder_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) } } @@ -470,72 +441,33 @@ private fun CategoryCard( accountViewModel: AccountViewModel, onTogglePin: (BottomBarEntry) -> Unit, ) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 5.dp), + CatalogCard( + icon = categoryIcon(category.titleRes), + title = stringRes(category.titleRes), + expanded = expanded, + onToggleExpand = onToggleExpand, ) { - Column { - Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpand).padding(13.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Box( - modifier = - Modifier - .size(34.dp) - .clip(RoundedCornerShape(11.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, + category.items.forEach { item -> + val def = NavBarCatalog[item] ?: return@forEach + val entry = BottomBarEntry.BuiltIn(item) + if (item in ExpandableItems) { + ExpandableAvailableRow( + icon = def.icon, + label = stringRes(def.labelRes), + pinned = entry.stableKey in pinnedKeys, + expanded = expandedItems[item] ?: false, + onTogglePin = { onTogglePin(entry) }, + onToggleExpand = { expandedItems[item] = !(expandedItems[item] ?: false) }, ) { - Icon( - symbol = categoryIcon(category.titleRes), - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + PickerChildren(item, pinnedKeys, accountViewModel, onTogglePin) } - Text( - text = stringRes(category.titleRes), - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.weight(1f), + } else { + AvailableRow( + leading = { LeadingGlyph(def.icon) }, + label = stringRes(def.labelRes), + pinned = entry.stableKey in pinnedKeys, + onToggle = { onTogglePin(entry) }, ) - Icon( - symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) { - Column(Modifier.padding(bottom = 6.dp)) { - category.items.forEach { item -> - val def = NavBarCatalog[item] ?: return@forEach - val entry = BottomBarEntry.BuiltIn(item) - if (item in ExpandableItems) { - ExpandableAvailableRow( - icon = def.icon, - label = stringRes(def.labelRes), - pinned = entry.stableKey in pinnedKeys, - expanded = expandedItems[item] ?: false, - onTogglePin = { onTogglePin(entry) }, - onToggleExpand = { expandedItems[item] = !(expandedItems[item] ?: false) }, - ) { - PickerChildren(item, pinnedKeys, accountViewModel, onTogglePin) - } - } else { - AvailableRow( - leading = { LeadingGlyph(def.icon) }, - label = stringRes(def.labelRes), - pinned = entry.stableKey in pinnedKeys, - onToggle = { onTogglePin(entry) }, - ) - } - } - } } } } @@ -757,18 +689,6 @@ private fun ConcordServerPickerGroup( // Rows & shared bits // ------------------------------------------------------------------------------------------------ -/** - * Start padding per nesting depth: 0 = a top-level catalog row, 1 = an item under an expandable - * category (a favorite, or a relay/community "server" row), 2 = a room nested under its server (a - * NIP-29 group under its relay, or a Concord channel under its community). - */ -private fun indentPadding(level: Int) = - when (level) { - 0 -> 13.dp - 1 -> 24.dp - else -> 40.dp - } - @Composable private fun AvailableRow( leading: @Composable () -> Unit, @@ -777,23 +697,12 @@ private fun AvailableRow( onToggle: () -> Unit, indentLevel: Int = 0, ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onToggle) - .padding(start = indentPadding(indentLevel), end = 13.dp, top = 7.dp, bottom = 7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + CatalogRow( + leading = leading, + label = label, + onToggle = onToggle, + indentLevel = indentLevel, ) { - leading() - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) AddPill(added = pinned, onClick = onToggle) } } @@ -838,52 +747,18 @@ private fun ExpandableAvailableRow( } } -/** - * Outlined "Add" that fills to "Added" once pinned — states the action and its result. Both states - * share one Row body (only color/border/tint differ) so the pill keeps a constant height and the rows - * stay aligned whether an item is added or not. - */ +/** Outlined "Add" that fills to "Added" once pinned — states the action and its result. */ @Composable private fun AddPill( added: Boolean, onClick: () -> Unit, ) { - val accent = MaterialTheme.colorScheme.primary - val content = if (added) MaterialTheme.colorScheme.onPrimary else accent - Surface( - shape = CircleShape, - color = if (added) accent else Color.Transparent, - border = if (added) null else BorderStroke(1.dp, accent), - ) { - Row( - modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - symbol = if (added) MaterialSymbols.Check else MaterialSymbols.Add, - contentDescription = null, - modifier = Modifier.size(15.dp), - tint = content, - ) - Text( - text = stringRes(if (added) R.string.bottom_bar_settings_added else R.string.bottom_bar_settings_add), - style = MaterialTheme.typography.labelLarge, - color = content, - ) - } - } -} - -/** A category/destination glyph in a soft accent-tinted circle. */ -@Composable -private fun LeadingGlyph(icon: MaterialSymbol) { - Box( - modifier = Modifier.size(34.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), - contentAlignment = Alignment.Center, - ) { - Icon(symbol = icon, contentDescription = null, modifier = Modifier.size(19.dp), tint = MaterialTheme.colorScheme.primary) - } + TogglePill( + on = added, + label = stringRes(if (added) R.string.bottom_bar_settings_added else R.string.bottom_bar_settings_add), + icon = if (added) MaterialSymbols.Check else MaterialSymbols.Add, + onClick = onClick, + ) } /** A favorite web-app / nsite / napplet's real favicon in a tinted circle (glyph fallback). */ @@ -902,30 +777,6 @@ private fun FavoriteLeading(app: FavoriteApp) { } } -@Composable -private fun SectionHeader(title: String) { - Text( - text = title, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = Size20dp, end = Size20dp, top = 18.dp, bottom = 6.dp), - ) -} - -@Composable -private fun EmptyChildHint( - textRes: Int, - indentLevel: Int = 1, -) { - Text( - text = stringRes(textRes), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = indentPadding(indentLevel), end = 13.dp, top = 6.dp, bottom = 6.dp), - ) -} - private fun categoryIcon(titleRes: Int): MaterialSymbol = when (titleRes) { R.string.bottom_bar_category_main -> MaterialSymbols.Home diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt new file mode 100644 index 0000000000..9b1d7cdb48 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSection +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections +import com.vitorpamplona.amethyst.ui.navigation.drawer.MandatoryDrawerItems +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow + +@Composable +@Preview(device = "spec:width=2100px,height=2340px,dpi=440") +fun DrawerSettingsScreenPreview() { + ThemeComparisonRow { + DrawerSettingsScreen( + mockAccountViewModel(), + EmptyNav(), + ) + } +} + +@Composable +fun DrawerSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(id = R.string.drawer_settings), nav) + }, + ) { padding -> + Column(Modifier.padding(padding)) { + DrawerSettingsContent(accountViewModel) + } + } +} + +/** + * Show/hide editor for the side menu's rows. It renders [DrawerSections] directly — the very list the + * drawer renders — so a destination added to a section shows up here with no work, and a row that + * exists here always exists there. + */ +@Composable +fun DrawerSettingsContent(accountViewModel: AccountViewModel) { + // Per-account, synced through the NIP-78 app-specific data event. + val savedHidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle() + + // All show/hide logic lives in the holder (unit-tested); the composable only renders and forwards + // events. Each edit republishes the account's NIP-78 settings event. syncFrom re-seeds when the + // saved set changes elsewhere. + // + // Deliberately unkeyed. The holder captures this `accountViewModel` in its persist lambda, so a + // holder that outlived an account switch would write account A's edits to account B. It cannot: + // SetAccountCentricViewModelStore wraps the whole logged-in tree in `key(account.signer.pubKey)`, + // so a switch disposes this composable (and the NavController with it) and re-runs this remember + // against the new account's ViewModel. Keying on accountViewModel here would be a no-op that + // implies the subtree survives a switch — if that ever becomes true, this comment is the bug. + val state = remember { DrawerSettingsState(savedHidden) { accountViewModel.changeHiddenDrawerItems(it) } } + LaunchedEffect(savedHidden) { state.syncFrom(savedHidden) } + + // Sections start collapsed: expanded, they are ~50 rows of scrolling. The header's hidden + // counter is what tells the user which one to open. + val expandedSections = remember { mutableStateMapOf() } + + // derivedStateOf so a toggle that leaves this total unchanged doesn't re-run the whole screen + // body — every SectionCard below reads the same coarse `hidden` state. + val totalHidden by remember { derivedStateOf { state.totalHidden() } } + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Spacer(Modifier.height(12.dp)) + + SummaryCard(totalHidden) + + RestoreDefaultRow(onClick = { state.restoreDefault() }) + + Spacer(Modifier.height(4.dp)) + + PickerSectionHeader(title = stringRes(R.string.drawer_settings_sections)) + + // A section with no catalog rows has nothing to configure (Create is composer entry points), + // so it isn't listed here even though the drawer renders it. + DrawerSections.forEach { section -> + if (section.items.isEmpty()) return@forEach + SectionCard( + section = section, + state = state, + expanded = expandedSections[section.id] ?: false, + onToggleExpand = { expandedSections[section.id] = !(expandedSections[section.id] ?: false) }, + ) + } + + Spacer(Modifier.height(24.dp)) + } +} + +/** What the setting does and how far from stock the menu currently is. */ +@Composable +private fun SummaryCard(totalHidden: Int) { + PickerHeroCard( + title = stringRes(R.string.drawer_settings_title), + trailing = { + Text( + text = stringRes(R.string.drawer_settings_hidden_count, totalHidden), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + }, + ) { + Text( + text = stringRes(R.string.drawer_settings_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SectionCard( + section: DrawerSection, + state: DrawerSettingsState, + expanded: Boolean, + onToggleExpand: () -> Unit, +) { + // Each card reads the same coarse `hidden` state, so without derivedStateOf a toggle in one + // section would recompose (and re-count) all of them. + val hiddenHere by remember(section) { derivedStateOf { state.hiddenCount(section) } } + + CatalogCard( + icon = section.icon, + title = stringRes(section.titleRes), + expanded = expanded, + onToggleExpand = onToggleExpand, + trailing = { + if (hiddenHere > 0) { + Text( + text = stringRes(R.string.drawer_settings_hidden_count, hiddenHere), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) { + // Bulk actions: turning ~29 feed rows off one at a time is the kind of chore that makes + // people give up halfway and leave the menu in a worse state than they found it. + if (DrawerItemVisibility.hasHideableRows(section)) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 6.dp, end = 6.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { state.showAll(section) }) { + Text(stringRes(R.string.drawer_settings_show_all)) + } + TextButton(onClick = { state.hideAll(section) }) { + Text(stringRes(R.string.drawer_settings_hide_all)) + } + } + } + + section.items.forEach { item -> + val def = NavBarCatalog[item] ?: return@forEach + val mandatory = item in MandatoryDrawerItems + val visible = state.isVisible(item) + CatalogRow( + leading = { LeadingGlyph(def.icon) }, + label = stringRes(def.labelRes), + onToggle = if (mandatory) null else ({ state.toggle(item) }), + ) { + VisibilityPill(visible = visible, mandatory = mandatory, onClick = { state.toggle(item) }) + } + } + } +} + +/** + * Filled "Visible" / outlined "Hidden" — the bottom bar's Add/Added pill, saying what this screen + * says instead. A mandatory row gets a locked "Always on" badge: it reads as deliberately fixed + * rather than as a control that ignores taps. + */ +@Composable +private fun VisibilityPill( + visible: Boolean, + mandatory: Boolean, + onClick: () -> Unit, +) { + TogglePill( + on = visible, + label = + stringRes( + when { + mandatory -> R.string.drawer_settings_always_on + visible -> R.string.drawer_settings_visible + else -> R.string.drawer_settings_hidden + }, + ), + icon = + when { + mandatory -> MaterialSymbols.Lock + visible -> MaterialSymbols.Visibility + else -> MaterialSymbols.VisibilityOff + }, + enabled = !mandatory, + onClick = onClick, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsState.kt new file mode 100644 index 0000000000..727058fd4f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsState.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSection + +/** + * State holder for the Side Menu settings screen: owns the set of switched-off drawer rows and the + * show / hide / restore-default operations, so the composable only renders and forwards events. + * + * The rules themselves live in [DrawerItemVisibility] (pure, unit-tested); this adds only the Compose + * state and the write-through to the account's synced settings. Unlike the bottom bar there is no + * transient/commit split — a toggle is a single discrete edit, not a drag, so every change persists + * immediately. + * + * No sanitizing here: every value in is either already sanitized by the persistence layer or produced + * by a [DrawerItemVisibility] operation that can't introduce a mandatory row, and the write side + * sanitizes again anyway. One authority, not three. + */ +@Stable +class DrawerSettingsState( + initial: Set, + private val persist: (Set) -> Unit, +) { + var hidden by mutableStateOf(initial) + private set + + fun isVisible(item: NavBarItem): Boolean = DrawerItemVisibility.isVisible(hidden, item) + + fun toggle(item: NavBarItem) = update(DrawerItemVisibility.toggle(hidden, item)) + + fun hiddenCount(section: DrawerSection): Int = DrawerItemVisibility.hiddenCount(section, hidden) + + fun totalHidden(): Int = DrawerItemVisibility.totalHidden(hidden) + + fun showAll(section: DrawerSection) = update(DrawerItemVisibility.showAll(hidden, section)) + + fun hideAll(section: DrawerSection) = update(DrawerItemVisibility.hideAll(hidden, section)) + + /** Back to the stock drawer: nothing hidden. */ + fun restoreDefault() = update(emptySet()) + + /** + * Re-seed from an external change (the saved settings flow emitted) without re-persisting. A no-op + * when equal, so the echo of our own [persist] doesn't fight an in-progress edit. + */ + fun syncFrom(items: Set) { + if (items != hidden) hidden = items + } + + private fun update(newHidden: Set) { + if (newHidden == hidden) return + hidden = newHidden + persist(newHidden) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NavPickerUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NavPickerUi.kt new file mode 100644 index 0000000000..0793edcf81 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NavPickerUi.kt @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size12dp +import com.vitorpamplona.amethyst.ui.theme.Size13dp +import com.vitorpamplona.amethyst.ui.theme.Size14dp +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Size18dp +import com.vitorpamplona.amethyst.ui.theme.Size19Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size22dp +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import com.vitorpamplona.amethyst.ui.theme.Size24dp +import com.vitorpamplona.amethyst.ui.theme.Size34dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.Size6dp + +/** + * The shared visual language of the navigation-configuration screens — the Bottom Navigation Bar + * picker and the Side Menu picker. Both present the same shape (collapsible cards of catalog rows, + * each row a glyph + label + a pill stating its current state), so the pieces live here once and + * each screen supplies only its own semantics: the bottom bar pins and reorders entries, the side + * menu switches rows on and off. + * + * [SectionExpand]/[SectionCollapse] reveal expandable sections by unrolling straight down from the + * top edge (the default AnimatedVisibility enter also expands horizontally from the bottom-end, + * which reads as a diagonal slide from the top-left). + */ +val SectionExpand = expandVertically(expandFrom = Alignment.Top) + fadeIn() +val SectionCollapse = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut() + +/** + * Start padding per nesting depth: 0 = a top-level catalog row, 1 = an item under an expandable + * category (a favorite, or a relay/community "server" row), 2 = a room nested under its server (a + * NIP-29 group under its relay, or a Concord channel under its community). + */ +fun indentPadding(level: Int) = + when (level) { + 0 -> Size13dp + 1 -> Size24dp + else -> Size40dp + } + +@Composable +fun PickerSectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = Size20dp, end = Size20dp, top = Size18dp, bottom = Size6dp), + ) +} + +/** A category/destination glyph in a soft accent-tinted circle. */ +@Composable +fun LeadingGlyph(icon: MaterialSymbol) { + Box( + modifier = SimpleImage35Modifier.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon(symbol = icon, contentDescription = null, modifier = Size19Modifier, tint = MaterialTheme.colorScheme.primary) + } +} + +@Composable +fun EmptyChildHint( + textRes: Int, + indentLevel: Int = 1, +) { + Text( + text = stringRes(textRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = indentPadding(indentLevel), end = Size13dp, top = Size6dp, bottom = Size6dp), + ) +} + +/** + * One catalog row: leading visual, label, and a caller-supplied [trailing] state control. Tapping + * anywhere on the row runs [onToggle]; pass null for a row whose state can't change (a mandatory + * side-menu item), which also drops the ripple so the row doesn't advertise an action it won't take. + */ +@Composable +fun CatalogRow( + leading: @Composable () -> Unit, + label: String, + onToggle: (() -> Unit)?, + indentLevel: Int = 0, + trailing: @Composable () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .let { if (onToggle != null) it.clickable(onClick = onToggle) else it } + .padding(start = indentPadding(indentLevel), end = Size13dp, top = 7.dp, bottom = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Size12dp), + ) { + leading() + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + trailing() + } +} + +/** + * The state pill at the end of a catalog row: outlined in the "off" state, filled in the "on" state — + * so it states both the current state and, by contrast, that it can be changed. Both states share one + * Row body (only color/border/tint differ) so the pill keeps a constant height and rows stay aligned. + * + * [enabled] false renders the pill as a locked, non-interactive badge — used for a row the user isn't + * allowed to switch off. + */ +@Composable +fun TogglePill( + on: Boolean, + label: String, + icon: MaterialSymbol, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val accent = MaterialTheme.colorScheme.primary + val container = if (enabled) accent else MaterialTheme.colorScheme.surfaceVariant + val content = + when { + !enabled -> MaterialTheme.colorScheme.onSurfaceVariant + on -> MaterialTheme.colorScheme.onPrimary + else -> accent + } + Surface( + shape = CircleShape, + color = if (on) container else Color.Transparent, + border = if (on) null else BorderStroke(1.dp, content), + ) { + Row( + modifier = + Modifier + .let { if (enabled) it.clickable(onClick = onClick) else it } + .padding(horizontal = Size14dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = icon, + contentDescription = null, + modifier = Size15Modifier, + tint = content, + ) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = content, + ) + } + } +} + +/** + * A collapsible card holding catalog rows. [trailing] renders between the title and the chevron — + * the side menu puts its "n hidden" counter there; the bottom bar leaves it empty. + */ +@Composable +fun CatalogCard( + icon: MaterialSymbol, + title: String, + expanded: Boolean, + onToggleExpand: () -> Unit, + trailing: @Composable () -> Unit = {}, + content: @Composable () -> Unit, +) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 5.dp), + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpand).padding(Size13dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Size12dp), + ) { + Box( + modifier = + Modifier + .size(Size34dp) + .clip(RoundedCornerShape(11.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = icon, + contentDescription = null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + trailing() + Icon( + symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Size24Modifier, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) { + Column(Modifier.padding(bottom = Size6dp)) { content() } + } + } + } +} + +/** + * The accent-tinted card each picker opens with: a bold title, an optional [trailing] status, and a + * body. The bottom bar puts its editable preview bar in the body; the side menu puts its description. + */ +@Composable +fun PickerHeroCard( + title: String, + trailing: @Composable () -> Unit = {}, + content: @Composable () -> Unit, +) { + val accent = MaterialTheme.colorScheme.primary + Surface( + shape = RoundedCornerShape(Size22dp), + color = accent.copy(alpha = 0.07f), + border = BorderStroke(1.dp, accent.copy(alpha = 0.22f)), + modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 4.dp), + ) { + Column(Modifier.padding(Size14dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = Size10dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = accent, + fontWeight = FontWeight.Bold, + ) + trailing() + } + + content() + } + } +} + +/** The end-aligned "Restore Default" action both pickers put under their hero card. */ +@Composable +fun RestoreDefaultRow(onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onClick) { + Text(stringRes(R.string.bottom_bar_settings_restore_default)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index d9a2d09b87..d164a3deba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -72,6 +72,7 @@ fun buildSettingsCatalog( symEntry(R.string.reactions_settings, MaterialSymbols.ThumbUp, R.string.reactions_settings_search_keywords, Route.ReactionsSettings), symEntry(R.string.messages_settings, MaterialSymbols.Mail, R.string.messages_settings_search_keywords, Route.MessagesSettings), symEntry(R.string.bottom_bar_settings, MaterialSymbols.Dashboard, R.string.bottom_bar_search_keywords, Route.BottomBarSettings), + symEntry(R.string.drawer_settings, MaterialSymbols.AutoMirrored.ViewList, R.string.drawer_search_keywords, Route.DrawerSettings), symEntry(R.string.video_player_settings, MaterialSymbols.VideoSettings, R.string.video_player_search_keywords, Route.VideoPlayerSettings), symEntry(R.string.audio_visualizer_settings, MaterialSymbols.MusicNote, R.string.audio_visualizer_search_keywords, Route.AudioVisualizerSettings), symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2221700aea..4f0003f2ba 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2378,6 +2378,7 @@ draft, posting, editor, auto-save, signature, proof of work, pow, mining, nip-13 emoji, reactions, like navigation, tabs, nav bar + side menu, drawer, hamburger, sections, hide, show tabs, feeds, threads, conversations profile, layout nsec, private key, seed, mnemonic, export @@ -3512,6 +3513,16 @@ Feeds Apps & Web Other + Side Menu + Your side menu + Open a section and switch off the rows you never use. Settings always stays visible, so you can always get back here. Section order is fixed. + Sections + %1$d hidden + Visible + Hidden + Always on + Show all + Hide all Home Tabs Pick which tabs appear on the Home screen. When only one tab is active the tab bar is hidden. Everything diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/DrawerPersistenceTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/DrawerPersistenceTest.kt new file mode 100644 index 0000000000..d686cadf79 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/DrawerPersistenceTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import com.vitorpamplona.amethyst.model.AccountNavigationPreferencesInternal +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames +import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Locks the per-account side-menu persistence. The hidden rows ride along in the same NIP-78 + * app-specific data blob as the bottom bar, so every account keeps its own menu and it syncs across + * the user's devices. + */ +class DrawerPersistenceTest { + @Test + fun defaultIsAnUntouchedMenu() { + val decoded = JsonMapper.fromJson(JsonMapper.toJson(AccountNavigationPreferencesInternal())) + + assertEquals(emptyList(), decoded.hiddenDrawerItems) + } + + @Test + fun blobWrittenBeforeTheFieldExistedDecodesToAnUntouchedMenu() { + // Every existing account is in this state, and so is every account that never opens the + // screen — which is exactly why the preference stores hidden rows rather than visible ones. + val decoded = JsonMapper.fromJson("{}") + + assertEquals(emptyList(), decoded.hiddenDrawerItems) + } + + @Test + fun hiddenRowsRoundTripThroughTheSyncedSettingsBlob() { + val hidden = setOf(NavBarItem.DRAFTS, NavBarItem.BADGES) + + val json = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = hidden.toNames())) + val decoded = JsonMapper.fromJson(json) + + assertEquals(hidden, navBarItemsFromNames(decoded.hiddenDrawerItems)) + } + + @Test + fun serializedFormIsDeterministic() { + // Two equal sets must produce byte-identical JSON, or a republish that changed nothing would + // still look like a change and churn the account's NIP-78 event. + val a = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = setOf(NavBarItem.DRAFTS, NavBarItem.BADGES).toNames())) + val b = JsonMapper.toJson(AccountNavigationPreferencesInternal(hiddenDrawerItems = setOf(NavBarItem.BADGES, NavBarItem.DRAFTS).toNames())) + + assertEquals(a, b) + } + + @Test + fun anIdFromANewerClientIsDroppedInsteadOfFailingTheWholeBlob() { + // The names are stored as strings precisely for this: decoding them as the enum would throw + // and take every other synced setting down with it. + val json = """{"hiddenDrawerItems":["DRAFTS","SOME_SCREEN_FROM_THE_FUTURE"]}""" + + val decoded = JsonMapper.fromJson(json) + + assertEquals(setOf(NavBarItem.DRAFTS), navBarItemsFromNames(decoded.hiddenDrawerItems)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt new file mode 100644 index 0000000000..d5fcf5f47a --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.navigation + +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId +import com.vitorpamplona.amethyst.ui.navigation.drawer.drawerSection +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The show/hide rules behind the Side Menu settings screen, exercised without the UI. + * + * The preference stores what is *hidden*, so the interesting cases are the empty set (a stock drawer, + * and the state every new install and every newly shipped destination starts in) and the mandatory + * rows, which no code path may switch off. + */ +class DrawerItemVisibilityTest { + private val you = drawerSection(DrawerSectionId.YOU) + private val system = drawerSection(DrawerSectionId.SYSTEM) + + @Test + fun nothingHiddenMeansEverythingVisible() { + val visible = DrawerItemVisibility.visibleItems(you, emptySet()) + + assertEquals(you.items, visible) + assertEquals(0, DrawerItemVisibility.hiddenCount(you, emptySet())) + } + + @Test + fun toggleHidesThenShows() { + val once = DrawerItemVisibility.toggle(emptySet(), NavBarItem.DRAFTS) + assertEquals(setOf(NavBarItem.DRAFTS), once) + assertFalse(DrawerItemVisibility.isVisible(once, NavBarItem.DRAFTS)) + + val twice = DrawerItemVisibility.toggle(once, NavBarItem.DRAFTS) + assertEquals(emptySet(), twice) + assertTrue(DrawerItemVisibility.isVisible(twice, NavBarItem.DRAFTS)) + } + + @Test + fun aHiddenRowDropsOutOfItsSectionKeepingTheOrderOfTheRest() { + val hidden = setOf(NavBarItem.DRAFTS) + val visible = DrawerItemVisibility.visibleItems(you, hidden) + + assertEquals(you.items.filter { it != NavBarItem.DRAFTS }, visible) + assertEquals(1, DrawerItemVisibility.hiddenCount(you, hidden)) + } + + @Test + fun settingsCannotBeHidden() { + // The escape hatch: hiding Settings would leave no route back to the screen that hides rows. + assertEquals(emptySet(), DrawerItemVisibility.toggle(emptySet(), NavBarItem.SETTINGS)) + assertTrue(DrawerItemVisibility.isVisible(setOf(NavBarItem.SETTINGS), NavBarItem.SETTINGS)) + } + + @Test + fun sanitizeStripsMandatoryItemsSyncedFromElsewhere() { + // Another client (or an older build) could put Settings in the set; reading it back must not + // strand the row as hidden-yet-unhideable. + val sanitized = DrawerItemVisibility.sanitize(setOf(NavBarItem.SETTINGS, NavBarItem.DRAFTS)) + + assertEquals(setOf(NavBarItem.DRAFTS), sanitized) + } + + @Test + fun sanitizeKeepsIdsThisDeviceDoesNotRender() { + // Favorite Apps is gated off below API 30. Editing the menu on such a device must not clear + // the choice the same account made on a newer one. + val sanitized = DrawerItemVisibility.sanitize(setOf(NavBarItem.FAVORITE_APPS)) + + assertEquals(setOf(NavBarItem.FAVORITE_APPS), sanitized) + } + + @Test + fun hideAllLeavesTheMandatoryRowsOfASection() { + val hidden = DrawerItemVisibility.hideAll(emptySet(), system) + + assertTrue(DrawerItemVisibility.isVisible(hidden, NavBarItem.SETTINGS)) + assertEquals(0, DrawerItemVisibility.hiddenCount(system, hidden)) + } + + @Test + fun aSectionOfOnlyMandatoryRowsHasNothingToHide() { + // What gates the section's bulk Show all / Hide all actions. + assertFalse(DrawerItemVisibility.hasHideableRows(system)) + assertTrue(DrawerItemVisibility.hasHideableRows(you)) + } + + @Test + fun hideAllThenShowAllRoundTripsASection() { + val hidden = DrawerItemVisibility.hideAll(emptySet(), you) + assertEquals(you.items.size, DrawerItemVisibility.hiddenCount(you, hidden)) + + val shown = DrawerItemVisibility.showAll(hidden, you) + assertEquals(emptySet(), shown) + } + + @Test + fun showAllOnlyTouchesItsOwnSection() { + val hidden = DrawerItemVisibility.hideAll(DrawerItemVisibility.hideAll(emptySet(), you), system) + + val shown = DrawerItemVisibility.showAll(hidden, system) + + assertEquals(you.items.size, DrawerItemVisibility.hiddenCount(you, shown)) + } + + @Test + fun stateHolderPersistsEveryEditAndRestoresDefaults() { + val saved = mutableListOf>() + val state = DrawerSettingsState(emptySet()) { saved.add(it) } + + state.toggle(NavBarItem.DRAFTS) + state.toggle(NavBarItem.BOOKMARKS) + + assertEquals(listOf(setOf(NavBarItem.DRAFTS), setOf(NavBarItem.DRAFTS, NavBarItem.BOOKMARKS)), saved) + assertEquals(2, state.totalHidden()) + + state.restoreDefault() + + assertEquals(emptySet(), state.hidden) + assertEquals(0, state.totalHidden()) + assertEquals(emptySet(), saved.last()) + } + + @Test + fun stateHolderDoesNotRepublishANoOpEdit() { + // Tapping a mandatory row must not republish the account's NIP-78 settings event. + val saved = mutableListOf>() + val state = DrawerSettingsState(emptySet()) { saved.add(it) } + + state.toggle(NavBarItem.SETTINGS) + state.restoreDefault() + + assertTrue(saved.isEmpty()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt new file mode 100644 index 0000000000..3cf1687a83 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.navigation + +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections +import com.vitorpamplona.amethyst.ui.navigation.drawer.MandatoryDrawerItems +import com.vitorpamplona.amethyst.ui.navigation.drawer.SdkGatedDrawerItems +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The drawer and its settings screen both render [DrawerSections], so a destination missing from + * every section is invisible in both places at once — no compiler error, no crash, just a screen + * nobody can reach from the menu. These pin the invariants that keep that from happening quietly. + */ +class DrawerSectionsTest { + @Test + fun everyCatalogItemAppearsInADrawerSection() { + val sectioned = DrawerSections.flatMap { it.items }.toSet() + val missing = NavBarCatalog.keys - sectioned + + assertEquals( + "a catalog destination is in no drawer section — add it to one in NavBarItem.kt, " + + "or to SdkGatedDrawerItems with the reason it can't be there", + emptySet(), + missing - SdkGatedDrawerItems, + ) + } + + @Test + fun noItemIsListedInTwoSections() { + val sectioned = DrawerSections.flatMap { it.items } + + assertEquals("an item is listed in more than one drawer section", sectioned.size, sectioned.toSet().size) + } + + @Test + fun everySectionedItemResolvesInTheCatalog() { + // The drawer looks each id up in NavBarCatalog and skips misses, so an id with no catalog + // entry would silently render nothing while still occupying a row in the settings screen. + DrawerSections.forEach { section -> + section.items.forEach { item -> + assertTrue("$item has no NavBarCatalog entry", NavBarCatalog.containsKey(item)) + } + } + } + + @Test + fun sectionsAreOrderedWithCreateBetweenFeedsAndSystem() { + // The drawer renders DrawerSections in order, so this list *is* the menu's layout. Create sits + // between the feeds and System, and is the one section with nothing configurable in it. + assertEquals( + listOf( + DrawerSectionId.YOU, + DrawerSectionId.NAVIGATE, + DrawerSectionId.FEEDS, + DrawerSectionId.CREATE, + DrawerSectionId.SYSTEM, + ), + DrawerSections.map { it.id }, + ) + assertEquals(emptyList(), DrawerSections.first { it.id == DrawerSectionId.CREATE }.items) + } + + @Test + fun everySectionHasItsOwnId() { + val ids = DrawerSections.map { it.id } + + assertEquals("two sections share a DrawerSectionId", ids.size, ids.toSet().size) + } + + @Test + fun mandatoryItemsAreActuallyRenderedByASection() { + // A mandatory item that no section renders would be unhideable *and* invisible — the worst + // of both. Settings is mandatory precisely because it is the way back to this configuration. + val sectioned = DrawerSections.flatMap { it.items }.toSet() + + assertTrue("a mandatory drawer item is in no section", sectioned.containsAll(MandatoryDrawerItems)) + } +} From 93fe3ac727b2cf41ead25e85a954c5d784fb6170 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 2 Aug 2026 09:16:35 +0200 Subject: [PATCH 131/227] Code review: - fold the pickers onto shared row/expand-state UI --- .../ui/navigation/bottombars/NavBarItem.kt | 7 +++ .../ui/navigation/drawer/DrawerContent.kt | 3 +- .../ui/navigation/drawer/DrawerSections.kt | 12 +++-- .../settings/BottomBarSettingsScreen.kt | 50 ++++++------------- .../loggedIn/settings/DrawerSettingsScreen.kt | 35 ++++++------- .../screen/loggedIn/settings/NavPickerUi.kt | 24 ++++++++- .../navigation/DrawerItemVisibilityTest.kt | 6 +-- .../amethyst/navigation/DrawerSectionsTest.kt | 16 ++++++ 8 files changed, 86 insertions(+), 67 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 2209c1bec6..5959cb5c25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -463,6 +463,7 @@ val DefaultBottomBarEntries: List = DefaultBottomBarItems.map { */ data class NavBarCategory( val titleRes: Int, + val icon: MaterialSymbol, val items: List, ) @@ -475,6 +476,7 @@ val BottomBarCategories: List = listOf( NavBarCategory( R.string.bottom_bar_category_main, + MaterialSymbols.Home, listOf( NavBarItem.HOME, NavBarItem.MESSAGES, @@ -485,6 +487,7 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_chats, + MaterialSymbols.Group, listOf( NavBarItem.PUBLIC_CHATS, NavBarItem.RELAY_GROUPS, @@ -494,6 +497,7 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_you, + MaterialSymbols.AccountCircle, listOf( NavBarItem.PROFILE, NavBarItem.MY_LISTS, @@ -511,6 +515,7 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_feeds, + MaterialSymbols.Subscriptions, listOf( NavBarItem.ARTICLES, NavBarItem.LONGS, @@ -537,6 +542,7 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_apps, + MaterialSymbols.Apps, listOf( NavBarItem.BROWSER, NavBarItem.FAVORITE_APPS, @@ -547,6 +553,7 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_other, + MaterialSymbols.Settings, listOf( NavBarItem.SETTINGS, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index a179e627c8..6009583b38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -648,8 +648,7 @@ fun CatalogSection( val onBackground = MaterialTheme.colorScheme.onBackground val visible = remember(section, hidden) { DrawerItemVisibility.visibleItems(section, hidden) } - val hasFixedRows = section.id == DrawerSectionId.CREATE || section.id == DrawerSectionId.SYSTEM - if (visible.isEmpty() && !hasFixedRows) return + if (visible.isEmpty() && !section.hasFixedRows) return CollapsibleSection(title = section.titleRes) { when (section.id) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt index 4e591f71f9..2cf323c39b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt @@ -47,6 +47,12 @@ data class DrawerSection( val titleRes: Int, val icon: MaterialSymbol, val items: List, + /** + * True for a section that renders rows of its own on top of its catalog items (see [CatalogSection]). + * Such a section stays in the drawer even with every catalog row switched off, and — since a fixed + * row is not a catalog destination — it never appears in the Side Menu settings screen's counts. + */ + val hasFixedRows: Boolean = false, ) /** @@ -133,12 +139,10 @@ val DrawerSections: List = DrawerSection(DrawerSectionId.YOU, R.string.drawer_section_you, MaterialSymbols.AccountCircle, DrawerYouItems), DrawerSection(DrawerSectionId.NAVIGATE, R.string.drawer_section_navigate, MaterialSymbols.Home, DrawerNavigateItems), DrawerSection(DrawerSectionId.FEEDS, R.string.drawer_section_feeds, MaterialSymbols.Subscriptions, DrawerFeedsItems), - DrawerSection(DrawerSectionId.CREATE, R.string.drawer_section_create, MaterialSymbols.Edit, emptyList()), - DrawerSection(DrawerSectionId.SYSTEM, R.string.drawer_section_system, MaterialSymbols.Settings, listOf(NavBarItem.SETTINGS)), + DrawerSection(DrawerSectionId.CREATE, R.string.drawer_section_create, MaterialSymbols.Edit, emptyList(), hasFixedRows = true), + DrawerSection(DrawerSectionId.SYSTEM, R.string.drawer_section_system, MaterialSymbols.Settings, listOf(NavBarItem.SETTINGS), hasFixedRows = true), ) -fun drawerSection(id: DrawerSectionId): DrawerSection = DrawerSections.first { it.id == id } - /** * Catalog ids deliberately absent from every [DrawerSections] list, with the reason. Only Favorite * Apps qualifies: [DrawerFeedsItems] gates it on API 30+ (its inline tabs need SurfaceControlViewHost), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt index f23ace6d4b..b3d24014fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -54,7 +54,6 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -91,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag @@ -158,8 +158,8 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { val pinned = state.pinned val pinnedKeys = remember(pinned) { state.pinnedKeys() } - val expandedCategories = remember { mutableStateMapOf() } - val expandedItems = remember { mutableStateMapOf() } + val expandedCategories = rememberExpandedKeys() + val expandedItems = rememberExpandedKeys() Column( modifier = @@ -183,8 +183,8 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { CategoryCard( category = category, pinnedKeys = pinnedKeys, - expanded = expandedCategories[category.titleRes] ?: false, - onToggleExpand = { expandedCategories[category.titleRes] = !(expandedCategories[category.titleRes] ?: false) }, + expanded = expandedCategories.isExpanded(category.titleRes), + onToggleExpand = { expandedCategories.toggle(category.titleRes) }, expandedItems = expandedItems, accountViewModel = accountViewModel, onTogglePin = state::togglePin, @@ -437,12 +437,12 @@ private fun CategoryCard( pinnedKeys: Set, expanded: Boolean, onToggleExpand: () -> Unit, - expandedItems: SnapshotStateMap, + expandedItems: ExpandedKeys, accountViewModel: AccountViewModel, onTogglePin: (BottomBarEntry) -> Unit, ) { CatalogCard( - icon = categoryIcon(category.titleRes), + icon = category.icon, title = stringRes(category.titleRes), expanded = expanded, onToggleExpand = onToggleExpand, @@ -455,9 +455,9 @@ private fun CategoryCard( icon = def.icon, label = stringRes(def.labelRes), pinned = entry.stableKey in pinnedKeys, - expanded = expandedItems[item] ?: false, + expanded = expandedItems.isExpanded(item), onTogglePin = { onTogglePin(entry) }, - onToggleExpand = { expandedItems[item] = !(expandedItems[item] ?: false) }, + onToggleExpand = { expandedItems.toggle(item) }, ) { PickerChildren(item, pinnedKeys, accountViewModel, onTogglePin) } @@ -717,27 +717,15 @@ private fun ExpandableAvailableRow( onToggleExpand: () -> Unit, children: @Composable () -> Unit, ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onToggleExpand) - .padding(start = 13.dp, end = 13.dp, top = 7.dp, bottom = 7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + CatalogRow( + leading = { LeadingGlyph(icon) }, + label = label, + onToggle = onToggleExpand, ) { - LeadingGlyph(icon) - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) Icon( symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, contentDescription = stringRes(R.string.bottom_bar_settings_expand), - modifier = Modifier.size(22.dp), + modifier = Size22Modifier, tint = MaterialTheme.colorScheme.onSurfaceVariant, ) AddPill(added = pinned, onClick = onTogglePin) @@ -777,16 +765,6 @@ private fun FavoriteLeading(app: FavoriteApp) { } } -private fun categoryIcon(titleRes: Int): MaterialSymbol = - when (titleRes) { - R.string.bottom_bar_category_main -> MaterialSymbols.Home - R.string.bottom_bar_category_chats -> MaterialSymbols.Group - R.string.bottom_bar_category_you -> MaterialSymbols.AccountCircle - R.string.bottom_bar_category_feeds -> MaterialSymbols.Subscriptions - R.string.bottom_bar_category_apps -> MaterialSymbols.Apps - else -> MaterialSymbols.Settings - } - // ------------------------------------------------------------------------------------------------ // Leading/label resolution for a pinned entry (built-in glyph, favorite icon, or group avatar). // Computed once so a group's channel is subscribed at most once per row. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt index 9b1d7cdb48..9a32cc8a11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt @@ -38,7 +38,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -113,11 +112,9 @@ fun DrawerSettingsContent(accountViewModel: AccountViewModel) { // Sections start collapsed: expanded, they are ~50 rows of scrolling. The header's hidden // counter is what tells the user which one to open. - val expandedSections = remember { mutableStateMapOf() } + val expandedSections = rememberExpandedKeys() - // derivedStateOf so a toggle that leaves this total unchanged doesn't re-run the whole screen - // body — every SectionCard below reads the same coarse `hidden` state. - val totalHidden by remember { derivedStateOf { state.totalHidden() } } + val totalHidden = state.totalHidden() Column( modifier = @@ -142,8 +139,8 @@ fun DrawerSettingsContent(accountViewModel: AccountViewModel) { SectionCard( section = section, state = state, - expanded = expandedSections[section.id] ?: false, - onToggleExpand = { expandedSections[section.id] = !(expandedSections[section.id] ?: false) }, + expanded = expandedSections.isExpanded(section.id), + onToggleExpand = { expandedSections.toggle(section.id) }, ) } @@ -241,22 +238,18 @@ private fun VisibilityPill( mandatory: Boolean, onClick: () -> Unit, ) { + // One branch decides both halves of the pill, so a label can't drift away from its glyph. + val (labelRes, icon) = + when { + mandatory -> R.string.drawer_settings_always_on to MaterialSymbols.Lock + visible -> R.string.drawer_settings_visible to MaterialSymbols.Visibility + else -> R.string.drawer_settings_hidden to MaterialSymbols.VisibilityOff + } + TogglePill( on = visible, - label = - stringRes( - when { - mandatory -> R.string.drawer_settings_always_on - visible -> R.string.drawer_settings_visible - else -> R.string.drawer_settings_hidden - }, - ), - icon = - when { - mandatory -> MaterialSymbols.Lock - visible -> MaterialSymbols.Visibility - else -> MaterialSymbols.VisibilityOff - }, + label = stringRes(labelRes), + icon = icon, enabled = !mandatory, onClick = onClick, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NavPickerUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NavPickerUi.kt index 0793edcf81..78be2e8f20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NavPickerUi.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NavPickerUi.kt @@ -42,6 +42,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -90,13 +93,32 @@ val SectionCollapse = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut( * category (a favorite, or a relay/community "server" row), 2 = a room nested under its server (a * NIP-29 group under its relay, or a Concord channel under its community). */ -fun indentPadding(level: Int) = +private fun indentPadding(level: Int) = when (level) { 0 -> Size13dp 1 -> Size24dp else -> Size40dp } +/** + * Which collapsible rows of a picker are currently open, keyed by whatever identifies a row (a + * section id, a string-resource id, a catalog item). Absent means collapsed, so the initial state + * costs nothing and no list has to be seeded. + */ +@Stable +class ExpandedKeys { + private val open = mutableStateMapOf() + + fun isExpanded(key: K): Boolean = open[key] == true + + fun toggle(key: K) { + open[key] = !isExpanded(key) + } +} + +@Composable +fun rememberExpandedKeys(): ExpandedKeys = remember { ExpandedKeys() } + @Composable fun PickerSectionHeader(title: String) { Text( diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt index d5fcf5f47a..3d193b7f08 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.navigation import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId -import com.vitorpamplona.amethyst.ui.navigation.drawer.drawerSection +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsState import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -38,8 +38,8 @@ import org.junit.Test * rows, which no code path may switch off. */ class DrawerItemVisibilityTest { - private val you = drawerSection(DrawerSectionId.YOU) - private val system = drawerSection(DrawerSectionId.SYSTEM) + private val you = DrawerSections.first { it.id == DrawerSectionId.YOU } + private val system = DrawerSections.first { it.id == DrawerSectionId.SYSTEM } @Test fun nothingHiddenMeansEverythingVisible() { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt index 3cf1687a83..f77c3d7523 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt @@ -90,6 +90,22 @@ class DrawerSectionsTest { assertEquals("two sections share a DrawerSectionId", ids.size, ids.toSet().size) } + @Test + fun aSectionWithNoCatalogItemsRendersFixedRowsOrNothingAtAll() { + // hasFixedRows is declared on the section but consumed by CatalogSection's `when (section.id)`, + // in another file — so the flag and the branch that honours it can drift apart with no compile + // error. A section that carries neither is unreachable in both directions at once: the settings + // screen skips it on items.isEmpty(), and CatalogSection returns before rendering a heading. + val unreachable = DrawerSections.filter { it.items.isEmpty() && !it.hasFixedRows } + + assertEquals( + "a drawer section has no catalog items and no fixed rows, so it renders nowhere — " + + "give it items, set hasFixedRows and a branch in CatalogSection, or delete it", + emptyList(), + unreachable.map { it.id }, + ) + } + @Test fun mandatoryItemsAreActuallyRenderedByASection() { // A mandatory item that no section renders would be unhideable *and* invisible — the worst From 64019dbd7c9d5048b93935daeaa9372fa21f7899 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 5 Aug 2026 22:21:47 +0200 Subject: [PATCH 132/227] update cs,pt,de,sv --- amethyst/src/main/res/values-cs/strings.xml | 90 +++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 78 ++++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 79 ++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 79 ++++++++++++++++ 4 files changed, 326 insertions(+) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 876d263166..66fa414df9 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -4811,4 +4811,94 @@ URL avataru (volitelné) Publikování… Publikovat personu + Vše + Oblíbený algoritmický zdroj + Vaše komunity + Vybraný seznam lidí + Lidé, které sledujete + Všichni + Ztlumení lidé + odběry subscriptions filtry relaye relay požadavky reqs připojení proč diagnostika + %1$d %% ze všech + Aktivní odběry relayů + Nepřiřazeno k žádnému účtu + Váš vlastní profil, nastavení a koncepty na vašich domovských relayích. + Relaye, na které každá komunita publikuje své roviny. + Vaše relaye pro schránku DM, kam se doručují zprávy zabalené v gift-wrapu. + Skupinové zprávy a balíčky klíčů na relayích každé skupiny. + Sleduje právě zobrazené události kvůli novým odpovědím, reakcím, sdílením, zapům a nahlášením, takže se počty aktualizují během čtení. + Chatovací místnosti bez historie — zprávy existují jen po dobu vašeho připojení, proto zůstávají odebírané, aby vůbec něco přišlo. + Seznamy sledovaných, ze kterých se sestavuje váš zdroj a vaše síť důvěry. + Místnosti podle polohy pro oblasti, které sledujete, dotazované na relayích, které je nesou. + Příspěvky lidí, které sledujete, čtené z relayů, na které každý z nich publikuje. + Chat a zapovací cíle připojené k živým vysíláním, která máte otevřená nebo sledujete. + Relaye místnosti, dokud je otevřená. + Prohledává relaye, které minty existují a které lidé doporučují. + Nahlášení, která vaši sledovaní napsali o profilech právě na obrazovce, dotazovaná na každém relayi, kam tito sledovaní publikují. + Vaše relaye pro příjem a k tomu malý rotující vzorek relayů, kam publikují vaši sledovaní, pro případ, že by zmínka byla doručena jinam. + Naslouchá na vašich nutzap relayích a také na relayích pro příjem a DM, aby vám neunikla žádná platba. + Upozornění z vaší připojené peněženky. + Profily lidí právě na obrazovce. + Domovský relay každého chatu, který máte otevřený nebo do kterého jste se připojili. + Načítá podle ID události, na které se něco na obrazovce odkazuje, ale zatím je nemáte — citaci, rodiče odpovědi, kořen vlákna. + Skupiny NIP-29, do kterých jste vstoupili. Každá skupina žije na jednom hostitelském relayi, takže se aplikace připojí ke každému relayi, který hostí některou vaši skupinu. + Zjišťuje, na které relaye každý člověk publikuje, aby se jeho příspěvky daly načíst na správném místě. + Události vaší vlastní peněženky, čtené zpět z relayů, na které jste je publikovali. + Doplňky + Procházení + Chaty komunit + Zdroje komunit + Schránka DM + Sledování událostí + Mizící chaty + Chaty podle místa + Domovský zdroj + Chat živého vysílání + Média + Adresář mintů + Schránka nutzapů + Sledování profilů + Ostatní + Hledání chybějících událostí + Relay skupiny + Informace o relayi + Vyhledávač seznamů relayů + Nahlášení od sledovaných + Hledání + Hashtagy + Konverzace + Témata + Data účtu + Peněženka + Neplatná adresa relaye. Použijte název hostitele nebo IP adresu v hranatých závorkách (například [201:d0e:9ba5:8bbc::1]:8080). + + %1$d filtr + %1$d filtry + %1$d filtru + %1$d filtrů + + + %1$d skupina + %1$d skupiny + %1$d skupiny + %1$d skupin + + + %1$d relay + %1$d relaye + %1$d relaye + %1$d relayů + + + %1$d filtr zatím není přiřazen + %1$d filtry zatím nejsou přiřazeny + %1$d filtru zatím není přiřazeno + %1$d filtrů zatím není přiřazeno + + + %1$s \u00b7 %2$d relay + %1$s \u00b7 %2$d relaye + %1$s \u00b7 %2$d relaye + %1$s \u00b7 %2$d relayů + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 4b96c7eddc..45e9c1cb5a 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -4635,4 +4635,82 @@ Avatar-URL (optional) Wird veröffentlicht… Persona veröffentlichen + Alle + Ein bevorzugter Feed-Algorithmus + Deine Communitys + Eine ausgewählte Liste von Personen + Personen, denen du folgst + Jeder + Stummgeschaltete Personen + abonnements subscriptions filter relays anfragen reqs verbindungen warum diagnose + %1$d %% von allen + Aktive Relay-Abonnements + Keinem Konto zugeordnet + Dein eigenes Profil, deine Einstellungen und Entwürfe auf deinen Heim-Relays. + Die Relays, auf denen jede Community ihre Planes veröffentlicht. + Deine DM-Posteingangs-Relays, an die Gift-Wrap-Nachrichten zugestellt werden. + Gruppennachrichten und Schlüsselpakete auf den Relays der jeweiligen Gruppe. + Beobachtet die gerade angezeigten Events auf neue Antworten, Reaktionen, Reposts, Zaps und Meldungen, damit die Zähler beim Lesen aktuell bleiben. + Chaträume ohne Verlauf — Nachrichten existieren nur, solange du verbunden bist, deshalb bleiben sie abonniert, damit überhaupt etwas ankommt. + Folgelisten, aus denen dein Feed und dein Web of Trust aufgebaut werden. + Standortbasierte Räume für die Gebiete, denen du folgst, abgefragt bei den Relays, die sie führen. + Beiträge von Personen, denen du folgst, gelesen von den Relays, auf denen jede von ihnen veröffentlicht. + Chat und Zap-Ziele, die an Live-Streams hängen, die du geöffnet hast oder denen du folgst. + Die Relays des Raums, solange er geöffnet ist. + Sucht über Relays hinweg, welche Mints existieren und welche empfohlen werden. + Meldungen, die deine Gefolgten über die gerade angezeigten Profile geschrieben haben, abgefragt bei jedem Relay, auf dem diese Gefolgten veröffentlichen. + Deine Posteingangs-Relays plus eine kleine, rotierende Stichprobe der Relays, auf denen deine Gefolgten veröffentlichen, falls eine Erwähnung woanders zugestellt wurde. + Lauscht auf deinen Nutzap-Relays sowie deinen Posteingangs- und DM-Relays, damit keine Zahlung durchrutscht. + Benachrichtigungen von deiner verbundenen Wallet. + Profile der gerade angezeigten Personen. + Das Heim-Relay jedes Chats, den du geöffnet hast oder dem du beigetreten bist. + Holt Events per ID, auf die etwas auf deinem Bildschirm verweist, die du aber noch nicht hast — ein Zitat, die übergeordnete Antwort, eine Thread-Wurzel. + NIP-29-Gruppen, denen du beigetreten bist. Jede Gruppe liegt auf einem Host-Relay, daher verbindet sich die App mit jedem Relay, das eine deiner Gruppen beherbergt. + Findet heraus, auf welchen Relays jede Person veröffentlicht, damit ihre Beiträge an der richtigen Stelle abgerufen werden können. + Deine eigenen Wallet-Events, zurückgelesen von den Relays, auf denen du sie veröffentlicht hast. + Erweiterungen + Stöbern + Community-Chats + Community-Feeds + DM-Posteingang + Events beobachten + Verschwindende Chats + Standort-Chats + Startseiten-Feed + Live-Stream-Chat + Medien + Mint-Verzeichnis + Nutzap-Posteingang + Profile beobachten + Sonstiges + Fehlende Events finden + Relay-Gruppen + Relay-Info + Relay-Listen-Finder + Meldungen von Gefolgten + Suche + Unterhaltung + Themen + Kontodaten + Keine gültige Relay-Adresse. Verwende einen Hostnamen oder eine IP-Adresse in Klammern (zum Beispiel [201:d0e:9ba5:8bbc::1]:8080). + + %1$d Filter + %1$d Filter + + + %1$d Gruppe + %1$d Gruppen + + + %1$d Relay + %1$d Relays + + + %1$d Filter ist noch nicht zugeordnet + %1$d Filter sind noch nicht zugeordnet + + + %1$s \u00b7 %2$d Relay + %1$s \u00b7 %2$d Relays + diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index ba71f48a49..17b4737653 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -4640,4 +4640,83 @@ URL do avatar (opcional) Publicando… Publicar persona + Tudo + Um algoritmo de feed favorito + Suas comunidades + Uma lista escolhida de pessoas + Pessoas que você segue + Todos + Pessoas silenciadas + assinaturas subscriptions filtros relays requisições reqs conexões por que diagnóstico + %1$d%% de todos + Assinaturas de relay ativas + Não atribuído a nenhuma conta + Seu próprio perfil, configurações e rascunhos, nos seus relays de origem. + Os relays em que cada comunidade publica seus planos. + Os relays da sua caixa de entrada de DM, para onde as mensagens em gift wrap são entregues. + Mensagens de grupo e pacotes de chaves, nos relays de cada grupo. + Observa os eventos exibidos no momento em busca de novas respostas, reações, repostagens, zaps e denúncias, para que as contagens sejam atualizadas enquanto você lê. + Salas de chat que não guardam histórico — as mensagens existem apenas enquanto você está conectado, então elas continuam assinadas para que algo chegue. + Listas de seguindo, usadas para montar seu feed e sua rede de confiança. + Salas baseadas em localização para as áreas que você segue, consultadas nos relays que as hospedam. + Publicações de pessoas que você segue, lidas dos relays em que cada uma delas publica. + Chat e metas de zap ligados às transmissões ao vivo que você tem abertas ou segue. + Os relays da sala, enquanto ela estiver aberta. + Procura pelos relays quais mints existem e quais as pessoas recomendam. + Denúncias que as pessoas que você segue escreveram sobre os perfis atualmente na sua tela, consultadas em cada relay em que essas pessoas publicam. + Os relays da sua caixa de entrada, mais uma pequena amostra rotativa dos relays em que quem você segue publica, caso uma menção tenha sido entregue em outro lugar. + Escuta nos seus relays de nutzap, além dos relays da caixa de entrada e de DM, para que nenhum pagamento passe despercebido. + Notificações da sua carteira conectada. + Perfis das pessoas atualmente na tela. + O relay de origem de cada chat que você abriu ou do qual participa. + Busca por id os eventos a que algo na sua tela se refere, mas que você ainda não tem — uma citação, o pai de uma resposta, a raiz de uma conversa. + Grupos NIP-29 dos quais você participa. Cada grupo vive em um relay hospedeiro, então o app se conecta a todo relay que hospeda um grupo seu. + Descobre em quais relays cada pessoa publica, para que as publicações dela possam ser buscadas no lugar certo. + Os eventos da sua própria carteira, lidos de volta dos relays em que você os publicou. + Complementos + Navegação + Chats de comunidades + Feeds de comunidades + Caixa de entrada de DM + Observando eventos + Chats efêmeros + Chats por localização + Feed inicial + Chat de transmissão ao vivo + Mídia + Diretório de mints + Caixa de entrada de nutzaps + Observando perfis + Outros + Encontrando eventos faltantes + Grupos de relay + Informações do relay + Localizador de listas de relays + Denúncias de quem você segue + Pesquisa + Conversa + Tópicos + Dados da conta + Carteira + Endereço de relay inválido. Use um nome de host ou um endereço IP entre colchetes (por exemplo [201:d0e:9ba5:8bbc::1]:8080). + + %1$d filtro + %1$d filtros + + + %1$d grupo + %1$d grupos + + + %1$d relay + %1$d relays + + + %1$d filtro ainda não foi atribuído + %1$d filtros ainda não foram atribuídos + + + %1$s \u00b7 %2$d relay + %1$s \u00b7 %2$d relays + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index d2a555d0f3..085d398a4c 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -4643,4 +4643,83 @@ Avatar-URL (valfritt) Publicerar… Publicera persona + Allt + En favorit-flödesalgoritm + Dina gemenskaper + En vald lista med personer + Personer du följer + Alla + Tystade personer + prenumerationer subscriptions filter reläer relay förfrågningar reqs anslutningar varför diagnostik + %1$d %% av alla + Aktiva reläprenumerationer + Inte kopplat till något konto + Din egen profil, dina inställningar och utkast, på dina hemreläer. + Reläerna som varje gemenskap publicerar sina plan till. + Dina DM-inkorgsreläer, dit gift-wrap-meddelanden levereras. + Gruppmeddelanden och nyckelpaket, på varje grupps reläer. + Bevakar de händelser som visas just nu efter nya svar, reaktioner, återinlägg, zaps och rapporter, så att räknarna uppdateras medan du läser. + Chattrum som inte sparar någon historik — meddelanden finns bara medan du är ansluten, så dessa förblir prenumererade för att något alls ska komma fram. + Följerlistor, som används för att bygga ditt flöde och ditt förtroendenät. + Platsbaserade rum för de områden du följer, efterfrågade från de reläer som bär dem. + Inlägg från personer du följer, lästa från de reläer var och en av dem publicerar till. + Chatt och zap-mål kopplade till livesändningar du har öppna eller följer. + Rummets reläer, medan det är öppet. + Söker över reläer efter vilka mints som finns och vilka folk rekommenderar. + Rapporter som personer du följer har skrivit om profilerna som just nu visas på skärmen, efterfrågade från varje relä dessa personer publicerar till. + Dina inkorgsreläer, plus ett litet roterande urval av de reläer personer du följer publicerar till, ifall ett omnämnande levererades någon annanstans. + Lyssnar på dina nutzap-reläer plus dina inkorgs- och DM-reläer, så att en betalning inte kan slinka förbi. + Aviseringar från din anslutna plånbok. + Profiler för personerna som just nu visas på skärmen. + Hemrelät för varje chatt du har öppen eller har gått med i. + Hämtar händelser via id som något på din skärm hänvisar till men som du inte har ännu — ett citat, ett svars förälder, en trådrot. + NIP-29-grupper du gått med i. Varje grupp bor på ett värdrelä, så appen ansluter till varje relä som är värd för en av dina grupper. + Hittar vilka reläer varje person publicerar till, så att deras inlägg kan hämtas från rätt ställe. + Dina egna plånbokshändelser, lästa tillbaka från de reläer du publicerade dem till. + Tillägg + Bläddring + Gemenskapschattar + Gemenskapsflöden + DM-inkorg + Observerar händelser + Försvinnande chattar + Platschattar + Hemflöde + Livesändningschatt + Mint-katalog + Nutzap-inkorg + Observerar profiler + Övrigt + Hittar saknade händelser + Relägrupper + Reläinfo + Relälistsökare + Rapporter från personer du följer + Sök + Hashtaggar + Konversation + Ämnen + Kontots data + Plånbok + Inte en giltig reläadress. Använd ett värdnamn eller en IP-adress inom hakparenteser (till exempel [201:d0e:9ba5:8bbc::1]:8080). + + %1$d filter + %1$d filter + + + %1$d grupp + %1$d grupper + + + %1$d relä + %1$d reläer + + + %1$d filter är inte kopplat ännu + %1$d filter är inte kopplade ännu + + + %1$s \u00b7 %2$d relä + %1$s \u00b7 %2$d reläer + From 0cf1534861b0792d6e72d44bc04fb44f1c718abb Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 5 Aug 2026 21:54:04 +0200 Subject: [PATCH 133/227] fix(media): stop rendering non-media NIP-94 files as video MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A kind-1063 file header was classified by a binary `isImage` test: anything that wasn't an image fell through to MediaUrlVideo. A webxdc app (application/x-webxdc, a zip) therefore reached ExoPlayer and buffered forever, as did every archive, installer and — since MediaUrlPdf was never constructed here — every NIP-94 PDF. --- .../ui/components/FileAttachmentCard.kt | 142 ++++++++++++++++++ .../ui/components/pdf/PdfPreviewCard.kt | 45 +----- .../amethyst/ui/note/types/FileHeader.kt | 142 ++++++++++++------ .../amethyst/ui/note/types/Video.kt | 5 +- .../amethyst/ui/note/types/VideoDisplay.kt | 5 +- .../loggedIn/shorts/VideoCardCompose.kt | 5 +- .../loggedIn/video/FileHeaderCardCompose.kt | 48 ++---- .../commons/richtext/MediaContentKind.kt | 37 +++++ .../commons/richtext/RichTextParser.kt | 83 +++++----- .../commons/richtext/ClassifyMediaTest.kt | 138 +++++++++++++++++ 10 files changed, 492 insertions(+), 158 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentKind.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt new file mode 100644 index 0000000000..759d5e7e83 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes +import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename +import com.vitorpamplona.amethyst.ui.note.types.prettyMime +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.innerPostModifier + +/** + * The renderer for a declared file that none of the media viewers can display — a webxdc app, + * an archive, an installer, any MIME [com.vitorpamplona.amethyst.commons.richtext.RichTextParser.classifyMedia] + * returns null for. + * + * It exists so those files have somewhere to land other than the video player: an unknown blob + * used to fall through an image-or-else-video branch into ExoPlayer, which buffers forever on a + * zip. Everything shown here comes off the event's own tags (NIP-94 `alt`, `m`, `size`), so the + * card costs no network round-trip — unlike routing the URL through the OpenGraph previewer, + * which would try to download the blob just to rediscover the type the event already declared. + */ +@Composable +fun FileAttachmentCard( + url: String, + description: String?, + mimeType: String?, + sizeInBytes: Long?, +) { + val uriHandler = LocalUriHandler.current + val filename = remember(url) { extractFilename(url) } + val subtitle = remember(mimeType, sizeInBytes) { fileSubtitle(mimeType, sizeInBytes) } + + Column( + modifier = + MaterialTheme.colorScheme.innerPostModifier + .fillMaxWidth() + .clickable { uriHandler.openUri(url) }, + ) { + FileAttachmentRow( + symbol = MaterialSymbols.AttachFile, + // The alt/content text names the file for a human ("Webxdc app: Quake"); + // the hashed URL basename is the fallback when the event omits it. + title = description?.ifBlank { null } ?: filename, + subtitle = subtitle, + titleMaxLines = 2, + ) + + Spacer(modifier = DoubleVertSpacer) + } +} + +/** + * The icon + title + subtitle row shared by every card that stands in for a file it can't + * render inline: this one and the PDF placeholder/skeleton in + * [com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard]. + */ +@Composable +internal fun FileAttachmentRow( + symbol: MaterialSymbol, + title: String, + subtitle: String?, + titleMaxLines: Int = 1, +) { + Row( + modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + maxLines = titleMaxLines, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } +} + +/** "APK · 16 MB", dropping either half when the event doesn't declare it. */ +private fun fileSubtitle( + mimeType: String?, + sizeInBytes: Long?, +): String? = + listOfNotNull( + mimeType?.ifBlank { null }?.let(::prettyMime), + sizeInBytes?.takeIf { it > 0 }?.let(::countToHumanReadableBytes), + ).joinToString(" · ").ifEmpty { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt index c352077e42..0b975ede46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -26,38 +26,29 @@ import android.os.ParcelFileDescriptor import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import androidx.core.graphics.createBitmap -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.ui.components.ClickableUrl +import com.vitorpamplona.amethyst.ui.components.FileAttachmentRow import com.vitorpamplona.amethyst.ui.components.ShareMediaAction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -207,35 +198,11 @@ private fun PdfSkeletonCard(filename: String) { private fun FilenameRow( filename: String, subtitle: String, -) { - Row( - modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - symbol = MaterialSymbols.PictureAsPdf, - contentDescription = null, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = filename, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - } -} +) = FileAttachmentRow( + symbol = MaterialSymbols.PictureAsPdf, + title = filename, + subtitle = subtitle, +) private fun renderFirstPage( file: java.io.File, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index 62d91d39aa..96730249ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -24,10 +24,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.FileAttachmentCard import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -43,50 +46,103 @@ fun FileHeaderDisplay( ) { val event = (note.event as? FileHeaderEvent) ?: return val fullUrl = event.url() ?: return + val mimeType = remember(note) { event.mimeType() } + val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) } - val content: BaseMediaContent = - remember(note) { - val blurHash = event.blurhash() - val thumbHash = event.thumbhash() - val hash = event.hash() - val dimensions = event.dimensions() - val description = event.content.ifEmpty { null } ?: event.alt() - val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) - val uri = note.toNostrUri() - val mimeType = event.mimeType() - - if (isImage) { - MediaUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - mimeType = mimeType, - thumbhash = thumbHash, - ) - } else { - MediaUrlVideo( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - mimeType = mimeType, - thumbhash = thumbHash, - ) - } - } - + // The sensitivity gate wraps both branches: a content warning is about the file, not about + // which viewer happens to render it, so an NSFW-tagged archive stays behind the same gate. SensitivityWarning(note = note, accountViewModel = accountViewModel) { - ZoomableContentView( - content = content, - roundedCorner = roundedCorner, - contentScale = contentScale, - accountViewModel = accountViewModel, - ) + if (content == null) { + FileHeaderAttachmentCard(event, fullUrl, mimeType) + } else { + ZoomableContentView( + content = content, + roundedCorner = roundedCorner, + contentScale = contentScale, + accountViewModel = accountViewModel, + ) + } } } + +/** + * Builds the viewer for a kind-1063 header, or **null** when no viewer can show the blob. + * + * Kind 1063 is a *generic* file container — its `m` tag can name any type, so unlike a NIP-71 + * video event the kind itself asserts nothing about how to render the payload. A null here means + * the file belongs in [FileHeaderAttachmentCard] rather than being pushed into the video player. + */ +internal fun FileHeaderEvent.toMediaContent( + note: Note, + url: String, + mimeType: String?, +): BaseMediaContent? { + val blurHash = blurhash() + val thumbHash = thumbhash() + val hash = hash() + val dimensions = dimensions() + val description = fileDescription() + val uri = note.toNostrUri() + + return when (RichTextParser.classifyMedia(url, mimeType)) { + MediaContentKind.IMAGE -> + MediaUrlImage( + url = url, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + mimeType = mimeType, + thumbhash = thumbHash, + ) + + MediaContentKind.VIDEO -> + MediaUrlVideo( + url = url, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + mimeType = mimeType, + thumbhash = thumbHash, + ) + + MediaContentKind.PDF -> + MediaUrlPdf( + url = url, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + mimeType = mimeType, + thumbhash = thumbHash, + ) + + null -> null + } +} + +/** The link card a kind-1063 header falls back to when [toMediaContent] returns null. */ +@Composable +internal fun FileHeaderAttachmentCard( + event: FileHeaderEvent, + url: String, + mimeType: String?, +) { + val description = remember(event) { event.fileDescription() } + val sizeInBytes = remember(event) { event.size()?.toLong() } + + FileAttachmentCard( + url = url, + description = description, + mimeType = mimeType, + sizeInBytes = sizeInBytes, + ) +} + +/** The human-facing name of the file: NIP-94 `content` when present, else the `alt` tag. */ +private fun FileHeaderEvent.fileDescription(): String? = content.ifEmpty { null } ?: alt() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index d00bd6d2e8..1d8cdcc3d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -88,7 +89,9 @@ fun VideoDisplay( val content: BaseMediaContent = remember(note) { val description = videoEvent.content.ifBlank { null } ?: event.alt() - val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) + // A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the + // viewer; an unclassifiable one still belongs in the player. See classifyMedia. + val isImage = RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE val uri = note.toNostrUri() if (isImage) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index 0abc16ac93..4f5c661810 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -55,7 +56,9 @@ fun JustVideoDisplay( val imeta = videoEvent.imetaTags().getOrNull(0) ?: return val isSensitive = remember(note) { event.isSensitiveOrNSFW() } val reasons = remember(note) { collectContentWarningReasons(event) } - val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) } + // A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the + // viewer; an unclassifiable one still belongs in the player. See classifyMedia. + val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE } val content by remember(note) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt index 6171a82067..9e52ec0362 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -104,7 +105,9 @@ private fun VideoCardImage( val imeta = videoEvent.imetaTags().getOrNull(0) ?: return val isSensitive = remember(note) { event.isSensitiveOrNSFW() } val reasons = remember(note) { collectContentWarningReasons(event) } - val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) } + // A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the + // viewer; an unclassifiable one still belongs in the player. See classifyMedia. + val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE } val content by remember(note) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt index ac3778152f..f47613e775 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt @@ -29,7 +29,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -38,10 +37,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage -import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo -import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.BlurhashBackdrop @@ -51,9 +47,10 @@ import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ReactionsRow +import com.vitorpamplona.amethyst.ui.note.types.FileHeaderAttachmentCard +import com.vitorpamplona.amethyst.ui.note.types.toMediaContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent @@ -101,45 +98,22 @@ private fun FileHeaderCardImage( val isSensitive = remember(note) { event.isSensitiveOrNSFW() } val reasons = remember(note) { collectContentWarningReasons(event) } - val isImage = remember(note) { event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) } + val mimeType = remember(note) { event.mimeType() } val blurHash = remember(note) { event.blurhash() } val thumbHash = remember(note) { event.thumbhash() } val dimensions = remember(note) { event.dimensions() } - val content by remember(note) { - val hash = event.hash() - val description = event.content.ifEmpty { null } ?: event.alt() - val uri = note.toNostrUri() - val mimeType = event.mimeType() + val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) } - mutableStateOf( - if (isImage) { - MediaUrlImage( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - mimeType = mimeType, - thumbhash = thumbHash, - ) - } else { - MediaUrlVideo( - url = fullUrl, - description = description, - hash = hash, - blurhash = blurHash, - dim = dimensions, - uri = uri, - authorName = note.author?.toBestDisplayName(), - mimeType = mimeType, - thumbhash = thumbHash, - ) - }, - ) + // VideoFeedFilter only admits image/video MIMEs and extensions, so a non-media blob should + // never reach this card. Render it as a link anyway rather than keeping an "unknown → video" + // default around: that default is what put a webxdc app into ExoPlayer in the note renderer. + if (content == null) { + FileHeaderAttachmentCard(event, fullUrl, mimeType) + return } + val isImage = content is MediaUrlImage val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(fullUrl) ContentWarningGate( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentKind.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentKind.kt new file mode 100644 index 0000000000..57cce902b2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentKind.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +/** + * Which player/viewer can render a declared blob, as resolved by + * [RichTextParser.classifyMedia]. + * + * The set is deliberately closed: it enumerates the renderers [BaseMediaContent] actually has + * (`MediaUrlImage`, `MediaUrlVideo`, `MediaUrlPdf`), so "no constant fits" — a `null` + * classification — is the honest answer for every other file type rather than a bucket some + * caller has to invent a default for. Audio folds into [VIDEO] because both play through the + * same pipeline; see `RichTextParser.videoExt`. + */ +enum class MediaContentKind { + IMAGE, + VIDEO, + PDF, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 60f04f7578..0ecc3b82f0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -61,41 +61,12 @@ class RichTextParser { val contentType = frags[MimeTypeTag.TAG_NAME] ?: tags[MimeTypeTag.TAG_NAME]?.firstOrNull() - var isImage = false - var isVideo = false - var isPdf = false + // Returning null here drops the URL to a plain link, discarding the imeta's `dim`/blurhash + // and forcing a URL-preview round-trip to rediscover a type the imeta already declared — + // which is why classifyMedia falls back to the extension before giving up. + val kind = classifyMedia(fullUrl, contentType) - if (contentType != null) { - isImage = contentType.startsWith("image/") - // HLS playlists are advertised with a non-`video/*` MIME (`application/vnd.apple.mpegurl` - // and three legacy aliases). Without these, an imeta-described `.m3u8` falls into the - // null bucket below and the renderer drops back to a plain hyperlink — even though - // the matching extension would have routed it to MediaUrlVideo. Mirror the canonical - // list used by MediaItemCache.toExoPlayerMimeType / GalleryThumb.isHlsMimeType. - isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/") || isHlsMimeType(contentType) - isPdf = contentType.startsWith("application/pdf") - } else if (fullUrl.startsWith("data:")) { - isImage = fullUrl.startsWith("data:image/") - isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/") - isPdf = fullUrl.startsWith("data:application/pdf") - } - - // Fall back to file-extension detection when the type is still unknown. This covers both - // the no-MIME case and a *malformed* imeta MIME — e.g. Primal iOS emits `m jpeg` instead - // of `m image/jpeg`, which matches none of the `startsWith` prefixes above. Without this - // fallback such a URL returns null and drops to a plain link: that discards the imeta - // `dim`/blurhash (so the loading placeholder can't reserve the image's height and the - // feed jumps once the bitmap arrives) and forces a needless URL-preview network - // round-trip just to rediscover the type the imeta already declared. `data:` URIs carry - // their type in the prefix, so a miss there is genuine — don't extension-probe them. - if (!isImage && !isVideo && !isPdf && !fullUrl.startsWith("data:")) { - val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) - isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } - isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } - isPdf = pdfExtensions.any { removedParamsFromUrl.endsWith(it) } - } - - return if (isImage) { + return if (kind == MediaContentKind.IMAGE) { MediaUrlImage( url = fullUrl, description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(), @@ -108,7 +79,7 @@ class RichTextParser { thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(), authorPubKey = authorPubKey, ) - } else if (isVideo) { + } else if (kind == MediaContentKind.VIDEO) { MediaUrlVideo( url = fullUrl, description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(), @@ -125,7 +96,7 @@ class RichTextParser { thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(), authorPubKey = authorPubKey, ) - } else if (isPdf) { + } else if (kind == MediaContentKind.PDF) { MediaUrlPdf( url = fullUrl, description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(), @@ -582,6 +553,46 @@ class RichTextParser { return pdfExtensions.any { removedParamsFromUrl.endsWith(it) } } + /** + * Resolves which renderer can display a declared blob — the single decision every media + * renderer must make, from a NIP-94 `m` tag, a NIP-92 imeta, or a bare URL. + * + * A declared MIME type wins; the URL extension is the fallback both for the no-MIME case + * and for a *malformed* MIME (Primal iOS emits `m jpeg` rather than `m image/jpeg`, which + * matches no prefix below). `data:` URIs carry their type in the prefix, so a miss there is + * genuine and the base64 payload is never extension-probed. + * + * Returns **null** when nothing can render the file. Callers must not substitute a media + * kind for that null: handing an arbitrary blob — a webxdc app, a zip, an APK — to the + * video player yields a permanently-buffering ExoPlayer where a plain link belongs. The one + * defensible default is on kinds whose *event* already asserts the type (a NIP-71 video + * event is a video however odd its imeta), and those call sites say so explicitly. + */ + fun classifyMedia( + url: String, + mimeType: String?, + ): MediaContentKind? { + if (mimeType != null) { + if (mimeType.startsWith("image/")) return MediaContentKind.IMAGE + // HLS playlists are advertised with a non-`video/*` MIME; see [isHlsMimeType]. + if (mimeType.startsWith("video/") || mimeType.startsWith("audio/") || isHlsMimeType(mimeType)) return MediaContentKind.VIDEO + if (mimeType.startsWith("application/pdf")) return MediaContentKind.PDF + } else if (url.startsWith("data:")) { + if (url.startsWith("data:image/")) return MediaContentKind.IMAGE + if (url.startsWith("data:video/") || url.startsWith("data:audio/")) return MediaContentKind.VIDEO + if (url.startsWith("data:application/pdf")) return MediaContentKind.PDF + } + + if (url.startsWith("data:")) return null + + val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.IMAGE + if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.VIDEO + if (pdfExtensions.any { removedParamsFromUrl.endsWith(it) }) return MediaContentKind.PDF + + return null + } + fun isValidURL(url: String?): Boolean = isValidUrl(url) fun parseImageOrVideo(fullUrl: String): BaseMediaContent { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt new file mode 100644 index 0000000000..2482a8ae80 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ClassifyMediaTest { + @Test + fun webxdcAppIsNotMedia() { + // Regression: a NIP-94 header for a webxdc app (a zip bundle) used to reach the + // ExoPlayer branch, because the only test was `isImage` and everything else fell + // through to video. https://blossom.ditto.pub/.xdc, m=application/x-webxdc + assertNull( + RichTextParser.classifyMedia( + "https://blossom.ditto.pub/d810ba7873d710b197fc402c0573cd95ce7d44fff7f904e8f58e48af3a47c107.xdc", + "application/x-webxdc", + ), + ) + } + + @Test + fun unknownTypesAreNotMedia() { + assertNull(RichTextParser.classifyMedia("https://x.com/app.apk", "application/vnd.android.package-archive")) + assertNull(RichTextParser.classifyMedia("https://x.com/archive.zip", "application/zip")) + assertNull(RichTextParser.classifyMedia("https://x.com/notes.txt", "text/plain")) + // No mime at all and an extension we don't render. + assertNull(RichTextParser.classifyMedia("https://x.com/file.xdc", null)) + assertNull(RichTextParser.classifyMedia("https://x.com/no-extension-at-all", null)) + } + + @Test + fun declaredMimeTypesClassify() { + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a", "image/png")) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "video/mp4")) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "audio/mpeg")) + assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("https://x.com/a", "application/pdf")) + } + + @Test + fun hlsPlaylistMimesAreVideo() { + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "application/vnd.apple.mpegurl")) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "application/x-mpegURL")) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a", "audio/mpegurl")) + } + + @Test + fun extensionIsUsedWhenMimeIsAbsent() { + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg", null)) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a.mp4", null)) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a.m3u8", null)) + assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("https://x.com/a.pdf", null)) + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.PNG", null)) + } + + @Test + fun extensionRescuesAMalformedMime() { + // Primal iOS emits `m jpeg` instead of `m image/jpeg`; the extension must still win + // over "unknown". Preserves the behaviour createMediaContent already documented. + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg", "jpeg")) + } + + @Test + fun queryStringsAndFragmentsAreStripped() { + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg?token=1", null)) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/a.mp4#t=10", null)) + assertNull(RichTextParser.classifyMedia("https://x.com/a.xdc?token=1", null)) + } + + @Test + fun dataUrisAreClassifiedByTheirPrefixOnly() { + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("data:image/png;base64,AAAA", null)) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("data:video/mp4;base64,AAAA", null)) + assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("data:application/pdf;base64,AAAA", null)) + // A data: URI carries its type in the prefix, so a miss there is genuine — the + // payload must never be extension-probed (base64 can end in any letters). + assertNull(RichTextParser.classifyMedia("data:application/zip;base64,AAAAmp4", null)) + } + + @Test + fun classifyMediaAgreesWithCreateMediaContent() { + // createMediaContent is the long-standing reference for this decision; the two must + // not drift, since half the renderers call one and half the other. + val cases = + listOf( + "https://x.com/a.jpg" to null, + "https://x.com/a" to "image/png", + "https://x.com/a" to "video/mp4", + "https://x.com/a" to "audio/mpeg", + "https://x.com/a" to "application/pdf", + "https://x.com/a" to "application/vnd.apple.mpegurl", + "https://x.com/a.xdc" to "application/x-webxdc", + "https://x.com/a.zip" to "application/zip", + "https://x.com/a.jpg" to "jpeg", + "data:image/png;base64,AAAA" to null, + "data:application/zip;base64,AAAAmp4" to null, + ) + + cases.forEach { (url, mime) -> + val tags = mime?.let { mapOf(url to imeta(url, it)) } ?: emptyMap() + val expected = + when (RichTextParser().createMediaContent(url, tags, null)) { + is MediaUrlImage -> MediaContentKind.IMAGE + is MediaUrlVideo -> MediaContentKind.VIDEO + is MediaUrlPdf -> MediaContentKind.PDF + null -> null + else -> error("unexpected content type for $url / $mime") + } + + assertEquals(expected, RichTextParser.classifyMedia(url, mime), "disagreement on $url / $mime") + } + } + + private fun imeta( + url: String, + mimeType: String, + ) = IMetaTag(url = url, properties = mapOf("m" to listOf(mimeType))) +} From 3565f75847c1de37046361525e2c7274413f2f88 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 5 Aug 2026 22:16:14 +0200 Subject: [PATCH 134/227] Code review: - gate the file-attachment card - move prettyMime to commons - add tests --- .../ui/components/FileAttachmentCard.kt | 2 +- .../amethyst/ui/note/types/SoftwareApp.kt | 22 +------- .../loggedIn/video/FileHeaderCardCompose.kt | 19 +++++-- .../amethyst/commons/util/MimeTypeLabels.kt | 52 +++++++++++++++++++ .../commons/richtext/ClassifyMediaTest.kt | 23 ++++++++ 5 files changed, 92 insertions(+), 26 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/MimeTypeLabels.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt index 759d5e7e83..ed4aba59d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt @@ -40,8 +40,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes +import com.vitorpamplona.amethyst.commons.util.prettyMime import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename -import com.vitorpamplona.amethyst.ui.note.types.prettyMime import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding import com.vitorpamplona.amethyst.ui.theme.Size20Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt index 059fa68747..8e999414f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt @@ -65,6 +65,7 @@ import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary +import com.vitorpamplona.amethyst.commons.util.prettyMime import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.Note @@ -766,27 +767,6 @@ fun RenderSoftwareAsset( } } -internal fun prettyMime(mime: String): String = - when (mime) { - "application/vnd.android.package-archive" -> "APK" - "application/vnd.apple.ipa" -> "IPA" - "application/x-apple-diskimage" -> "DMG" - "application/vnd.apple.installer+xml" -> "PKG" - "application/x-msi" -> "MSI" - "application/vnd.appimage" -> "AppImage" - "application/vnd.flatpak" -> "Flatpak" - "application/vnd.oci.image.manifest.v1+json" -> "OCI" - "application/x-executable" -> "ELF" - "application/x-mach-binary" -> "Mach-O" - "application/vnd.microsoft.portable-executable" -> "EXE" - "application/vsix" -> "VSIX" - "application/x-chrome-extension" -> "CRX" - "application/x-xpinstall" -> "XPI" - "application/wasm" -> "WASM" - "application/webbundle" -> "Web Bundle" - else -> mime - } - internal fun formatBytes(bytes: Long): String { if (bytes < 1024L) return "$bytes B" val kb = bytes / 1024.0 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt index f47613e775..d5b18976f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt @@ -105,11 +105,22 @@ private fun FileHeaderCardImage( val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) } - // VideoFeedFilter only admits image/video MIMEs and extensions, so a non-media blob should - // never reach this card. Render it as a link anyway rather than keeping an "unknown → video" - // default around: that default is what put a webxdc app into ExoPlayer in the note renderer. + // Reachable despite VideoFeedFilter admitting only image/video types: the filter accepts on + // `urls().any { … }` while this card renders `url()`, the first tag — so a multi-mirror event + // whose first URL is unrenderable lands here. The gate wraps it for the same reason it wraps + // the viewer in FileHeaderDisplay: a content warning is about the file, and the card still + // spells out its filename, alt text, MIME and size. Sizing stays on the gate's defaults + // (fillMaxWidth, no backdrop) — a link card has no aspect ratio to reserve and no blurhash + // to show behind it. if (content == null) { - FileHeaderAttachmentCard(event, fullUrl, mimeType) + ContentWarningGate( + isSensitive = isSensitive, + reasons = reasons, + preloadUrls = emptyList(), + accountViewModel = accountViewModel, + ) { + FileHeaderAttachmentCard(event, fullUrl, mimeType) + } return } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/MimeTypeLabels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/MimeTypeLabels.kt new file mode 100644 index 0000000000..1911a22019 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/MimeTypeLabels.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +/** + * The short label a user recognises for a distributable file type — "APK", not + * "application/vnd.android.package-archive". + * + * Unmapped types return the raw MIME unchanged, which is the honest fallback: a bare + * `application/x-webxdc` still tells the reader more than an invented label would. + * + * Used by the NIP-82 software-app chips and by the file-attachment card that stands in for any + * blob no viewer can render. + */ +fun prettyMime(mime: String): String = + when (mime) { + "application/vnd.android.package-archive" -> "APK" + "application/vnd.apple.ipa" -> "IPA" + "application/x-apple-diskimage" -> "DMG" + "application/vnd.apple.installer+xml" -> "PKG" + "application/x-msi" -> "MSI" + "application/vnd.appimage" -> "AppImage" + "application/vnd.flatpak" -> "Flatpak" + "application/vnd.oci.image.manifest.v1+json" -> "OCI" + "application/x-executable" -> "ELF" + "application/x-mach-binary" -> "Mach-O" + "application/vnd.microsoft.portable-executable" -> "EXE" + "application/vsix" -> "VSIX" + "application/x-chrome-extension" -> "CRX" + "application/x-xpinstall" -> "XPI" + "application/wasm" -> "WASM" + "application/webbundle" -> "Web Bundle" + else -> mime + } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt index 2482a8ae80..240d4f0baf 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt @@ -73,6 +73,29 @@ class ClassifyMediaTest { assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.PNG", null)) } + @Test + fun aDeclaredMimeBeatsAContradictingExtension() { + // The check this replaced was an OR — `mime.startsWith("image/") || isImageUrl(url)` — + // so a poster-named video URL classified as an image. A declared MIME is the publisher + // stating the type; the extension is only a guess for when they didn't. Pins the + // precedence against a future "simplification" back to OR-semantics. + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/thumb.jpg", "video/mp4")) + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/clip.mp4", "image/png")) + assertEquals(MediaContentKind.PDF, RichTextParser.classifyMedia("https://x.com/scan.png", "application/pdf")) + } + + @Test + fun anUnrecognisedMimeDefersToTheExtensionRatherThanVetoingIt() { + // Precedence applies only to MIMEs we recognise. An unrecognised one means "no usable + // declaration", not "declared unrenderable" — the two are indistinguishable here, and + // treating them alike is what lets [extensionRescuesAMalformedMime] work. So a real + // video mislabelled `application/x-webxdc` still plays… + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/bundle.mp4", "application/x-webxdc")) + // …while the webxdc app that motivated this class stays unrenderable, because nothing + // rescues it: `.xdc` is in no extension list either. + assertNull(RichTextParser.classifyMedia("https://x.com/bundle.xdc", "application/x-webxdc")) + } + @Test fun extensionRescuesAMalformedMime() { // Primal iOS emits `m jpeg` instead of `m image/jpeg`; the extension must still win From 5c18cee6ad917eb785d9bcb1f6753de6583415ed Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:29:50 +0000 Subject: [PATCH 135/227] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-cs/strings.xml | 168 ++++++++---------- .../src/main/res/values-de-rDE/strings.xml | 156 ++++++++-------- .../src/main/res/values-pt-rBR/strings.xml | 150 ++++++++-------- .../src/main/res/values-sv-rSE/strings.xml | 154 ++++++++-------- docs/changelog/translators.json | 20 +-- 5 files changed, 312 insertions(+), 336 deletions(-) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 66fa414df9..6a86477be8 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -211,6 +211,7 @@ Procento úspěšných připojení k relé Vyhledat a přidat uživatele Přidat přeposílání + Neplatná adresa relaye. Použijte název hostitele nebo IP adresu v hranatých závorkách (například [201:d0e:9ba5:8bbc::1]:8080). Moje @tag jméno Zobrazované jméno Moje zobrazované jméno @@ -2024,14 +2025,91 @@ + Procházení + Média + Hashtagy + Témata + Konverzace + Hledání + Hledání chybějících událostí + Sledování událostí + Načítá podle ID události, na které se něco na obrazovce odkazuje, ale zatím je nemáte — citaci, rodiče odpovědi, kořen vlákna. + Sleduje právě zobrazené události kvůli novým odpovědím, reakcím, sdílením, zapům a nahlášením, takže se počty aktualizují během čtení. + Doplňky + Informace o relayi + Ostatní + Vyhledávač seznamů relayů + Sledování profilů + Data účtu + Domovský zdroj + Relay skupiny + + %1$d skupina + %1$d skupiny + %1$d skupiny + %1$d skupin + + Mizící chaty + Chaty podle místa + Chat živého vysílání + Skupiny NIP-29, do kterých jste vstoupili. Každá skupina žije na jednom hostitelském relayi, takže se aplikace připojí ke každému relayi, který hostí některou vaši skupinu. + Chatovací místnosti bez historie — zprávy existují jen po dobu vašeho připojení, proto zůstávají odebírané, aby vůbec něco přišlo. + Místnosti podle polohy pro oblasti, které sledujete, dotazované na relayích, které je nesou. + Chat a zapovací cíle připojené k živým vysíláním, která máte otevřená nebo sledujete. + Schránka DM + Peněženka + Schránka nutzapů + Adresář mintů + Chaty komunit + Zdroje komunit + Vaše relaye pro příjem a k tomu malý rotující vzorek relayů, kam publikují vaši sledovaní, pro případ, že by zmínka byla doručena jinam. + Vaše relaye pro schránku DM, kam se doručují zprávy zabalené v gift-wrapu. + Domovský relay každého chatu, který máte otevřený nebo do kterého jste se připojili. + Relaye, na které každá komunita publikuje své roviny. + Skupinové zprávy a balíčky klíčů na relayích každé skupiny. + Relaye místnosti, dokud je otevřená. + Váš vlastní profil, nastavení a koncepty na vašich domovských relayích. + Profily lidí právě na obrazovce. + Zjišťuje, na které relaye každý člověk publikuje, aby se jeho příspěvky daly načíst na správném místě. + Seznamy sledovaných, ze kterých se sestavuje váš zdroj a vaše síť důvěry. + Nahlášení, která vaši sledovaní napsali o profilech právě na obrazovce, dotazovaná na každém relayi, kam tito sledovaní publikují. + Nahlášení od sledovaných + Události vaší vlastní peněženky, čtené zpět z relayů, na které jste je publikovali. + Naslouchá na vašich nutzap relayích a také na relayích pro příjem a DM, aby vám neunikla žádná platba. + Prohledává relaye, které minty existují a které lidé doporučují. + Upozornění z vaší připojené peněženky. + Aktivní odběry relayů + + %1$d filtr + %1$d filtry + %1$d filtru + %1$d filtrů + + + %1$d filtr zatím není přiřazen + %1$d filtry zatím nejsou přiřazeny + %1$d filtru zatím není přiřazeno + %1$d filtrů zatím není přiřazeno + + Nepřiřazeno k žádnému účtu + Vše + Všichni + Lidé, které sledujete + Vybraný seznam lidí + Ztlumení lidé + Vaše komunity + Oblíbený algoritmický zdroj + %1$d %% ze všech + odběry subscriptions filtry relaye relay požadavky reqs připojení proč diagnostika + Příspěvky lidí, které sledujete, čtené z relayů, na které každý z nich publikuje. Připojování k inbox relayím\u2026 Služba trvalých oznámení Udržuje trvalé připojení k vašim inbox relayím pro okamžité doručování oznámení. Zobrazuje průběžné oznámení. Spotřebovává více baterie, ale zajišťuje, že nezmeškáte žádnou zprávu. @@ -4811,94 +4889,4 @@ URL avataru (volitelné) Publikování… Publikovat personu - Vše - Oblíbený algoritmický zdroj - Vaše komunity - Vybraný seznam lidí - Lidé, které sledujete - Všichni - Ztlumení lidé - odběry subscriptions filtry relaye relay požadavky reqs připojení proč diagnostika - %1$d %% ze všech - Aktivní odběry relayů - Nepřiřazeno k žádnému účtu - Váš vlastní profil, nastavení a koncepty na vašich domovských relayích. - Relaye, na které každá komunita publikuje své roviny. - Vaše relaye pro schránku DM, kam se doručují zprávy zabalené v gift-wrapu. - Skupinové zprávy a balíčky klíčů na relayích každé skupiny. - Sleduje právě zobrazené události kvůli novým odpovědím, reakcím, sdílením, zapům a nahlášením, takže se počty aktualizují během čtení. - Chatovací místnosti bez historie — zprávy existují jen po dobu vašeho připojení, proto zůstávají odebírané, aby vůbec něco přišlo. - Seznamy sledovaných, ze kterých se sestavuje váš zdroj a vaše síť důvěry. - Místnosti podle polohy pro oblasti, které sledujete, dotazované na relayích, které je nesou. - Příspěvky lidí, které sledujete, čtené z relayů, na které každý z nich publikuje. - Chat a zapovací cíle připojené k živým vysíláním, která máte otevřená nebo sledujete. - Relaye místnosti, dokud je otevřená. - Prohledává relaye, které minty existují a které lidé doporučují. - Nahlášení, která vaši sledovaní napsali o profilech právě na obrazovce, dotazovaná na každém relayi, kam tito sledovaní publikují. - Vaše relaye pro příjem a k tomu malý rotující vzorek relayů, kam publikují vaši sledovaní, pro případ, že by zmínka byla doručena jinam. - Naslouchá na vašich nutzap relayích a také na relayích pro příjem a DM, aby vám neunikla žádná platba. - Upozornění z vaší připojené peněženky. - Profily lidí právě na obrazovce. - Domovský relay každého chatu, který máte otevřený nebo do kterého jste se připojili. - Načítá podle ID události, na které se něco na obrazovce odkazuje, ale zatím je nemáte — citaci, rodiče odpovědi, kořen vlákna. - Skupiny NIP-29, do kterých jste vstoupili. Každá skupina žije na jednom hostitelském relayi, takže se aplikace připojí ke každému relayi, který hostí některou vaši skupinu. - Zjišťuje, na které relaye každý člověk publikuje, aby se jeho příspěvky daly načíst na správném místě. - Události vaší vlastní peněženky, čtené zpět z relayů, na které jste je publikovali. - Doplňky - Procházení - Chaty komunit - Zdroje komunit - Schránka DM - Sledování událostí - Mizící chaty - Chaty podle místa - Domovský zdroj - Chat živého vysílání - Média - Adresář mintů - Schránka nutzapů - Sledování profilů - Ostatní - Hledání chybějících událostí - Relay skupiny - Informace o relayi - Vyhledávač seznamů relayů - Nahlášení od sledovaných - Hledání - Hashtagy - Konverzace - Témata - Data účtu - Peněženka - Neplatná adresa relaye. Použijte název hostitele nebo IP adresu v hranatých závorkách (například [201:d0e:9ba5:8bbc::1]:8080). - - %1$d filtr - %1$d filtry - %1$d filtru - %1$d filtrů - - - %1$d skupina - %1$d skupiny - %1$d skupiny - %1$d skupin - - - %1$d relay - %1$d relaye - %1$d relaye - %1$d relayů - - - %1$d filtr zatím není přiřazen - %1$d filtry zatím nejsou přiřazeny - %1$d filtru zatím není přiřazeno - %1$d filtrů zatím není přiřazeno - - - %1$s \u00b7 %2$d relay - %1$s \u00b7 %2$d relaye - %1$s \u00b7 %2$d relaye - %1$s \u00b7 %2$d relayů - diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 45e9c1cb5a..16abc0f2e7 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -203,6 +203,7 @@ Prozentsatz erfolgreicher Verbindungen zum Relay Benutzer suchen und hinzufügen Relay hinzufügen + Keine gültige Relay-Adresse. Verwende einen Hostnamen oder eine IP-Adresse in Klammern (zum Beispiel [201:d0e:9ba5:8bbc::1]:8080). Mein @tag-Name Anzeigename Mein Anzeigename @@ -1942,14 +1943,91 @@ + + %1$s \u00b7 %2$d Relay + %1$s \u00b7 %2$d Relays + + Stöbern + Medien + Themen + Unterhaltung + Suche + Fehlende Events finden + Events beobachten + Holt Events per ID, auf die etwas auf deinem Bildschirm verweist, die du aber noch nicht hast — ein Zitat, die übergeordnete Antwort, eine Thread-Wurzel. + Beobachtet die gerade angezeigten Events auf neue Antworten, Reaktionen, Reposts, Zaps und Meldungen, damit die Zähler beim Lesen aktuell bleiben. + Erweiterungen + Relay-Info + Sonstiges + Relay-Listen-Finder + Profile beobachten + Kontodaten + Startseiten-Feed + Relay-Gruppen + + %1$d Gruppe + %1$d Gruppen + + Verschwindende Chats + Standort-Chats + Live-Stream-Chat + NIP-29-Gruppen, denen du beigetreten bist. Jede Gruppe liegt auf einem Host-Relay, daher verbindet sich die App mit jedem Relay, das eine deiner Gruppen beherbergt. + Chaträume ohne Verlauf — Nachrichten existieren nur, solange du verbunden bist, deshalb bleiben sie abonniert, damit überhaupt etwas ankommt. + Standortbasierte Räume für die Gebiete, denen du folgst, abgefragt bei den Relays, die sie führen. + Chat und Zap-Ziele, die an Live-Streams hängen, die du geöffnet hast oder denen du folgst. + DM-Posteingang + Nutzap-Posteingang + Mint-Verzeichnis + Community-Chats + Community-Feeds + Deine Posteingangs-Relays plus eine kleine, rotierende Stichprobe der Relays, auf denen deine Gefolgten veröffentlichen, falls eine Erwähnung woanders zugestellt wurde. + Deine DM-Posteingangs-Relays, an die Gift-Wrap-Nachrichten zugestellt werden. + Das Heim-Relay jedes Chats, den du geöffnet hast oder dem du beigetreten bist. + Die Relays, auf denen jede Community ihre Planes veröffentlicht. + Gruppennachrichten und Schlüsselpakete auf den Relays der jeweiligen Gruppe. + Die Relays des Raums, solange er geöffnet ist. + Dein eigenes Profil, deine Einstellungen und Entwürfe auf deinen Heim-Relays. + Profile der gerade angezeigten Personen. + Findet heraus, auf welchen Relays jede Person veröffentlicht, damit ihre Beiträge an der richtigen Stelle abgerufen werden können. + Folgelisten, aus denen dein Feed und dein Web of Trust aufgebaut werden. + Meldungen, die deine Gefolgten über die gerade angezeigten Profile geschrieben haben, abgefragt bei jedem Relay, auf dem diese Gefolgten veröffentlichen. + Meldungen von Gefolgten + Deine eigenen Wallet-Events, zurückgelesen von den Relays, auf denen du sie veröffentlicht hast. + Lauscht auf deinen Nutzap-Relays sowie deinen Posteingangs- und DM-Relays, damit keine Zahlung durchrutscht. + Sucht über Relays hinweg, welche Mints existieren und welche empfohlen werden. + Benachrichtigungen von deiner verbundenen Wallet. + Aktive Relay-Abonnements + + %1$d Filter + %1$d Filter + + + %1$d Relay + %1$d Relays + + + %1$d Filter ist noch nicht zugeordnet + %1$d Filter sind noch nicht zugeordnet + + Keinem Konto zugeordnet + Alle + Jeder + Personen, denen du folgst + Eine ausgewählte Liste von Personen + Stummgeschaltete Personen + Deine Communitys + Ein bevorzugter Feed-Algorithmus + %1$d %% von allen + abonnements subscriptions filter relays anfragen reqs verbindungen warum diagnose + Beiträge von Personen, denen du folgst, gelesen von den Relays, auf denen jede von ihnen veröffentlicht. Verbinde mit Inbox-Relays\u2026 Dauerhafter Benachrichtigungsdienst Hält eine dauerhafte Verbindung zu deinen Inbox-Relays für sofortige Benachrichtigungen aufrecht. Zeigt eine fortlaufende Benachrichtigung an. Verbraucht mehr Akku, stellt aber sicher, dass du keine Nachricht verpasst. @@ -4635,82 +4713,4 @@ Avatar-URL (optional) Wird veröffentlicht… Persona veröffentlichen - Alle - Ein bevorzugter Feed-Algorithmus - Deine Communitys - Eine ausgewählte Liste von Personen - Personen, denen du folgst - Jeder - Stummgeschaltete Personen - abonnements subscriptions filter relays anfragen reqs verbindungen warum diagnose - %1$d %% von allen - Aktive Relay-Abonnements - Keinem Konto zugeordnet - Dein eigenes Profil, deine Einstellungen und Entwürfe auf deinen Heim-Relays. - Die Relays, auf denen jede Community ihre Planes veröffentlicht. - Deine DM-Posteingangs-Relays, an die Gift-Wrap-Nachrichten zugestellt werden. - Gruppennachrichten und Schlüsselpakete auf den Relays der jeweiligen Gruppe. - Beobachtet die gerade angezeigten Events auf neue Antworten, Reaktionen, Reposts, Zaps und Meldungen, damit die Zähler beim Lesen aktuell bleiben. - Chaträume ohne Verlauf — Nachrichten existieren nur, solange du verbunden bist, deshalb bleiben sie abonniert, damit überhaupt etwas ankommt. - Folgelisten, aus denen dein Feed und dein Web of Trust aufgebaut werden. - Standortbasierte Räume für die Gebiete, denen du folgst, abgefragt bei den Relays, die sie führen. - Beiträge von Personen, denen du folgst, gelesen von den Relays, auf denen jede von ihnen veröffentlicht. - Chat und Zap-Ziele, die an Live-Streams hängen, die du geöffnet hast oder denen du folgst. - Die Relays des Raums, solange er geöffnet ist. - Sucht über Relays hinweg, welche Mints existieren und welche empfohlen werden. - Meldungen, die deine Gefolgten über die gerade angezeigten Profile geschrieben haben, abgefragt bei jedem Relay, auf dem diese Gefolgten veröffentlichen. - Deine Posteingangs-Relays plus eine kleine, rotierende Stichprobe der Relays, auf denen deine Gefolgten veröffentlichen, falls eine Erwähnung woanders zugestellt wurde. - Lauscht auf deinen Nutzap-Relays sowie deinen Posteingangs- und DM-Relays, damit keine Zahlung durchrutscht. - Benachrichtigungen von deiner verbundenen Wallet. - Profile der gerade angezeigten Personen. - Das Heim-Relay jedes Chats, den du geöffnet hast oder dem du beigetreten bist. - Holt Events per ID, auf die etwas auf deinem Bildschirm verweist, die du aber noch nicht hast — ein Zitat, die übergeordnete Antwort, eine Thread-Wurzel. - NIP-29-Gruppen, denen du beigetreten bist. Jede Gruppe liegt auf einem Host-Relay, daher verbindet sich die App mit jedem Relay, das eine deiner Gruppen beherbergt. - Findet heraus, auf welchen Relays jede Person veröffentlicht, damit ihre Beiträge an der richtigen Stelle abgerufen werden können. - Deine eigenen Wallet-Events, zurückgelesen von den Relays, auf denen du sie veröffentlicht hast. - Erweiterungen - Stöbern - Community-Chats - Community-Feeds - DM-Posteingang - Events beobachten - Verschwindende Chats - Standort-Chats - Startseiten-Feed - Live-Stream-Chat - Medien - Mint-Verzeichnis - Nutzap-Posteingang - Profile beobachten - Sonstiges - Fehlende Events finden - Relay-Gruppen - Relay-Info - Relay-Listen-Finder - Meldungen von Gefolgten - Suche - Unterhaltung - Themen - Kontodaten - Keine gültige Relay-Adresse. Verwende einen Hostnamen oder eine IP-Adresse in Klammern (zum Beispiel [201:d0e:9ba5:8bbc::1]:8080). - - %1$d Filter - %1$d Filter - - - %1$d Gruppe - %1$d Gruppen - - - %1$d Relay - %1$d Relays - - - %1$d Filter ist noch nicht zugeordnet - %1$d Filter sind noch nicht zugeordnet - - - %1$s \u00b7 %2$d Relay - %1$s \u00b7 %2$d Relays - diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 17b4737653..33467f8882 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -203,6 +203,7 @@ Porcentagem de conexões bem-sucedidas ao relay Pesquisar e adicionar usuário Adicionar um Relay + Endereço de relay inválido. Use um nome de host ou um endereço IP entre colchetes (por exemplo [201:d0e:9ba5:8bbc::1]:8080). Meu nome de @tag Nome de Exibição Meu nome de exibição @@ -1940,14 +1941,84 @@ + Navegação + Mídia + Tópicos + Conversa + Pesquisa + Encontrando eventos faltantes + Observando eventos + Busca por id os eventos a que algo na sua tela se refere, mas que você ainda não tem — uma citação, o pai de uma resposta, a raiz de uma conversa. + Observa os eventos exibidos no momento em busca de novas respostas, reações, repostagens, zaps e denúncias, para que as contagens sejam atualizadas enquanto você lê. + Complementos + Informações do relay + Outros + Localizador de listas de relays + Observando perfis + Dados da conta + Feed inicial + Grupos de relay + + %1$d grupo + %1$d grupos + + Chats efêmeros + Chats por localização + Chat de transmissão ao vivo + Grupos NIP-29 dos quais você participa. Cada grupo vive em um relay hospedeiro, então o app se conecta a todo relay que hospeda um grupo seu. + Salas de chat que não guardam histórico — as mensagens existem apenas enquanto você está conectado, então elas continuam assinadas para que algo chegue. + Salas baseadas em localização para as áreas que você segue, consultadas nos relays que as hospedam. + Chat e metas de zap ligados às transmissões ao vivo que você tem abertas ou segue. + Caixa de entrada de DM + Carteira + Caixa de entrada de nutzaps + Diretório de mints + Chats de comunidades + Feeds de comunidades + Os relays da sua caixa de entrada, mais uma pequena amostra rotativa dos relays em que quem você segue publica, caso uma menção tenha sido entregue em outro lugar. + Os relays da sua caixa de entrada de DM, para onde as mensagens em gift wrap são entregues. + O relay de origem de cada chat que você abriu ou do qual participa. + Os relays em que cada comunidade publica seus planos. + Mensagens de grupo e pacotes de chaves, nos relays de cada grupo. + Os relays da sala, enquanto ela estiver aberta. + Seu próprio perfil, configurações e rascunhos, nos seus relays de origem. + Perfis das pessoas atualmente na tela. + Descobre em quais relays cada pessoa publica, para que as publicações dela possam ser buscadas no lugar certo. + Listas de seguindo, usadas para montar seu feed e sua rede de confiança. + Denúncias que as pessoas que você segue escreveram sobre os perfis atualmente na sua tela, consultadas em cada relay em que essas pessoas publicam. + Denúncias de quem você segue + Os eventos da sua própria carteira, lidos de volta dos relays em que você os publicou. + Escuta nos seus relays de nutzap, além dos relays da caixa de entrada e de DM, para que nenhum pagamento passe despercebido. + Procura pelos relays quais mints existem e quais as pessoas recomendam. + Notificações da sua carteira conectada. + Assinaturas de relay ativas + + %1$d filtro + %1$d filtros + + + %1$d filtro ainda não foi atribuído + %1$d filtros ainda não foram atribuídos + + Não atribuído a nenhuma conta + Tudo + Todos + Pessoas que você segue + Uma lista escolhida de pessoas + Pessoas silenciadas + Suas comunidades + Um algoritmo de feed favorito + %1$d%% de todos + assinaturas subscriptions filtros relays requisições reqs conexões por que diagnóstico + Publicações de pessoas que você segue, lidas dos relays em que cada uma delas publica. Conectando aos relays de caixa de entrada\u2026 Serviço de notificações sempre ativo Mantém uma conexão persistente com seus relays de caixa de entrada para entrega instantânea de notificações. Mostra uma notificação contínua. Usa mais bateria, mas garante que você nunca perca uma mensagem. @@ -4640,83 +4711,4 @@ URL do avatar (opcional) Publicando… Publicar persona - Tudo - Um algoritmo de feed favorito - Suas comunidades - Uma lista escolhida de pessoas - Pessoas que você segue - Todos - Pessoas silenciadas - assinaturas subscriptions filtros relays requisições reqs conexões por que diagnóstico - %1$d%% de todos - Assinaturas de relay ativas - Não atribuído a nenhuma conta - Seu próprio perfil, configurações e rascunhos, nos seus relays de origem. - Os relays em que cada comunidade publica seus planos. - Os relays da sua caixa de entrada de DM, para onde as mensagens em gift wrap são entregues. - Mensagens de grupo e pacotes de chaves, nos relays de cada grupo. - Observa os eventos exibidos no momento em busca de novas respostas, reações, repostagens, zaps e denúncias, para que as contagens sejam atualizadas enquanto você lê. - Salas de chat que não guardam histórico — as mensagens existem apenas enquanto você está conectado, então elas continuam assinadas para que algo chegue. - Listas de seguindo, usadas para montar seu feed e sua rede de confiança. - Salas baseadas em localização para as áreas que você segue, consultadas nos relays que as hospedam. - Publicações de pessoas que você segue, lidas dos relays em que cada uma delas publica. - Chat e metas de zap ligados às transmissões ao vivo que você tem abertas ou segue. - Os relays da sala, enquanto ela estiver aberta. - Procura pelos relays quais mints existem e quais as pessoas recomendam. - Denúncias que as pessoas que você segue escreveram sobre os perfis atualmente na sua tela, consultadas em cada relay em que essas pessoas publicam. - Os relays da sua caixa de entrada, mais uma pequena amostra rotativa dos relays em que quem você segue publica, caso uma menção tenha sido entregue em outro lugar. - Escuta nos seus relays de nutzap, além dos relays da caixa de entrada e de DM, para que nenhum pagamento passe despercebido. - Notificações da sua carteira conectada. - Perfis das pessoas atualmente na tela. - O relay de origem de cada chat que você abriu ou do qual participa. - Busca por id os eventos a que algo na sua tela se refere, mas que você ainda não tem — uma citação, o pai de uma resposta, a raiz de uma conversa. - Grupos NIP-29 dos quais você participa. Cada grupo vive em um relay hospedeiro, então o app se conecta a todo relay que hospeda um grupo seu. - Descobre em quais relays cada pessoa publica, para que as publicações dela possam ser buscadas no lugar certo. - Os eventos da sua própria carteira, lidos de volta dos relays em que você os publicou. - Complementos - Navegação - Chats de comunidades - Feeds de comunidades - Caixa de entrada de DM - Observando eventos - Chats efêmeros - Chats por localização - Feed inicial - Chat de transmissão ao vivo - Mídia - Diretório de mints - Caixa de entrada de nutzaps - Observando perfis - Outros - Encontrando eventos faltantes - Grupos de relay - Informações do relay - Localizador de listas de relays - Denúncias de quem você segue - Pesquisa - Conversa - Tópicos - Dados da conta - Carteira - Endereço de relay inválido. Use um nome de host ou um endereço IP entre colchetes (por exemplo [201:d0e:9ba5:8bbc::1]:8080). - - %1$d filtro - %1$d filtros - - - %1$d grupo - %1$d grupos - - - %1$d relay - %1$d relays - - - %1$d filtro ainda não foi atribuído - %1$d filtros ainda não foram atribuídos - - - %1$s \u00b7 %2$d relay - %1$s \u00b7 %2$d relays - diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 085d398a4c..f39e33d025 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -203,6 +203,7 @@ Andel lyckade anslutningar till reläet Sök och lägg till användare Lägg till Relä + Inte en giltig reläadress. Använd ett värdnamn eller en IP-adress inom hakparenteser (till exempel [201:d0e:9ba5:8bbc::1]:8080). Mitt @tag-namn Visningsnamn Mitt visningsnamn @@ -1940,14 +1941,88 @@ + + %1$s \u00b7 %2$d relä + %1$s \u00b7 %2$d reläer + + Bläddring + Hashtaggar + Ämnen + Konversation + Sök + Hittar saknade händelser + Observerar händelser + Hämtar händelser via id som något på din skärm hänvisar till men som du inte har ännu — ett citat, ett svars förälder, en trådrot. + Bevakar de händelser som visas just nu efter nya svar, reaktioner, återinlägg, zaps och rapporter, så att räknarna uppdateras medan du läser. + Tillägg + Reläinfo + Övrigt + Relälistsökare + Observerar profiler + Kontots data + Hemflöde + Relägrupper + + %1$d grupp + %1$d grupper + + Försvinnande chattar + Platschattar + Livesändningschatt + NIP-29-grupper du gått med i. Varje grupp bor på ett värdrelä, så appen ansluter till varje relä som är värd för en av dina grupper. + Chattrum som inte sparar någon historik — meddelanden finns bara medan du är ansluten, så dessa förblir prenumererade för att något alls ska komma fram. + Platsbaserade rum för de områden du följer, efterfrågade från de reläer som bär dem. + Chatt och zap-mål kopplade till livesändningar du har öppna eller följer. + DM-inkorg + Plånbok + Nutzap-inkorg + Mint-katalog + Gemenskapschattar + Gemenskapsflöden + Dina inkorgsreläer, plus ett litet roterande urval av de reläer personer du följer publicerar till, ifall ett omnämnande levererades någon annanstans. + Dina DM-inkorgsreläer, dit gift-wrap-meddelanden levereras. + Hemrelät för varje chatt du har öppen eller har gått med i. + Reläerna som varje gemenskap publicerar sina plan till. + Gruppmeddelanden och nyckelpaket, på varje grupps reläer. + Rummets reläer, medan det är öppet. + Din egen profil, dina inställningar och utkast, på dina hemreläer. + Profiler för personerna som just nu visas på skärmen. + Hittar vilka reläer varje person publicerar till, så att deras inlägg kan hämtas från rätt ställe. + Följerlistor, som används för att bygga ditt flöde och ditt förtroendenät. + Rapporter som personer du följer har skrivit om profilerna som just nu visas på skärmen, efterfrågade från varje relä dessa personer publicerar till. + Rapporter från personer du följer + Dina egna plånbokshändelser, lästa tillbaka från de reläer du publicerade dem till. + Lyssnar på dina nutzap-reläer plus dina inkorgs- och DM-reläer, så att en betalning inte kan slinka förbi. + Söker över reläer efter vilka mints som finns och vilka folk rekommenderar. + Aviseringar från din anslutna plånbok. + Aktiva reläprenumerationer + + %1$d relä + %1$d reläer + + + %1$d filter är inte kopplat ännu + %1$d filter är inte kopplade ännu + + Inte kopplat till något konto + Allt + Alla + Personer du följer + En vald lista med personer + Tystade personer + Dina gemenskaper + En favorit-flödesalgoritm + %1$d %% av alla + prenumerationer subscriptions filter reläer relay förfrågningar reqs anslutningar varför diagnostik + Inlägg från personer du följer, lästa från de reläer var och en av dem publicerar till. Ansluter till inbox-relän\u2026 Alltid på-notifieringstjänst Upprätthåller en konstant anslutning till dina inbox-relän för omedelbar leverans av notifieringar. Visar en pågående notifiering. Använder mer batteri men säkerställer att du aldrig missar ett meddelande. @@ -4643,83 +4718,4 @@ Avatar-URL (valfritt) Publicerar… Publicera persona - Allt - En favorit-flödesalgoritm - Dina gemenskaper - En vald lista med personer - Personer du följer - Alla - Tystade personer - prenumerationer subscriptions filter reläer relay förfrågningar reqs anslutningar varför diagnostik - %1$d %% av alla - Aktiva reläprenumerationer - Inte kopplat till något konto - Din egen profil, dina inställningar och utkast, på dina hemreläer. - Reläerna som varje gemenskap publicerar sina plan till. - Dina DM-inkorgsreläer, dit gift-wrap-meddelanden levereras. - Gruppmeddelanden och nyckelpaket, på varje grupps reläer. - Bevakar de händelser som visas just nu efter nya svar, reaktioner, återinlägg, zaps och rapporter, så att räknarna uppdateras medan du läser. - Chattrum som inte sparar någon historik — meddelanden finns bara medan du är ansluten, så dessa förblir prenumererade för att något alls ska komma fram. - Följerlistor, som används för att bygga ditt flöde och ditt förtroendenät. - Platsbaserade rum för de områden du följer, efterfrågade från de reläer som bär dem. - Inlägg från personer du följer, lästa från de reläer var och en av dem publicerar till. - Chatt och zap-mål kopplade till livesändningar du har öppna eller följer. - Rummets reläer, medan det är öppet. - Söker över reläer efter vilka mints som finns och vilka folk rekommenderar. - Rapporter som personer du följer har skrivit om profilerna som just nu visas på skärmen, efterfrågade från varje relä dessa personer publicerar till. - Dina inkorgsreläer, plus ett litet roterande urval av de reläer personer du följer publicerar till, ifall ett omnämnande levererades någon annanstans. - Lyssnar på dina nutzap-reläer plus dina inkorgs- och DM-reläer, så att en betalning inte kan slinka förbi. - Aviseringar från din anslutna plånbok. - Profiler för personerna som just nu visas på skärmen. - Hemrelät för varje chatt du har öppen eller har gått med i. - Hämtar händelser via id som något på din skärm hänvisar till men som du inte har ännu — ett citat, ett svars förälder, en trådrot. - NIP-29-grupper du gått med i. Varje grupp bor på ett värdrelä, så appen ansluter till varje relä som är värd för en av dina grupper. - Hittar vilka reläer varje person publicerar till, så att deras inlägg kan hämtas från rätt ställe. - Dina egna plånbokshändelser, lästa tillbaka från de reläer du publicerade dem till. - Tillägg - Bläddring - Gemenskapschattar - Gemenskapsflöden - DM-inkorg - Observerar händelser - Försvinnande chattar - Platschattar - Hemflöde - Livesändningschatt - Mint-katalog - Nutzap-inkorg - Observerar profiler - Övrigt - Hittar saknade händelser - Relägrupper - Reläinfo - Relälistsökare - Rapporter från personer du följer - Sök - Hashtaggar - Konversation - Ämnen - Kontots data - Plånbok - Inte en giltig reläadress. Använd ett värdnamn eller en IP-adress inom hakparenteser (till exempel [201:d0e:9ba5:8bbc::1]:8080). - - %1$d filter - %1$d filter - - - %1$d grupp - %1$d grupper - - - %1$d relä - %1$d reläer - - - %1$d filter är inte kopplat ännu - %1$d filter är inte kopplade ännu - - - %1$s \u00b7 %2$d relä - %1$s \u00b7 %2$d reläer - diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 87f8b8a5ae..ef7dced2fc 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -90,6 +90,16 @@ "Hungarian" ] }, + { + "user": "vitorpamplona", + "languages": [ + "Czech", + "German", + "Polish", + "Portuguese, Brazilian", + "Swedish" + ] + }, { "user": "maxblake2015", "languages": [ @@ -102,16 +112,6 @@ "Hindi" ] }, - { - "user": "vitorpamplona", - "languages": [ - "Czech", - "German", - "Polish", - "Portuguese, Brazilian", - "Swedish" - ] - }, { "user": "greenart7c3", "languages": [] From cd2ce05ee8b0c2a7f3faf4ff91cf673dc64a6425 Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Wed, 5 Aug 2026 15:45:39 +1000 Subject: [PATCH 136/227] ci: publish windows-arm64 desktop + windows amy/geode release assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the linux-arm64 CI leg (feat/release-linux-arm64). Extends the release matrix to Windows in three places, all on free public-repo hosted GitHub runners: * build-desktop: adds windows-11-arm (arm64) alongside the existing windows-latest (x64). jpackage/jlink on Windows arm64 produce arm64 MSIs natively; the same packageReleaseMsi + createReleaseDistributable task list is used unchanged and the portable-archive step already parameterises on ${{ matrix.arch }}. * build-cli: adds windows-latest (x64) and windows-11-arm (arm64) legs running :cli:amyImage. Windows has no jpackageDeb/Rpm and MSI-for-CLI is deferred (portable zip is the documented Windows install path); the headless-lib assertion runs unchanged under git-bash. amyImage now emits both a POSIX `bin/amy` shell launcher AND a Windows `bin/amy.bat` launcher into the flat image so the tree layout is uniform regardless of build host. The .bat pins UTF-8 (chcp 65001) for sun.jnu.encoding, same reason the installDist .bat was already patched. * build-geode: adds windows-latest + windows-11-arm legs running :geode:geodeImage. The existing --port smoke test is generalised to pick bin/geode.bat on Windows; NIP-11 fetch via curl works unchanged under git-bash on GH windows runners. Same dual-launcher pattern as amy. scripts/asset-name.sh: collect_cli_assets and collect_geode_assets now package the flat image as .zip on Windows (7z when available, falling back to `zip`, then a portable python3 zipfile.ZipFile invocation). Every other OS continues to use tar.gz. Adds the expected Windows examples to the header block. BUILDING.md: mentions the windows-11-arm runner and updates the asset count in the Release runbook. No asset-naming contract changes — the existing amethyst-desktop--windows-., amy--windows-.zip, and geode--windows-.zip shapes were already in scope, they just weren't produced by any CI leg before. Local validation on macOS arm64 (build host: JDK 21, gradle 9.5.0): ./gradlew :cli:amyImage -> bin/amy + bin/amy.bat both present ./gradlew :geode:geodeImage -> bin/geode + bin/geode.bat both present ./bin/amy --help -> parses (unix launcher unbroken) ./bin/geode --port 17447 -> NIP-11 served, "supported_nips" present collect_cli_assets windows arm64 ... -> valid .zip with bin/amy.bat collect_geode_assets windows x64 ... -> valid .zip with bin/geode.bat actionlint .github/workflows/create-release.yml -> no new findings Cross-compile is impossible for jlink/jpackage, so end-to-end Windows-runtime validation still happens on GH CI on the first PR build; nothing in this change can be verified any harder locally. --- .github/workflows/create-release.yml | 49 +++++++++++---- BUILDING.md | 19 +++--- cli/build.gradle.kts | 37 ++++++++++-- geode/build.gradle.kts | 30 ++++++++-- scripts/asset-name.sh | 89 +++++++++++++++++++++++----- 5 files changed, 185 insertions(+), 39 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 0dfea9bfcd..ec8dd01ac0 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -44,11 +44,14 @@ jobs: matrix: # Linux legs run on x64 and arm64 GitHub-hosted runners (the # ubuntu-24.04-arm label is a standard free public-repo runner as of - # early 2025). jpackage / jlink / Compose Multiplatform 1.11 all - # produce host-native artifacts — no cross-compilation needed. + # early 2025). Windows arm64 uses windows-11-arm, added to the free + # public-repo runner catalogue in 2025 (4 vCPU / 16 GB / arm64). + # jpackage / jlink / Compose Multiplatform 1.11 all produce + # host-native artifacts — no cross-compilation needed. include: - { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" } - { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" } + - { os: windows-11-arm, arch: arm64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" } - { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } - { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" } @@ -369,9 +372,17 @@ jobs: fail-fast: false matrix: include: - - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } - - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } - - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + - { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" } + # Windows legs: only amyImage. .deb/.rpm are Linux-only jpackage types + # and jpackageMsi for a CLI is deferred (the portable zip is the + # documented Windows install path). The launcher script writes both + # `bin/amy` (sh) and `bin/amy.bat`, and the assertion below runs + # under bash on GH windows runners (git-bash is on PATH). collect_cli_assets + # zips the image on Windows instead of tar.gz. + - { os: windows-latest, arch: x64, family: windows, tasks: "amyImage" } + - { os: windows-11-arm, arch: arm64, family: windows, tasks: "amyImage" } runs-on: ${{ matrix.os }} timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image defaults: @@ -619,9 +630,16 @@ jobs: fail-fast: false matrix: include: - - { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" } - - { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } - - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } + - { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" } + - { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } + - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" } + # Windows legs: geodeImage only. The .deb/.rpm are Linux-only; MSI is + # deferred (portable zip covers the primary use — operators still + # deploy geode via the Docker image or the tarball on Linux). The + # image writes both `bin/geode` (sh) and `bin/geode.bat`, and the + # smoke test below runs under bash on the windows runner. + - { os: windows-latest, arch: x64, family: windows, tasks: "geodeImage" } + - { os: windows-11-arm, arch: arm64, family: windows, tasks: "geodeImage" } runs-on: ${{ matrix.os }} timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image defaults: @@ -676,12 +694,23 @@ jobs: # module list is complete for the real relay path (Ktor CIO + SQLite + # NIP-11 serialization) — a too-tight module list links fine but fails # here with NoClassDefFound instead of on an operator's machine. + # + # On the Windows legs we invoke bin/geode.bat instead of bin/geode. The + # tmp path also differs between git-bash on Windows (which resolves /tmp + # to a mingw path that curl -o accepts) and POSIX runners; kept identical + # because the workflow's `defaults.run.shell: bash` uses git-bash on + # Windows and /tmp is a valid mingw path there. - name: Smoke-test the geode image run: | set -euo pipefail IMG="geode/build/geode-image/geode" - "$IMG/bin/geode" --version - "$IMG/bin/geode" --port 17447 & + if [[ "${{ matrix.family }}" == "windows" ]]; then + LAUNCHER="$IMG/bin/geode.bat" + else + LAUNCHER="$IMG/bin/geode" + fi + "$LAUNCHER" --version + "$LAUNCHER" --port 17447 & PID=$! ok=0 for i in $(seq 1 20); do diff --git a/BUILDING.md b/BUILDING.md index b931926c00..680d4caad3 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -35,7 +35,9 @@ All platforms: Platform-specific: - **macOS**: Xcode Command Line Tools (`xcode-select --install`) -- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset` +- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`. + Windows arm64 builds run on the free public-repo `windows-11-arm` GitHub runner — + jpackage on Windows arm64 produces arm64 MSIs natively; no cross-compilation. - **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; `appimagetool` + `desktop-file-utils` for AppImage; `flatpak` + `flatpak-builder` for the Flatpak bundle (see @@ -328,14 +330,17 @@ Quartz library in one pipeline. 3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min). -4. **Verify** — the GH Release should hold **31 assets**: - - **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`, - `AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS - DMG** — `jpackage` cannot cross-compile and no Intel runner leg is - configured, so macOS ships arm64-only. +4. **Verify** — the GH Release should hold **37 assets**: + - **10 desktop** — `dmg` (macOS arm64); `msi` + `zip` per Windows arch + (x64 and arm64, 4 files); `deb`, `rpm`, `AppImage`, `flatpak`, `tar.gz` + for Linux x64+arm64 (5 formats × 2 arches shipped as one merged set of + 5 in the current layout — see the previous release for the exact + enumeration). There is **no Intel/x64 macOS DMG** — `jpackage` cannot + cross-compile and no Intel runner leg is configured, so macOS ships + arm64-only. - **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid `.apks` set built for Accrescent. - - **5 amy** + **5 geode** bundles. + - **7 amy** + **7 geode** bundles (5 unix + 2 Windows portable zips each). - Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset) - Android flow unchanged diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index d92227363e..6aa88d026f 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -254,7 +254,14 @@ val jlinkRuntime = } // Flat app-image: bin/amy launcher + lib/*.jar + runtime/ (the jlink'd JRE). -// Cross-platform — the release workflow tars this up on every OS. +// Cross-platform — the release workflow archives this on every OS (tar.gz on +// unix, zip on Windows). We write BOTH a POSIX `amy` shell launcher AND a +// Windows `amy.bat` launcher into `bin/` unconditionally so the same tree is +// runnable on any target after extraction, regardless of which OS built it. +// (The bundled jlink runtime is host-native — you still need to unzip a +// Windows-built image on Windows to actually launch it — but the launcher +// scripts themselves are host-agnostic, which keeps the layout uniform and +// makes ad-hoc cross-machine inspection painless.) val amyImage = tasks.register("amyImage") { group = "distribution" @@ -282,13 +289,35 @@ val amyImage = DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)" exec "${'$'}DIR/runtime/bin/java" -Djava.awt.headless=true -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@" """.trimIndent() + "\n" + // Windows launcher. Uses %~dp0 (drive+path of this .bat, always ending in + // a backslash) so it resolves the app root without depending on CWD, then + // execs the bundled JRE against lib\*. `chcp 65001` pins the console to + // UTF-8 so `sun.jnu.encoding` isn't the OS OEM code page — same rationale + // as the installDist launcher patch above. CRLF line endings so cmd.exe + // parses it correctly. + // + // The `for %%i in (...) do set DIR=%%~fi` trick canonicalises `\bin\..` + // out of DIR to the parent directory — same idiom Gradle's own + // installDist .bat uses to resolve APP_HOME. Java tolerates the `..` + // segment but canonicalising once here keeps every classpath entry and + // error message clean (and matches the loose-directory layout users see + // after unzipping the release archive). + val windowsLauncher = + "@echo off\r\n" + + "chcp 65001 > NUL 2>&1\r\n" + + "setlocal\r\n" + + "set \"DIR=%~dp0..\"\r\n" + + "for %%i in (\"%DIR%\") do set \"DIR=%%~fi\"\r\n" + + "\"%DIR%\\runtime\\bin\\java.exe\" -Djava.awt.headless=true -cp \"%DIR%\\lib\\*\" $mainClass %*\r\n" doLast { val binDir = amyImageDir.get().asFile.resolve("bin") binDir.mkdirs() - val launcher = binDir.resolve("amy") - launcher.writeText(unixLauncher) - launcher.setExecutable(true, false) + val unix = binDir.resolve("amy") + unix.writeText(unixLauncher) + unix.setExecutable(true, false) + val windows = binDir.resolve("amy.bat") + windows.writeText(windowsLauncher) } } diff --git a/geode/build.gradle.kts b/geode/build.gradle.kts index 70254af99c..2c8efa600f 100644 --- a/geode/build.gradle.kts +++ b/geode/build.gradle.kts @@ -236,7 +236,9 @@ val jlinkRuntime = // Flat app-image: bin/geode launcher + lib/*.jar + runtime/ (the jlink'd JRE) + // share/geode/ (config.example.toml + the systemd unit). Cross-platform — the -// release workflow tars this up on every OS. +// release workflow archives it on every OS (tar.gz on unix, zip on Windows). +// Both a POSIX shell launcher and a Windows .bat launcher are written so the +// tree layout is uniform regardless of build host. val geodeImage = tasks.register("geodeImage") { group = "distribution" @@ -271,13 +273,33 @@ val geodeImage = DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)" exec "${'$'}DIR/runtime/bin/java" -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@" """.trimIndent() + "\n" + // Windows launcher: %~dp0 anchors on the .bat's own directory (drive+ + // path, always trailing backslash) so geode.bat works no matter where + // it's invoked from. CRLF for cmd.exe. We do NOT force UTF-8 here — the + // relay is a network daemon that logs and speaks JSON over sockets, and + // its stdout is machine-readable; leaving the console code page alone + // matches the geode launcher on POSIX which similarly doesn't touch + // LANG. + // + // The `for %%i in (...) do set DIR=%%~fi` trick canonicalises `\bin\..` + // out of DIR to the parent directory — same idiom Gradle's own + // installDist .bat uses to resolve APP_HOME. See the matching comment on + // the amy launcher for the rationale (log cleanliness, not correctness). + val windowsLauncher = + "@echo off\r\n" + + "setlocal\r\n" + + "set \"DIR=%~dp0..\"\r\n" + + "for %%i in (\"%DIR%\") do set \"DIR=%%~fi\"\r\n" + + "\"%DIR%\\runtime\\bin\\java.exe\" -cp \"%DIR%\\lib\\*\" $mainClass %*\r\n" doLast { val binDir = geodeImageDir.get().asFile.resolve("bin") binDir.mkdirs() - val launcher = binDir.resolve("geode") - launcher.writeText(unixLauncher) - launcher.setExecutable(true, false) + val unix = binDir.resolve("geode") + unix.writeText(unixLauncher) + unix.setExecutable(true, false) + val windows = binDir.resolve("geode.bat") + windows.writeText(windowsLauncher) } } diff --git a/scripts/asset-name.sh b/scripts/asset-name.sh index ee7b2bda98..100e7247b2 100755 --- a/scripts/asset-name.sh +++ b/scripts/asset-name.sh @@ -24,6 +24,8 @@ # amethyst-desktop-1.08.0-macos-arm64.dmg # amethyst-desktop-1.08.0-windows-x64.msi # amethyst-desktop-1.08.0-windows-x64.zip +# amethyst-desktop-1.08.0-windows-arm64.msi +# amethyst-desktop-1.08.0-windows-arm64.zip # amethyst-desktop-1.08.0-linux-x64.deb # amethyst-desktop-1.08.0-linux-x64.rpm # amethyst-desktop-1.08.0-linux-x64.AppImage @@ -42,6 +44,8 @@ # amy-1.08.0-linux-arm64.tar.gz # amy-1.08.0-linux-arm64.deb # amy-1.08.0-linux-arm64.rpm +# amy-1.08.0-windows-x64.zip +# amy-1.08.0-windows-arm64.zip # geode-1.08.0-macos-arm64.tar.gz # geode-1.08.0-linux-x64.tar.gz # geode-1.08.0-linux-x64.deb @@ -49,6 +53,8 @@ # geode-1.08.0-linux-arm64.tar.gz # geode-1.08.0-linux-arm64.deb # geode-1.08.0-linux-arm64.rpm +# geode-1.08.0-windows-x64.zip +# geode-1.08.0-windows-arm64.zip # # Two assets break the family/arch shape on purpose: the no-JRE jar bundles for # Homebrew-core are pure JVM bytecode (no bundled runtime), so a single @@ -121,10 +127,14 @@ collect_assets() { # # Expected inputs: # cli/build/amy-image/amy/ flat app-image built by :cli:amyImage -# (bin/amy + lib/*.jar + runtime/) +# (bin/amy + bin/amy.bat + lib/*.jar + runtime/) # cli/build/jpackage/*.deb from :cli:jpackageDeb (Linux only) # cli/build/jpackage/*.rpm from :cli:jpackageRpm (Linux only) # +# On Windows the flat image is packaged as .zip (native archive format, +# preserves file layout without requiring a tar tool at install time). Every +# other OS uses tar.gz. +# # Usage: collect_cli_assets collect_cli_assets() { local family="$1" arch="$2" version="$3" dest="$4" @@ -133,14 +143,39 @@ collect_cli_assets() { abs_dest="$(cd "$dest" && pwd)" shopt -s nullglob - # 1. Tar the flat app-image into amy---.tar.gz. - # This is the portable-across-OS asset — macOS runners produce only - # this one. + # 1. Archive the flat app-image into amy---.. + # macOS runners produce only this one. Windows uses .zip; every other + # OS uses tar.gz. local app_image="cli/build/amy-image/amy" if [ -d "$app_image" ]; then - local tarball="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" tar.gz)" - ( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" ) - echo "Collected: $tarball" + if [ "$family" = "windows" ]; then + local zipfile="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" zip)" + # Prefer 7z when available (bash+7zip is standard on GH windows runners), + # else fall back to a portable python3 zipfile. `zip` itself is not always + # present on GH windows runners. + if command -v 7z >/dev/null 2>&1; then + ( cd "$(dirname "$app_image")" && 7z a -tzip "$zipfile" "$(basename "$app_image")/" >/dev/null ) + elif command -v zip >/dev/null 2>&1; then + ( cd "$(dirname "$app_image")" && zip -qr "$zipfile" "$(basename "$app_image")" ) + else + python3 - "$app_image" "$zipfile" <<'PY' +import os, sys, zipfile +src, dst = sys.argv[1], sys.argv[2] +root = os.path.dirname(src) +base = os.path.basename(src) +with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zf: + for dirpath, _dirs, files in os.walk(src): + for f in files: + p = os.path.join(dirpath, f) + zf.write(p, os.path.relpath(p, root)) +PY + fi + echo "Collected: $zipfile" + else + local tarball="$abs_dest/$(cli_asset_name "$family" "$arch" "$version" tar.gz)" + ( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" ) + echo "Collected: $tarball" + fi fi # 2. Linux native installers (.deb, .rpm). jpackage writes them directly @@ -166,10 +201,14 @@ collect_cli_assets() { # # Expected inputs: # geode/build/geode-image/geode/ flat app-image built by :geode:geodeImage -# (bin/geode + lib/*.jar + runtime/ + share/) +# (bin/geode + bin/geode.bat + lib/*.jar +# + runtime/ + share/) # geode/build/jpackage/*.deb from :geode:jpackageDeb (Linux only) # geode/build/jpackage/*.rpm from :geode:jpackageRpm (Linux only) # +# On Windows the flat image is packaged as .zip; every other OS uses tar.gz. +# See the matching comment in collect_cli_assets for the tool-selection order. +# # Usage: collect_geode_assets collect_geode_assets() { local family="$1" arch="$2" version="$3" dest="$4" @@ -178,14 +217,36 @@ collect_geode_assets() { abs_dest="$(cd "$dest" && pwd)" shopt -s nullglob - # 1. Tar the flat app-image into geode---.tar.gz. - # This is the portable-across-OS asset — macOS runners produce only - # this one. + # 1. Archive the flat app-image into geode---.. + # macOS runners produce only this one. Windows uses .zip; every other + # OS uses tar.gz. local app_image="geode/build/geode-image/geode" if [ -d "$app_image" ]; then - local tarball="$abs_dest/$(geode_asset_name "$family" "$arch" "$version" tar.gz)" - ( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" ) - echo "Collected: $tarball" + if [ "$family" = "windows" ]; then + local zipfile="$abs_dest/$(geode_asset_name "$family" "$arch" "$version" zip)" + if command -v 7z >/dev/null 2>&1; then + ( cd "$(dirname "$app_image")" && 7z a -tzip "$zipfile" "$(basename "$app_image")/" >/dev/null ) + elif command -v zip >/dev/null 2>&1; then + ( cd "$(dirname "$app_image")" && zip -qr "$zipfile" "$(basename "$app_image")" ) + else + python3 - "$app_image" "$zipfile" <<'PY' +import os, sys, zipfile +src, dst = sys.argv[1], sys.argv[2] +root = os.path.dirname(src) +base = os.path.basename(src) +with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zf: + for dirpath, _dirs, files in os.walk(src): + for f in files: + p = os.path.join(dirpath, f) + zf.write(p, os.path.relpath(p, root)) +PY + fi + echo "Collected: $zipfile" + else + local tarball="$abs_dest/$(geode_asset_name "$family" "$arch" "$version" tar.gz)" + ( cd "$(dirname "$app_image")" && tar czf "$tarball" "$(basename "$app_image")" ) + echo "Collected: $tarball" + fi fi # 2. Linux native installers (.deb, .rpm). jpackage writes them directly From f3104f0a6c4f45b30b7a941befd04f9c7efeee24 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 5 Aug 2026 17:29:51 -0400 Subject: [PATCH 137/227] ci(release): build windows-arm64 desktop as a portable zip only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The windows-11-arm leg added by the previous commit runs `packageReleaseMsi`, which cannot succeed on that runner: jpackage --type msi shells out to WiX 3's heat.exe / candle.exe / light.exe (JDK 21 jpackage guide names WiX 3.11.1), and the Windows 11 Arm64 runner image ships no WiX at all. Verified against actions/runner-images: images/windows/Windows2025-Readme.md -> "WiX Toolset 3.14.1.8722" images/windows/Windows11-Arm64-Readme.md -> no WiX entry (7zip 26.02, Python 3.13 and Java 21 aarch64 ARE present on the arm64 image, so the rest of the leg — createReleaseDistributable, the 7z portable zip, collect_assets — is unaffected.) Installing WiX in the job instead was the alternative and is worse: wixtoolset/wix3 was archived in Feb 2025, WiX 4+ replaced the candle/light CLI that jpackage drives with `wix build`, and the WiX 3 binaries are x86-only (emulated on arm64). That would mean pulling an archived, unpinned third-party toolchain into the job that publishes signed release assets, for one asset we already ship in portable form. So: arm64 Windows gets the portable .zip, which is already the documented Windows install path for amy and geode. The x64 leg is untouched and still produces the MSI. collect_assets needs no change — it globs with nullglob and skips the absent msi/ directory. BUILDING.md: replace the release-verification asset count, which this branch had left vague ("5 formats x 2 arches shipped as one merged set of 5 ... see the previous release"), with a per-leg enumeration counted off the matrix: 14 desktop + 13 Android + 10 amy + 10 geode = 47. Also corrects the Windows prerequisites note, which claimed CI produces arm64 MSIs natively. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/create-release.yml | 14 ++++++++++- BUILDING.md | 35 +++++++++++++++++++--------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index ec8dd01ac0..72643e9e40 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -48,10 +48,22 @@ jobs: # public-repo runner catalogue in 2025 (4 vCPU / 16 GB / arm64). # jpackage / jlink / Compose Multiplatform 1.11 all produce # host-native artifacts — no cross-compilation needed. + # + # The arm64 Windows leg builds the portable .zip ONLY — no MSI. + # jpackage --type msi shells out to WiX 3's heat/candle/light, and the + # windows-11-arm runner image ships no WiX (the windows-latest image + # has WiX 3.14 preinstalled, which is why the x64 leg can package an + # MSI). Installing it here would mean pulling an archived, x86-only + # toolchain (wixtoolset/wix3 was archived in Feb 2025; WiX 4+ dropped + # the candle/light CLI that JDK 21's jpackage requires) into the job + # that publishes signed release assets. The portable zip is the + # documented Windows install path for amy/geode already, so arm64 + # Windows users get that until either the runner image gains WiX or + # jpackage learns the WiX 4+ CLI. include: - { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" } - { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" } - - { os: windows-11-arm, arch: arm64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" } + - { os: windows-11-arm, arch: arm64, family: windows, tasks: "createReleaseDistributable" } - { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } - { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } - { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" } diff --git a/BUILDING.md b/BUILDING.md index 680d4caad3..aa29c7ba11 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -36,8 +36,11 @@ Platform-specific: - **macOS**: Xcode Command Line Tools (`xcode-select --install`) - **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`. - Windows arm64 builds run on the free public-repo `windows-11-arm` GitHub runner — - jpackage on Windows arm64 produces arm64 MSIs natively; no cross-compilation. + Windows arm64 builds run on the free public-repo `windows-11-arm` GitHub runner + and produce the portable `.zip` only — that image ships no WiX, so CI cannot + package an arm64 MSI. Locally you *can* build one on an arm64 Windows box with + WiX 3.x installed (jpackage produces host-native artifacts; the WiX 3 binaries + themselves are x86 and run under emulation). - **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; `appimagetool` + `desktop-file-utils` for AppImage; `flatpak` + `flatpak-builder` for the Flatpak bundle (see @@ -330,17 +333,27 @@ Quartz library in one pipeline. 3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min). -4. **Verify** — the GH Release should hold **37 assets**: - - **10 desktop** — `dmg` (macOS arm64); `msi` + `zip` per Windows arch - (x64 and arm64, 4 files); `deb`, `rpm`, `AppImage`, `flatpak`, `tar.gz` - for Linux x64+arm64 (5 formats × 2 arches shipped as one merged set of - 5 in the current layout — see the previous release for the exact - enumeration). There is **no Intel/x64 macOS DMG** — `jpackage` cannot - cross-compile and no Intel runner leg is configured, so macOS ships - arm64-only. +4. **Verify** — the GH Release should hold **47 assets**: + - **14 desktop**, one per matrix leg × format: + - macOS arm64: `dmg` (1) + - Windows x64: `msi` + portable `zip` (2) + - Windows arm64: portable `zip` only (1) — **no arm64 MSI**, see below + - Linux x64 / arm64: `deb` + `rpm` (4) + - Linux-portable x64 / arm64: `AppImage` + `tar.gz` + `flatpak` (6) + + There is **no Intel/x64 macOS DMG** — `jpackage` cannot cross-compile + and no Intel runner leg is configured, so macOS ships arm64-only. + There is **no Windows arm64 MSI**: `jpackage --type msi` shells out to + WiX 3's `heat`/`candle`/`light`, and the `windows-11-arm` runner image + ships no WiX (`windows-latest` has WiX 3.14 preinstalled, which is why + the x64 leg gets an MSI). Revisit if that image gains WiX, or if + jpackage learns the WiX 4+ `wix build` CLI. - **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid `.apks` set built for Accrescent. - - **7 amy** + **7 geode** bundles (5 unix + 2 Windows portable zips each). + - **10 amy** — `tar.gz` (macOS arm64, Linux x64, Linux arm64), + `deb` + `rpm` per Linux arch, portable `zip` per Windows arch, and the + one arch-independent no-JRE `amy--jvm.tar.gz` for Homebrew-core. + - **10 geode** — same shape as amy. - Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset) - Android flow unchanged From ff9855aad614661bccfd84b0a7358f3320bf56d2 Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Wed, 29 Jul 2026 09:01:04 +1000 Subject: [PATCH 138/227] feat(gitRepositories): add ngit-specific search on the Git Repositories screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a client-side filter for the Git Repositories page that only searches fields relevant to ngit repository announcements (kind:30617 NIP-34): repo name, `d` identifier, description, hashtags/topics, clone URLs, web URLs, maintainer relays, maintainer/author pubkeys (accepting both hex and `npub…` bech32 in the query), and the earliest-unique-commit hash. Before this change, the only search affordance on the screen was the generic Nostr search icon that navigated away to `Route.Search`, which matches people, notes, hashtags, and channels — none of which are ngit repositories. Users who wanted to find a repo they'd already discovered had to scroll through the full follow-list-scoped feed. UX: - A filter icon in the top bar toggles an inline `OutlinedTextField` directly above the feed. First-appearance focus opens the keyboard without a second tap. - The general search icon is preserved beside the filter icon so outbound searches still work. - While filtering, results render with the same `NoteCompose` cells the feed uses so every affordance (bookmark, open, share) still works. - Filtered rendering uses a scoped `LazyListState` because the item-key set of the filtered list is not stable against the feed's cached scroll offset; sharing them would jump the user to an unrelated repo. - Closing the filter icon clears the query, restoring the full feed in one tap. Filter semantics: - Whitespace-separated terms are ANDed against each repo (`amethyst nostr` matches only repos that carry both terms in some indexed field). - Case-insensitive substring match on each indexed field. - `npub1…` queries are decoded to hex before matching, so a maintainer can be found by either encoding. Tests: `GitRepositorySearchMatcherTest` (17 hermetic cases) pins every indexed field, plus the "empty query returns nothing / filter blank returns everything" contract that the caller relies on to skip the filter path. Build check: `./gradlew :amethyst:compileFdroidDebugKotlin :amethyst:testFdroidDebugUnitTest --tests 'com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.GitRepositorySearchMatcherTest' :amethyst:spotlessCheck` all green. --- .../gitRepositories/GitRepositoriesScreen.kt | 218 +++++++++++++++++- .../gitRepositories/GitRepositoriesTopBar.kt | 92 +++++++- .../GitRepositorySearchMatcher.kt | 134 +++++++++++ amethyst/src/main/res/values/strings.xml | 4 + .../GitRepositorySearchMatcherTest.kt | 194 ++++++++++++++++ 5 files changed, 622 insertions(+), 20 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcher.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcherTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt index 3b03b5ba4c..6dcee4d39b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt @@ -20,11 +20,35 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState @@ -34,8 +58,17 @@ import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.ClearTextIcon +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent @Composable fun GitRepositoriesScreen( @@ -59,10 +92,31 @@ fun GitRepositoriesScreen( WatchAccountForGitRepositoriesScreen(gitRepositoriesFeedContentState = gitRepositoriesFeedContentState, accountViewModel = accountViewModel) GitRepositoriesFilterAssemblerSubscription(accountViewModel) + // Search UI state is remembered across configuration changes so the + // user doesn't lose their query when rotating; scoped to this screen, + // not persisted to disk (unlike the follow-list filter above). + var isSearchOpen by rememberSaveable { mutableStateOf(false) } + var searchQuery by rememberSaveable { mutableStateOf("") } + DisappearingScaffold( isInvertedLayout = false, topBar = { - GitRepositoriesTopBar(accountViewModel, nav) + GitRepositoriesTopBar( + isSearchOpen = isSearchOpen, + onToggleSearch = { + // Closing collapses the field AND clears the query so + // the feed is fully restored — the icon acts as a + // one-tap "reset" once the user has narrowed the view. + if (isSearchOpen) { + searchQuery = "" + isSearchOpen = false + } else { + isSearchOpen = true + } + }, + accountViewModel = accountViewModel, + nav = nav, + ) }, bottomBar = { AppBottomBar(Route.GitRepositories, nav, accountViewModel) { route -> @@ -75,20 +129,166 @@ fun GitRepositoriesScreen( }, accountViewModel = accountViewModel, ) { - RefresheableBox(gitRepositoriesFeedContentState, true) { - SaveableFeedContentState(gitRepositoriesFeedContentState, scrollStateKey = ScrollStateKeys.GIT_REPOSITORIES_SCREEN) { listState -> - RenderFeedContentState( - feedContentState = gitRepositoriesFeedContentState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = "GitRepositoriesFeed", + Column(Modifier.fillMaxSize()) { + if (isSearchOpen) { + GitRepositorySearchField( + query = searchQuery, + onQueryChange = { searchQuery = it }, + onClearQuery = { searchQuery = "" }, ) + HorizontalDivider(thickness = DividerThickness) + } + RefresheableBox(gitRepositoriesFeedContentState, true) { + SaveableFeedContentState(gitRepositoriesFeedContentState, scrollStateKey = ScrollStateKeys.GIT_REPOSITORIES_SCREEN) { listState -> + val query = searchQuery + if (query.isBlank()) { + RenderFeedContentState( + feedContentState = gitRepositoriesFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "GitRepositoriesFeed", + ) + } else { + // When the filter is active we can't reuse the shared + // scroll state because the filtered list has a different + // set of item keys — using the same LazyListState would + // make Compose try to restore an index that no longer + // exists and jump the user to an unrelated repo. We + // scope a fresh, per-query LazyListState so scrolling + // stays inside the filtered view. + RenderFilteredFeed( + feedContentState = gitRepositoriesFeedContentState, + query = query, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } } } } } +/** + * Inline text field that drives the client-side ngit-repository search. Sits + * directly under the top bar and above the feed so the user can see the + * result of every keystroke narrow the list beneath it. + */ +@Composable +private fun GitRepositorySearchField( + query: String, + onQueryChange: (String) -> Unit, + onClearQuery: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + // Focus on first appearance so the keyboard opens without a second + // tap. Subsequent recompositions inside the same session don't re- + // request focus, which would fight with the user pressing "back to + // the feed" via the field's clear-text icon. + focusRequester.requestFocus() + } + + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + placeholder = { + Text( + text = stringRes(R.string.git_repositories_search_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + leadingIcon = { SearchIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = onClearQuery) { + ClearTextIcon() + } + } + }, + singleLine = true, + ) + } +} + +/** + * Renders the ngit repositories the user is already subscribed to, filtered + * by [query]. Loading and error states are delegated to the shared + * [RenderFeedContentState] via the appropriate branches; the loaded branch + * is intercepted so we can filter the notes without touching the shared + * feed model (which other screens also observe). + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RenderFilteredFeed( + feedContentState: FeedContentState, + query: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val filteredListState = rememberLazyListState() + + RenderFeedContentState( + feedContentState = feedContentState, + accountViewModel = accountViewModel, + listState = filteredListState, + nav = nav, + routeForLastRead = "GitRepositoriesFeed", + onLoaded = { loaded -> + val loadedItems by loaded.feed.collectAsStateWithLifecycle() + + val filtered = + remember(loadedItems, query) { + loadedItems.list.filter { note -> + val event = note.event as? GitRepositoryEvent ?: return@filter false + GitRepositorySearchMatcher.matches(event, query) + } + } + + if (filtered.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + ) { + Text( + text = stringRes(R.string.git_repositories_search_no_results), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = filteredListState, + modifier = Modifier.fillMaxSize(), + ) { + itemsIndexed( + filtered, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + Row(Modifier.fillMaxWidth().animateItem()) { + NoteCompose( + item, + modifier = Modifier.fillMaxWidth(), + routeForLastRead = "GitRepositoriesFeed", + isBoostedNote = false, + isHiddenFeed = loadedItems.showHidden, + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = nav, + ) + } + HorizontalDivider(thickness = DividerThickness) + } + } + } + }, + ) +} + @Composable fun WatchAccountForGitRepositoriesScreen( gitRepositoriesFeedContentState: FeedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt index 4c30842a95..2d15903c9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt @@ -20,35 +20,105 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner -import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarNavigationIcon +import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size22Modifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText +/** + * Top bar for the ngit repositories discovery screen. + * + * Two search affordances live side-by-side in the actions row: + * + * 1. A **repository filter** (magnifier-with-a-plus icon) that toggles + * an inline text field over the loaded feed. This is the ngit-specific + * search — it matches the fields NIP-34 announcements carry: name, + * identifier, description, hashtags, clone/web/relay URLs, and + * maintainer pubkeys. It filters what the user is already looking + * at without touching relays. + * + * 2. The **generic Nostr search** (plain magnifier) that navigates to + * the global [Route.Search] screen, matching the affordance on + * every other top-level screen. + * + * Splitting them this way makes it obvious which magnifier does what: the + * inline one narrows the current list, the outbound one opens the fleet- + * wide search that also queries people, notes, hashtags, etc. + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun GitRepositoriesTopBar( + isSearchOpen: Boolean, + onToggleSearch: () -> Unit, accountViewModel: AccountViewModel, nav: INav, ) { - UserDrawerSearchTopBar(accountViewModel, nav) { - val list by accountViewModel.account.settings.defaultGitRepositoriesFollowList - .collectAsStateWithLifecycle() + ShorterTopAppBar( + title = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val list by accountViewModel.account.settings.defaultGitRepositoriesFollowList + .collectAsStateWithLifecycle() - GitRepositoriesTopNavFilterBar( - followListsModel = accountViewModel.feedStates.feedListOptions, - listName = list, - accountViewModel = accountViewModel, - onChange = accountViewModel.account.settings::changeDefaultGitRepositoriesFollowList, - ) - } + GitRepositoriesTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultGitRepositoriesFollowList, + ) + } + }, + navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) }, + actions = { + IconButton(onClick = onToggleSearch) { + Icon( + symbol = + if (isSearchOpen) { + MaterialSymbols.Close + } else { + MaterialSymbols.FilterAlt + }, + contentDescription = + stringRes( + if (isSearchOpen) { + R.string.git_repositories_search_close + } else { + R.string.git_repositories_search_open + }, + ), + ) + } + IconButton(onClick = { nav.nav(Route.Search) }) { + SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText) + } + }, + ) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcher.kt new file mode 100644 index 0000000000..4f40260a88 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcher.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories + +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +/** + * Local, in-memory matcher for the Git Repositories screen search box. + * + * The screen already loads the full set of ngit repository announcements + * (`kind:30617` `GitRepositoryEvent`) the user has subscribed to via their + * follow lists / follow set, so a client-side filter avoids issuing an + * extra NIP-50 relay query for the common "I know its name/topic/host" + * lookup. It also matches on fields the generic NIP-50 search would + * ignore — clone/web URLs, maintainer npubs, the ngit `d` identifier + * — which are exactly what someone browsing repos on gitworkshop / + * ngit tends to remember. + * + * The query is split on whitespace so `"amethyst nostr"` requires each + * term to appear in at least one indexed field of the same repository. + * Every term match is case-insensitive. + * + * Indexed fields: + * - repo name (`name` tag) + * - repo identifier (`d` tag; what appears in the ngit URL path) + * - description + * - hashtags/topics (`t` tags) + * - clone URLs (`clone` tag values) + * - web URLs (`web` tag values) + * - relay URLs the maintainers listen on (`relays` tag values) + * - maintainer pubkeys (both hex and NIP-19 `npub…` form) + * - repo author pubkey (hex and npub) + * - earliest-unique-commit hash (`r … euc`) — lets you paste a commit + * hash from a nostr:naddr and land on the repo + */ +object GitRepositorySearchMatcher { + /** + * @return `true` when [event] matches every whitespace-separated term + * in [query]. An empty query matches nothing (callers should skip the + * filter path in that case). + */ + fun matches( + event: GitRepositoryEvent, + query: String, + ): Boolean { + val terms = query.trim().split(WHITESPACE).filter { it.isNotEmpty() } + if (terms.isEmpty()) return false + + val haystack = buildHaystack(event) + return terms.all { term -> + val needle = term.lowercase() + // Support "npub1…" queries by resolving them to hex; the hex + // form is already in the haystack via authorNpubs / dTag / + // maintainers. + val hexFromBech32 = tryDecodeNpubToHex(needle) + haystack.any { field -> field.contains(needle) } || + (hexFromBech32 != null && haystack.any { field -> field.contains(hexFromBech32) }) + } + } + + /** + * Same as [matches] but returns the list of unique repositories + * ordered by the caller-provided iteration order. Duplicate `d` + * tags collapse to the newest event, because a maintainer publishing + * two revisions of `amethyst` is still one repo. + */ + fun filter( + events: Sequence, + query: String, + ): List { + if (query.isBlank()) return events.toList() + return events.filter { matches(it, query) }.toList() + } + + private fun buildHaystack(event: GitRepositoryEvent): List { + val out = ArrayList(16) + event.name()?.lowercase()?.let(out::add) + event + .dTag() + .takeIf { it.isNotEmpty() } + ?.lowercase() + ?.let(out::add) + event.description()?.lowercase()?.let(out::add) + event.hashtags().forEach { out.add(it.lowercase()) } + event.clones().forEach { out.add(it.lowercase()) } + event.webs().forEach { out.add(it.lowercase()) } + event.relays().forEach { out.add(it.lowercase()) } + // Maintainers as hex + npub. Author is an implicit maintainer per + // NIP-34, so include it in both forms too. + val authors = HashSet() + authors.add(event.pubKey) + authors.addAll(event.maintainers()) + authors.forEach { hex -> + out.add(hex.lowercase()) + hexToNpub(hex)?.let { out.add(it.lowercase()) } + } + event.earliestUniqueCommit()?.lowercase()?.let(out::add) + return out + } + + private val WHITESPACE = Regex("\\s+") + + private fun tryDecodeNpubToHex(candidate: String): String? { + if (!candidate.startsWith("npub1")) return null + return runCatching { + when (val parsed = Nip19Parser.uriToRoute(candidate)?.entity) { + is NPub -> parsed.hex + else -> null + } + }.getOrNull() + } + + private fun hexToNpub(hex: String): String? = runCatching { NPub.create(hex) }.getOrNull() +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4f0003f2ba..b6b362ed51 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3930,6 +3930,10 @@ Save Git Repositories Highlights + Filter repositories + Close filter + Filter by name, topic, host, maintainer… + No repositories in the current feed match this search. nSite: %1$s nApplet: %1$s Permissions: diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcherTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcherTest.kt new file mode 100644 index 0000000000..7703d9f853 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcherTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories + +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the ngit-repository-relevant search matcher used by + * `GitRepositoriesScreen`. Every field the matcher promises to index is + * exercised once here so a future refactor that drops one (e.g. relays) + * shows up as a red test. + */ +class GitRepositorySearchMatcherTest { + private val ownerHex = "aa".repeat(32) + private val maintainerHex = "bb".repeat(32) + private val ownerNpub = NPub.create(ownerHex) + private val maintainerNpub = NPub.create(maintainerHex) + + private fun repo( + name: String = "amethyst", + dTag: String = "amethyst", + description: String? = "A Nostr client for Android", + clones: List = listOf("https://github.com/vitorpamplona/amethyst.git"), + webs: List = listOf("https://amethyst.social"), + relays: List = listOf("wss://relay.ngit.dev"), + maintainers: List = listOf(maintainerHex), + hashtags: List = listOf("nostr", "android"), + euc: String? = "99614f07e4ffa99dff4143d7457be8923690bbba", + pubKey: String = ownerHex, + ): GitRepositoryEvent { + val tags = mutableListOf>() + tags += arrayOf("d", dTag) + tags += arrayOf("name", name) + description?.let { tags += arrayOf("description", it) } + clones.forEach { tags += arrayOf("clone", it) } + webs.forEach { tags += arrayOf("web", it) } + if (relays.isNotEmpty()) tags += arrayOf("relays", *relays.toTypedArray()) + if (maintainers.isNotEmpty()) tags += arrayOf("maintainers", *maintainers.toTypedArray()) + hashtags.forEach { tags += arrayOf("t", it) } + euc?.let { tags += arrayOf("r", it, "euc") } + return GitRepositoryEvent( + id = "00".repeat(32), + pubKey = pubKey, + createdAt = 0L, + tags = tags.toTypedArray(), + content = "", + sig = "00", + ) + } + + @Test + fun emptyQueryMatchesNothing() { + // Callers must skip the filter path themselves — the matcher is + // conservative and refuses to accept an empty term list. + assertFalse(GitRepositorySearchMatcher.matches(repo(), "")) + assertFalse(GitRepositorySearchMatcher.matches(repo(), " ")) + } + + @Test + fun matchesRepoNameCaseInsensitive() { + assertTrue(GitRepositorySearchMatcher.matches(repo(name = "Amethyst"), "amethyst")) + assertTrue(GitRepositorySearchMatcher.matches(repo(name = "Amethyst"), "AMET")) + } + + @Test + fun matchesRepoIdentifierDTag() { + assertTrue(GitRepositorySearchMatcher.matches(repo(dTag = "ngit-cli"), "ngit-cli")) + } + + @Test + fun matchesDescription() { + assertTrue( + GitRepositorySearchMatcher.matches( + repo(description = "A private Nostr messenger"), + "messenger", + ), + ) + } + + @Test + fun matchesHashtag() { + assertTrue(GitRepositorySearchMatcher.matches(repo(hashtags = listOf("kotlin", "mobile")), "kotlin")) + } + + @Test + fun matchesCloneUrl() { + assertTrue( + GitRepositorySearchMatcher.matches( + repo(clones = listOf("https://relay.ngit.dev/npub1abc/foo.git")), + "relay.ngit.dev", + ), + ) + } + + @Test + fun matchesWebUrl() { + assertTrue(GitRepositorySearchMatcher.matches(repo(webs = listOf("https://gitworkshop.dev/x")), "gitworkshop")) + } + + @Test + fun matchesRelayHost() { + assertTrue( + GitRepositorySearchMatcher.matches( + repo(relays = listOf("wss://relay.damus.io")), + "damus.io", + ), + ) + } + + @Test + fun matchesMaintainerHex() { + assertTrue(GitRepositorySearchMatcher.matches(repo(), maintainerHex)) + } + + @Test + fun matchesMaintainerNpub() { + // The `bb…` npub is a valid bech32 pubkey; supplying it as a + // query must resolve to the same hex the tag carries. + assertTrue(GitRepositorySearchMatcher.matches(repo(), maintainerNpub)) + } + + @Test + fun matchesAuthorHex() { + assertTrue(GitRepositorySearchMatcher.matches(repo(), ownerHex)) + } + + @Test + fun matchesAuthorNpub() { + assertTrue(GitRepositorySearchMatcher.matches(repo(), ownerNpub)) + } + + @Test + fun matchesEarliestUniqueCommit() { + // Full euc must match; a prefix that lives inside it should too. + assertTrue(GitRepositorySearchMatcher.matches(repo(euc = "99614f07e4ff"), "99614f07")) + } + + @Test + fun multipleTermsMustAllMatch() { + val target = repo(name = "amethyst", hashtags = listOf("nostr", "android")) + assertTrue(GitRepositorySearchMatcher.matches(target, "amethyst android")) + // "kotlin" isn't in this repo's fields, so the AND fails. + assertFalse(GitRepositorySearchMatcher.matches(target, "amethyst kotlin")) + } + + @Test + fun invalidNpubTreatedAsRawText() { + // "npub1notreallybech32" is not a decodable npub; the matcher + // should still let it match as a raw substring of e.g. the + // description, without throwing. + val target = repo(description = "npub1notreallybech32 is a placeholder") + assertTrue(GitRepositorySearchMatcher.matches(target, "npub1notreallybech32")) + } + + @Test + fun filterReturnsAllMatches() { + val a = repo(name = "amethyst") + val b = repo(name = "ngit-cli", dTag = "ngit-cli", hashtags = listOf("rust", "git")) + val c = repo(name = "shakespeare", dTag = "shakespeare") + val hits = GitRepositorySearchMatcher.filter(sequenceOf(a, b, c), "rust") + assertEquals(listOf(b), hits) + } + + @Test + fun filterBlankQueryReturnsEverything() { + val a = repo(name = "amethyst") + val b = repo(name = "ngit-cli") + val hits = GitRepositorySearchMatcher.filter(sequenceOf(a, b), " ") + assertEquals(listOf(a, b), hits) + } +} From 221214e545d168c66796606b725d3eab55034957 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 5 Aug 2026 18:13:31 -0400 Subject: [PATCH 139/227] refactor(commons): move GitRepositorySearchMatcher to commons/search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matcher is pure platform-agnostic Kotlin over a Quartz event type — no Compose, no Android, no platform APIs — so per the sharing philosophy it belongs in commons rather than amethyst. commons/ARCHITECTURE.md assigns "event search filtering/ranking" to the `search` package (non-UI, CLI-safe), which is where it lands. The desktop Git Repositories screen can now use the same matcher instead of growing its own copy. The test moves to commons/src/commonTest and swaps org.junit for kotlin.test, matching every other test in that source set. That gains iOS coverage for free, and keeps the source set compiling for the native targets — commonTest is built for iosArm64/iosSimulatorArm64 too, so a JUnit import there is a build break, not a style nit. The test builds GitRepositoryEvent from raw tags and never signs, so it needs no secp256k1 binding. Also drops `filter()`. It had no caller — the screen filters the loaded feed itself with `matches` — and its KDoc promised behaviour it never implemented ("duplicate `d` tags collapse to the newest event"; it did no deduplication at all). Better to delete the unused API than to ship a dedup nobody asked for or a doc comment that lies. Its two tests go with it; the empty-query contract the screen does rely on stays covered. Verified: :commons:jvmTest (15/15 in the new location), :commons:compileTestKotlinIosSimulatorArm64, :commons:verifyKmpPurity, :amethyst:compileFdroidDebugKotlin, spotlessApply — all green. Co-Authored-By: Claude Opus 5 (1M context) --- .../gitRepositories/GitRepositoriesScreen.kt | 1 + .../search}/GitRepositorySearchMatcher.kt | 16 +----------- .../search}/GitRepositorySearchMatcherTest.kt | 26 +++---------------- 3 files changed, 6 insertions(+), 37 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search}/GitRepositorySearchMatcher.kt (89%) rename {amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories => commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search}/GitRepositorySearchMatcherTest.kt (88%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt index 6dcee4d39b..ee32e6e1b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.search.GitRepositorySearchMatcher import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/GitRepositorySearchMatcher.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcher.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/GitRepositorySearchMatcher.kt index 4f40260a88..90d5ed1f5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcher.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/GitRepositorySearchMatcher.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories +package com.vitorpamplona.amethyst.commons.search import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NPub @@ -78,20 +78,6 @@ object GitRepositorySearchMatcher { } } - /** - * Same as [matches] but returns the list of unique repositories - * ordered by the caller-provided iteration order. Duplicate `d` - * tags collapse to the newest event, because a maintainer publishing - * two revisions of `amethyst` is still one repo. - */ - fun filter( - events: Sequence, - query: String, - ): List { - if (query.isBlank()) return events.toList() - return events.filter { matches(it, query) }.toList() - } - private fun buildHaystack(event: GitRepositoryEvent): List { val out = ArrayList(16) event.name()?.lowercase()?.let(out::add) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcherTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/GitRepositorySearchMatcherTest.kt similarity index 88% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcherTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/GitRepositorySearchMatcherTest.kt index 7703d9f853..592eeb7ce4 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositorySearchMatcherTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/GitRepositorySearchMatcherTest.kt @@ -18,14 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories +package com.vitorpamplona.amethyst.commons.search import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * Pins the ngit-repository-relevant search matcher used by @@ -174,21 +173,4 @@ class GitRepositorySearchMatcherTest { val target = repo(description = "npub1notreallybech32 is a placeholder") assertTrue(GitRepositorySearchMatcher.matches(target, "npub1notreallybech32")) } - - @Test - fun filterReturnsAllMatches() { - val a = repo(name = "amethyst") - val b = repo(name = "ngit-cli", dTag = "ngit-cli", hashtags = listOf("rust", "git")) - val c = repo(name = "shakespeare", dTag = "shakespeare") - val hits = GitRepositorySearchMatcher.filter(sequenceOf(a, b, c), "rust") - assertEquals(listOf(b), hits) - } - - @Test - fun filterBlankQueryReturnsEverything() { - val a = repo(name = "amethyst") - val b = repo(name = "ngit-cli") - val hits = GitRepositorySearchMatcher.filter(sequenceOf(a, b), " ") - assertEquals(listOf(a, b), hits) - } } From bcbf78d8ea42d01e210360450363fd1520cd6e39 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:04:09 +0000 Subject: [PATCH 140/227] fix(ime): settle the keyboard in Nav so every screen is covered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaving a screen while the soft keyboard is still animating strands `imePadding()` at keyboard height for the whole app — `WindowInsets.ime` is a single shared holder, so the padding survives leaving the screen that caused it. PR #3864 fixed this for the post composers, at their call sites. That was the wrong altitude: Search strands it too, and Search has no BackHandler and no top bar of ours. Search is the clearest case: it focuses its field on arrival, so the keyboard is up before the user has done anything, and every way out is a navigation — a bottom-nav tab, a tapped result, back. Any destination that can focus a text field can strand the padding on the way out. There are 174 files with text input in this module; enumerating the screens was never going to converge. Two facts make a central fix possible: every in-app navigation goes through INav (there is not one `controller.navigate` outside navigation/navs/, and nothing touches OnBackPressedDispatcher, navigateUp or popBackStack directly), and every Nav method already runs inside `navigationScope.launch`. So Nav awaits an ImeSettler before each transition: keyboard down, it returns immediately and nothing changes; keyboard up, it clears focus, hides the IME and waits for the inset to actually reach zero, bounded, so the two animations never overlap. ObservableNav delegates to Nav and inherits it. That subsumes #3864's call-site patches, so they are removed rather than left as a second mechanism: ActionTopBar goes back to plain callbacks (which also drops the composition-scoped deferral of onPost, so posting no longer depends on the top bar staying composed), and KeyboardAwareBackHandler keeps only its imeAnimationTarget gate — the part that stops back falling through and silently dropping a draft. It is now a UX preference (let the system animate the dismissal) rather than the safety mechanism. NavImeSettleTest pins the ordering: each transition must settle before it navigates, and a settler that suspends must hold the navigation back rather than run alongside it. All four fail with the settle calls removed. Known gap: on a screen with no BackHandler the system's back pops through the NavController directly, not Nav.popBack(), so a second back landing inside the ~250ms retraction can still race. Closing it needs a shell-level handler registered after the NavHost to outrank its back callback, which is a composition-order dependency subtle enough to break silently — worth a deliberate decision rather than smuggling in here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN --- .../ui/navigation/bottombars/KeyboardState.kt | 89 ++----------- .../amethyst/ui/navigation/navs/ImeSettler.kt | 89 +++++++++++++ .../amethyst/ui/navigation/navs/Nav.kt | 13 ++ .../ui/navigation/navs/RememberNavs.kt | 5 +- .../ui/navigation/topbars/ActionTopBar.kt | 9 +- .../ui/navigation/NavImeSettleTest.kt | 118 ++++++++++++++++++ 6 files changed, 238 insertions(+), 85 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ImeSettler.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavImeSettleTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt index f576a3556c..78ea948462 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt @@ -30,15 +30,7 @@ import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import java.util.concurrent.atomic.AtomicBoolean enum class KeyboardState { Opened, @@ -69,80 +61,26 @@ fun keyboardAsState(): State { } } -/** How long to wait for the IME inset to reach zero before running the action anyway. */ -private const val IME_SETTLE_TIMEOUT_MS = 700L - -/** - * Returns a runner that defers an action until the soft keyboard is fully off screen. - * - * Popping a screen while the keyboard is still up races the window animation against the IME's - * close animation. On release builds — fast enough that the window animation wins — the IME - * [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] is cancelled before - * its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime` holder stays - * "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard height until a - * later inset pass rebalances it (the "stuck IME padding" that survives leaving the screen). - * - * Any exit that leaves a keyboard-bearing screen has to serialize the two animations rather than - * overlap them. With the keyboard already down the action runs inline — same frame, no behavior - * change. With it up we dismiss the keyboard, wait for the inset to actually reach zero, and only - * then act, so the IME animation always completes before the window animation begins. - * - * Re-entrant calls while an action is pending are dropped: the deferral widens the window in which - * a second tap on a Post/Save button would fire the action twice. - * - * [IME_SETTLE_TIMEOUT_MS] bounds the wait — if the inset never reports zero (precisely the failure - * this guards against) the action still runs, so a stale reading can never trap the user on screen. - */ -@Composable -fun rememberAfterKeyboardCloses(): (() -> Unit) -> Unit { - val density = LocalDensity.current - val imeInsets = WindowInsets.ime - val keyboard = LocalSoftwareKeyboardController.current - val focusManager = LocalFocusManager.current - val scope = rememberCoroutineScope() - val pending = remember { AtomicBoolean(false) } - - return remember(density, imeInsets, keyboard, focusManager, scope, pending) { - { action: () -> Unit -> - if (imeInsets.getBottom(density) <= 0) { - action() - } else if (pending.compareAndSet(false, true)) { - // Clear focus first so nothing re-requests the IME as it retracts. - focusManager.clearFocus(true) - keyboard?.hide() - scope.launch { - try { - withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) { - snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 } - } - action() - } finally { - pending.set(false) - } - } - } - } - } -} - /** * A [BackHandler] that lets the system dismiss the soft keyboard before it consumes back. * - * Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen, - * which is the pop-during-IME-animation race described on [rememberAfterKeyboardCloses]. While the - * keyboard is up we do NOT consume back, so the system dismisses it first with its own animation - * (which completes cleanly, and on recent Android follows the back gesture). The next back runs - * [onBack] as before. + * Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen. + * While the keyboard is up we do NOT consume back, so the system dismisses it first with its own + * animation — which completes cleanly, and on recent Android follows the back gesture rather than + * snapping. The next back runs [onBack] as before. + * + * This is a UX preference, not the safety mechanism: the actual pop-during-IME-animation race is + * handled for every exit in the app by + * [ImeSettler][com.vitorpamplona.amethyst.ui.navigation.navs.ImeSettler] on `Nav`, so [onBack] is + * safe to run whenever it fires. * * The gate reads [WindowInsets.imeAnimationTarget] — where the IME is *heading* — not the animated * [WindowInsets.ime]. Gating on the animated value left a hole: it stays above zero for the whole * close animation, ~250ms in which the IME has already stopped consuming back but this handler was * still disabled, so a second back fell through to the NavController and popped the screen without - * ever running [onBack] — silently dropping the draft it exists to save. The target flips to zero - * the moment the hide begins, so back keeps reaching [onBack] throughout. - * - * Re-enabling that early means [onBack] can now fire mid-animation, so it is routed through - * [rememberAfterKeyboardCloses] to wait for the inset to settle before popping. + * ever running [onBack] — silently dropping the draft it exists to save, since nothing else saves + * one. The target flips to zero the moment the hide begins, so back keeps reaching [onBack] + * throughout the animation. */ @OptIn(ExperimentalLayoutApi::class) @Composable @@ -152,11 +90,10 @@ fun KeyboardAwareBackHandler( ) { val density = LocalDensity.current val imeTarget = WindowInsets.imeAnimationTarget - val afterKeyboardCloses = rememberAfterKeyboardCloses() val keyboardIsStaying by remember(density, imeTarget) { derivedStateOf { imeTarget.getBottom(density) > 0 } } - BackHandler(enabled = enabled && !keyboardIsStaying) { afterKeyboardCloses(onBack) } + BackHandler(enabled = enabled && !keyboardIsStaying, onBack = onBack) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ImeSettler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ImeSettler.kt new file mode 100644 index 0000000000..c36e3bc30b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ImeSettler.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.navs + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull + +/** How long to wait for the IME inset to reach zero before navigating anyway. */ +const val IME_SETTLE_TIMEOUT_MS = 700L + +/** + * Waits for the soft keyboard to be fully off screen. Installed on [Nav] so that every navigation + * in the app serializes the IME and window animations instead of overlapping them. + * + * Navigating while the keyboard is up races the window animation against the IME's close animation. + * On release builds — fast enough that the window animation wins — the IME + * [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] is cancelled before + * its terminal (zero) frame reaches Compose. `WindowInsets.ime` is a single app-wide holder, so it + * stays "animating" and every `Modifier.imePadding()` in the app — not just the screen being left — + * freezes at the keyboard height until some later inset pass happens to rebalance it. + * + * This is not a composer-screen problem, which is why it lives here rather than in the screens. + * Any destination that can hold focus in a text field can strand the padding on the way out, by any + * exit: a back gesture, a top-bar button, a bottom-nav tab, or tapping a result. Search is the + * clearest case — it focuses its field on arrival, so the keyboard is already up before the user + * has done anything, and every way out of it is a navigation. + */ +fun interface ImeSettler { + suspend fun settle() + + companion object { + /** For [EmptyNav] and previews, where there is no window to read insets from. */ + val None = ImeSettler { } + } +} + +/** + * Reads the same animated `WindowInsets.ime` that drives `Modifier.imePadding()`, so the settler + * and the padding can never disagree about whether the keyboard is gone. + * + * Focus is cleared before hiding so nothing re-requests the IME as it retracts. The wait is bounded + * by [IME_SETTLE_TIMEOUT_MS] — if the inset never reports zero, which is precisely the failure this + * guards against, navigation still proceeds rather than stranding the user on the screen. + */ +@Composable +fun rememberImeSettler(): ImeSettler { + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + val keyboard = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + return remember(density, imeInsets, keyboard, focusManager) { + ImeSettler { + if (imeInsets.getBottom(density) > 0) { + focusManager.clearFocus(true) + keyboard?.hide() + withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) { + snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 1526d0be72..d937109b10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -44,6 +44,13 @@ import kotlin.reflect.KClass class Nav( val controller: NavHostController, override val navigationScope: CoroutineScope, + /** + * Awaited before every transition below. Leaving a screen while the soft keyboard is still + * animating strands `imePadding()` app-wide; see [ImeSettler]. Every in-app navigation goes + * through this class, so this is the one place that has to get it right — no screen, top bar + * or back handler needs to think about the keyboard on its way out. + */ + private val ime: ImeSettler = ImeSettler.None, ) : INav { override val drawerState = DrawerState(DrawerValue.Closed) @@ -63,6 +70,7 @@ class Nav( override fun nav(route: Route) { navigationScope.launch { + ime.settle() if (getRouteWithArguments(route::class, controller) != route) { controller.navigate(route) } @@ -71,6 +79,7 @@ class Nav( override fun nav(computeRoute: suspend () -> Route?) { navigationScope.launch { + ime.settle() val route = computeRoute() if (route != null && getRouteWithArguments(route::class, controller) != route) { controller.navigate(route) @@ -80,6 +89,7 @@ class Nav( override fun newStack(route: Route) { navigationScope.launch { + ime.settle() controller.navigate(route) { popUpTo(route) { inclusive = true @@ -91,6 +101,7 @@ class Nav( override fun navBottomBar(route: Route) { navigationScope.launch { + ime.settle() controller.navigate(route) { // Clear sibling bottom-nav entries but keep Home (the start // destination) below, so back-swipe from any tab returns to @@ -149,6 +160,7 @@ class Nav( override fun popBack() { navigationScope.launch { + ime.settle() controller.navigateUp() } } @@ -159,6 +171,7 @@ class Nav( klass: KClass, ) { navigationScope.launch { + ime.settle() controller.navigate(route) { popUpTo(klass) { inclusive = true } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt index f6c42c0aa3..8b9a2d0e40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt @@ -29,9 +29,10 @@ import androidx.navigation.compose.rememberNavController fun rememberNav(): Nav { val navController = rememberNavController() val scope = rememberCoroutineScope() + val ime = rememberImeSettler() - return remember(navController, scope) { - Nav(navController, scope) + return remember(navController, scope, ime) { + Nav(navController, scope, ime) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt index 4bd0aba287..84b19c084a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.bottombars.rememberAfterKeyboardCloses import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding @@ -46,10 +45,6 @@ fun ActionTopBar( onPost: () -> Unit, additionalActions: @Composable (() -> Unit)? = null, ) { - // Both exits pop the screen, and on a composer the keyboard is up while typing — the same - // pop-during-IME-animation race the back gesture avoids, just reached by a tap instead. - val afterKeyboardCloses = rememberAfterKeyboardCloses() - ShorterTopAppBar( title = { if (titleRes != null) { @@ -65,7 +60,7 @@ fun ActionTopBar( navigationIcon = { CloseButton( modifier = HalfHorzPadding, - onPress = { afterKeyboardCloses(onCancel) }, + onPress = onCancel, ) }, actions = { @@ -75,7 +70,7 @@ fun ActionTopBar( Button( modifier = HalfHorzPadding, enabled = isActive(), - onClick = { afterKeyboardCloses(onPost) }, + onClick = onPost, ) { Text(text = stringRes(postRes)) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavImeSettleTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavImeSettleTest.kt new file mode 100644 index 0000000000..1b362dc062 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavImeSettleTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation + +import androidx.navigation.NavHostController +import androidx.navigation.NavOptionsBuilder +import com.vitorpamplona.amethyst.ui.navigation.navs.ImeSettler +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Leaving a screen while the soft keyboard is still animating strands `imePadding()` at keyboard + * height for the whole app, because `WindowInsets.ime` is a single shared holder. The fix is that + * [Nav] waits for the IME to be gone before it moves, so these assert the ordering rather than any + * visual result: every transition must settle the keyboard *first*. + * + * Without the settle calls in [Nav] each of these records only "navigate" and fails. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NavImeSettleTest { + private fun controllerRecording(order: MutableList): NavHostController = + mockk(relaxed = true) { + every { navigate(any(), any Unit>()) } answers + { order.add("navigate") } + every { navigate(any()) } answers { order.add("navigate") } + every { navigateUp() } answers { + order.add("navigate") + true + } + } + + @Test + fun popBackSettlesTheKeyboardBeforeNavigating() = + runTest { + val order = mutableListOf() + val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") }) + + nav.popBack() + advanceUntilIdle() + + assertEquals(listOf("settle", "navigate"), order) + } + + @Test + fun bottomBarSettlesTheKeyboardBeforeNavigating() = + runTest { + // The search tab focuses its field on arrival, so the keyboard is already up when the + // user taps another tab — the exit that has no BackHandler and no top bar to guard it. + val order = mutableListOf() + val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") }) + + nav.navBottomBar(Route.Home) + advanceUntilIdle() + + assertEquals(listOf("settle", "navigate"), order) + } + + @Test + fun newStackSettlesTheKeyboardBeforeNavigating() = + runTest { + val order = mutableListOf() + val nav = Nav(controllerRecording(order), this, ImeSettler { order.add("settle") }) + + nav.newStack(Route.Home) + advanceUntilIdle() + + assertEquals(listOf("settle", "navigate"), order) + } + + @Test + fun aSlowKeyboardStillHoldsTheNavigationBack() = + runTest { + // The real settler suspends for the length of the IME close animation. Navigation must + // wait for it, not fire alongside it — that overlap is the bug. + val order = mutableListOf() + val nav = + Nav( + controllerRecording(order), + this, + ImeSettler { + delay(250) + order.add("settle") + }, + ) + + nav.popBack() + assertEquals(emptyList(), order) + + advanceUntilIdle() + assertEquals(listOf("settle", "navigate"), order) + } +} From 72d05e2eb8bd7579fc797ff94f5f00b4a22051bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:58:24 +0000 Subject: [PATCH 141/227] refactor(ime): drop KeyboardAwareBackHandler now that Nav settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its job was to stop a composer's pop from racing the IME close animation, and that pop is `nav.popBack()` in all 11 call sites — exactly what the ImeSettler on Nav now serializes. So it no longer carries the fix; a plain BackHandler reaches the same place safely. What it did still provide was the two-back convention: first back dismisses the keyboard (via the system's own animation, which on recent Android follows the gesture), second back leaves. That came at a price it did not used to have. The mechanism is to NOT consume back while the keyboard is up and let the IME consume it instead — so on any device or API level where the IME does not, back reaches the NavController, which pops without ever running onBack and silently drops the draft, since nothing else saves one. Now that Nav settles, that failure would also be invisible: no stranded padding to hint at it, just a missing draft. A plain BackHandler has no such failure mode. It always consumes, so the draft is always flushed, on the first back rather than the second. KeyboardState.kt keeps keyboardAsState(), which is a separate concern — AppBottomBar uses it to hide the bottom bar while typing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN --- .../ui/navigation/bottombars/KeyboardState.kt | 41 ------------------- .../nip22Comments/GenericCommentPostScreen.kt | 4 +- .../loggedIn/badges/award/AwardBadgeScreen.kt | 4 +- .../chats/privateDM/send/NewGroupDMScreen.kt | 4 +- .../send/PrivateMessageEditFieldRow.kt | 4 +- .../chats/publicChannels/send/EditFieldRow.kt | 4 +- .../nip23LongForm/LongFormPostScreen.kt | 4 +- .../nip99Classifieds/NewProductScreen.kt | 4 +- .../loggedIn/home/ShortNotePostScreen.kt | 4 +- .../loggedIn/home/nip75Goals/NewGoalScreen.kt | 4 +- .../publicMessages/NewPublicMessageScreen.kt | 4 +- .../loggedIn/workouts/NewWorkoutScreen.kt | 4 +- 12 files changed, 22 insertions(+), 63 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt index 78ea948462..1fc4a00534 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt @@ -20,15 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.navigation.bottombars -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.imeAnimationTarget import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalDensity @@ -60,40 +56,3 @@ fun keyboardAsState(): State { } } } - -/** - * A [BackHandler] that lets the system dismiss the soft keyboard before it consumes back. - * - * Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen. - * While the keyboard is up we do NOT consume back, so the system dismisses it first with its own - * animation — which completes cleanly, and on recent Android follows the back gesture rather than - * snapping. The next back runs [onBack] as before. - * - * This is a UX preference, not the safety mechanism: the actual pop-during-IME-animation race is - * handled for every exit in the app by - * [ImeSettler][com.vitorpamplona.amethyst.ui.navigation.navs.ImeSettler] on `Nav`, so [onBack] is - * safe to run whenever it fires. - * - * The gate reads [WindowInsets.imeAnimationTarget] — where the IME is *heading* — not the animated - * [WindowInsets.ime]. Gating on the animated value left a hole: it stays above zero for the whole - * close animation, ~250ms in which the IME has already stopped consuming back but this handler was - * still disabled, so a second back fell through to the NavController and popped the screen without - * ever running [onBack] — silently dropping the draft it exists to save, since nothing else saves - * one. The target flips to zero the moment the hide begins, so back keeps reaching [onBack] - * throughout the animation. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun KeyboardAwareBackHandler( - enabled: Boolean = true, - onBack: () -> Unit, -) { - val density = LocalDensity.current - val imeTarget = WindowInsets.imeAnimationTarget - - val keyboardIsStaying by remember(density, imeTarget) { - derivedStateOf { imeTarget.getBottom(density) > 0 } - } - - BackHandler(enabled = enabled && !keyboardIsStaying, onBack = onBack) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index aec22cb911..6baef9f1a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.nip22Comments +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box @@ -66,7 +67,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture @@ -177,7 +177,7 @@ fun GenericCommentPostScreen( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - KeyboardAwareBackHandler { + BackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt index bdc768cc3c..2172e18945 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/badges/award/AwardBadgeScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -51,7 +52,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.components.Nip05OrPubkeyLine import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.note.UserPicture @@ -88,7 +88,7 @@ fun AwardBadgeScreen( onDispose { userSuggestions.reset() } } - KeyboardAwareBackHandler { + BackHandler { nav.popBack() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index ce87e19328..d87f54987d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send import android.net.Uri +import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Box @@ -86,7 +87,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage @@ -169,7 +169,7 @@ fun NewGroupDMScreen( WatchAndLoadMyEmojiList(accountViewModel) - KeyboardAwareBackHandler { + BackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 390d718d9b..fc64efe0ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -110,7 +110,7 @@ fun PrivateMessageEditFieldRow( onSendNewMessage: () -> Unit, nav: INav, ) { - KeyboardAwareBackHandler { + BackHandler { if (channelScreenModel.message.text.isNotBlank()) { accountViewModel.launchSigner { channelScreenModel.sendDraftSync() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index f14cd1d59e..8176dc4d5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -78,7 +78,7 @@ fun EditFieldRow( onSendNewMessage: suspend () -> Unit, nav: INav, ) { - KeyboardAwareBackHandler { + BackHandler { accountViewModel.launchSigner { channelScreenModel.sendDraftSync() channelScreenModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 12cba33c60..2465929d40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm +import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -95,7 +96,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer @@ -149,7 +149,7 @@ fun LongFormPostScreen( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - KeyboardAwareBackHandler { + BackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 8cd67d7b46..2e95142599 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds +import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -52,7 +53,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -141,7 +141,7 @@ fun NewProductScreen( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - KeyboardAwareBackHandler { + BackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index a4bc2c6bfb..db7e95f3be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import android.annotation.SuppressLint import android.content.Intent import android.net.Uri +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -92,7 +93,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.getActivity -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -235,7 +235,7 @@ internal fun NewPostScreenInner( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - KeyboardAwareBackHandler { + BackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt index f7aabcd6f7..5a6348cb6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -47,7 +48,6 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -81,7 +81,7 @@ fun NewGoalScreen( accountViewModel: AccountViewModel, nav: INav, ) { - KeyboardAwareBackHandler { + BackHandler { goalViewModel.cancel() nav.popBack() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index f9e277665c..56e1382b6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages +import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Column @@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -134,7 +134,7 @@ fun NewPublicMessageScreen( StrippingFailureDialog(postViewModel.strippingFailureConfirmation) - KeyboardAwareBackHandler { + BackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt index 5bc98f4cdb..a3de2013d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts +import androidx.activity.compose.BackHandler import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -80,7 +80,7 @@ fun NewWorkoutScreen( postViewModel.init(accountViewModel) postViewModel.prefill(prefill) - KeyboardAwareBackHandler { + BackHandler { postViewModel.cancel() nav.popBack() } From f4fc9917f8e6df5473febda56bde4494365bff19 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:48:28 +0000 Subject: [PATCH 142/227] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-cs/strings.xml | 16 ++++++++++++++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 7 +++++++ docs/changelog/translators.json | 11 +++++++---- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 6a86477be8..854354592d 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -2025,6 +2025,12 @@ + + %1$s \u00b7 %2$d relay + %1$s \u00b7 %2$d relaye + %1$s \u00b7 %2$d relaye + %1$s \u00b7 %2$d relayů + Procházení Média Hashtagy @@ -2063,6 +2069,7 @@ Peněženka Schránka nutzapů Adresář mintů + Wallet Connect Chaty komunit Zdroje komunit + + %1$d filter + %1$d filter + %1$d relä %1$d reläer @@ -2012,6 +2018,7 @@ %1$d filter är inte kopplat ännu %1$d filter är inte kopplade ännu + %1$s \u00b7 %2$s Inte kopplat till något konto Allt Alla diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index ef7dced2fc..a322a2a269 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -112,6 +112,13 @@ "Hindi" ] }, + { + "user": "davotoula", + "languages": [ + "Czech", + "Swedish" + ] + }, { "user": "greenart7c3", "languages": [] @@ -180,10 +187,6 @@ "user": "adhrasreoshiathoi", "languages": [] }, - { - "user": "davotoula", - "languages": [] - }, { "user": "crackadoo", "languages": [] From d6b8a54d8a7e6aa3f4800bbda2c62505e563b35e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:21:32 +0000 Subject: [PATCH 143/227] NEG-ERR: state the relay's max_sync_events on an overflow refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client that is refused for matching too much has exactly one thing to decide — how much smaller to ask next time — and no way to find out. NIP-11 has no field for max_sync_events, so the only route to a window the relay will answer is to guess and halve, and every wrong guess costs the relay the snapshot scan that produces the refusal. strfry already states the number in its rejection text; this makes it a first-class part of the frame. ["NEG-ERR", , ] unchanged, still what NIP-77 says ["NEG-ERR", , , ] when the refusal is about size Both mappers write the fourth element only when there is one, so a refusal with nothing to state is byte-identical to before, and both tolerate a non-numeric fourth element from someone else's relay. NegErrMessage.statedCap reads either form — the wire field or strfry's "(2431002 > 1000000)" prose — but only for a refusal that is about SIZE. That gate is the point of the property: a rate limit or a quota can carry numbers too, and it does not shrink when the window shrinks, so a client that mistook one for a cap would shrink its windows forever against a relay that has no size limit at all. The relay side sends its own configured cap for the same reason it is cheap: it had to know the number to refuse. --- .../kotlinSerialization/MessageKSerializer.kt | 9 ++ .../relay/server/NegSessionRegistry.kt | 8 +- .../quartz/nip77Negentropy/NegErrMessage.kt | 51 ++++++++++ .../nip77Negentropy/NegentropySettings.kt | 4 +- .../nip77Negentropy/NegErrMessageTest.kt | 96 +++++++++++++++++++ .../commands/toClient/MessageDeserializer.kt | 16 +++- .../commands/toClient/MessageSerializer.kt | 1 + .../nip77Negentropy/Nip77SerializationTest.kt | 64 +++++++++++++ 8 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessageTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index adba985212..93c0c360cb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -46,6 +46,7 @@ import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull object MessageKSerializer : KSerializer { override val descriptor: SerialDescriptor = @@ -112,6 +113,10 @@ object MessageKSerializer : KSerializer { is NegErrMessage -> { add(JsonPrimitive(value.subId)) add(JsonPrimitive(value.reason)) + // Only written when there is one: a three-element + // NEG-ERR is what NIP-77 describes, and that is what a + // refusal with nothing to state stays. + value.cap?.let { add(JsonPrimitive(it)) } } } } @@ -184,6 +189,10 @@ object MessageKSerializer : KSerializer { NegErrMessage( subId = array[1].jsonPrimitive.content, reason = if (array.size > 2) array[2].jsonPrimitive.content else "", + // Optional, and only a number: a relay that puts something + // else there is telling us nothing rather than breaking the + // frame. + cap = if (array.size > 3) array[3].jsonPrimitive.longOrNull else null, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NegSessionRegistry.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NegSessionRegistry.kt index 66e1675e32..18b4a31925 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NegSessionRegistry.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NegSessionRegistry.kt @@ -104,7 +104,13 @@ class NegSessionRegistry( // `null` = matching set exceeds the cap (strfry-parity error). val sealedStorage = store.sealedNegentropyStorage(filters, maxEntries = settings.maxSyncEvents) if (sealedStorage == null) { - send(NegErrMessage(cmd.subId, "blocked: too many query results")) + // The cap rides along with the refusal. A client cannot discover + // this number any other way — NIP-11 has no field for it — so + // without it the only route to a window we WILL answer is guessing, + // halving, one refused NEG-OPEN at a time. Every one of those costs + // us the snapshot scan that produced this rejection, which makes + // stating it cheaper for the relay than staying quiet. + send(NegErrMessage(cmd.subId, "blocked: too many query results", settings.maxSyncEvents.toLong())) return } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessage.kt index a81e1ded1f..e191598d97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessage.kt @@ -22,13 +22,64 @@ package com.vitorpamplona.quartz.nip77Negentropy import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +/** + * `["NEG-ERR", , ]`, optionally followed by the relay's own + * `max_sync_events` when the refusal is about result-set size. + * + * That fourth element is not in NIP-77, but it is the only way a client learns + * the one number that decides how to ask again — no NIP-11 field carries it — + * and it is free for the relay to send, since it must know its own cap to have + * refused. strfry states it in the prose (`… too many records (2431002 > + * 1000000)`); [statedCap] reads either form. + * + * @property cap the fourth wire element, when present. + */ class NegErrMessage( val subId: String, val reason: String, + val cap: Long? = null, ) : Message { override fun label() = LABEL + /** + * The relay's negentropy cap if this refusal states one, from the wire + * field or from the prose, in that order. + * + * Only read for a refusal that is about SIZE ([isOverflow]). A quota or + * rate-limit refusal can carry numbers too, and sizing future windows + * against one of those would shrink every ask against a relay that has no + * size limit at all — while the limit that actually refused does not move + * however small the window gets. + */ + val statedCap: Long? + get() = if (!isOverflow(reason)) null else cap?.takeIf { it > 0 } ?: capInReason(reason) + companion object { const val LABEL = "NEG-ERR" + + /** `(2431002 > 1000000)` — the cap is the right-hand side. */ + private val COMPARISON = Regex("""\(\s*\d+\s*>\s*(\d+)\s*\)""") + + /** + * Does this reason mean "your query matched more than I will + * reconcile"? — as opposed to any other refusal, which no amount of + * window splitting will get past. + */ + fun isOverflow(reason: String): Boolean = + reason.contains("too many records", ignoreCase = true) || + reason.contains("too many results", ignoreCase = true) || + reason.contains("too many query results", ignoreCase = true) || + reason.contains("result set too large", ignoreCase = true) || + reason.contains("results too large", ignoreCase = true) || + reason.contains("max_sync_events", ignoreCase = true) + + /** The cap strfry writes into the refusal text, when it is there. */ + fun capInReason(reason: String): Long? = + COMPARISON + .find(reason) + ?.groupValues + ?.get(1) + ?.toLongOrNull() + ?.takeIf { it > 0 } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySettings.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySettings.kt index 896ec7c472..0bd1c1027d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySettings.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegentropySettings.kt @@ -32,7 +32,9 @@ package com.vitorpamplona.quartz.nip77Negentropy * unlimited). * @param maxSyncEvents Hard cap on the snapshot size for a single * NEG-OPEN. Mirrors strfry's `relay__negentropy__maxSyncEvents`. - * Overflow returns NEG-ERR `"blocked: too many query results"`. + * Overflow returns NEG-ERR `"blocked: too many query results"` + * carrying this number as its fourth element, so a client can size + * its next window instead of halving its way down to one. * @param maxSessionsPerConnection Cap on concurrent NEG sessions * held by one connection. strfry shares 200 with REQ subs; we * count NEG independently. Overflow sends NOTICE diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessageTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessageTest.kt new file mode 100644 index 0000000000..9adafce838 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/NegErrMessageTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip77Negentropy + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A stated cap is acted on — it sizes the next NEG-OPEN — so reading one out of + * a refusal that is not about size is worse than reading none at all: a quota or + * rate limit does not shrink when the window shrinks, so a client that mistook + * one for a cap would shrink its windows forever against a relay that has no + * size limit. + */ +class NegErrMessageTest { + @Test + fun capComesFromTheWireField() { + assertEquals(1_000_000L, NegErrMessage("s", "blocked: too many query results", 1_000_000L).statedCap) + } + + @Test + fun capComesFromStrfrysProseWhenTheFieldIsAbsent() { + val msg = NegErrMessage("s", "blocked: query matches too many records (2431002 > 1000000)") + assertEquals(1_000_000L, msg.statedCap) + } + + @Test + fun theWireFieldWinsOverTheProse() { + val msg = NegErrMessage("s", "blocked: too many records (5 > 10)", 1_000L) + assertEquals(1_000L, msg.statedCap) + } + + @Test + fun anOverflowWithNoNumberStatesNothing() { + assertNull(NegErrMessage("s", "blocked: too many query results").statedCap) + } + + @Test + fun aRateLimitIsNotACapHoweverManyNumbersItCarries() { + assertFalse(NegErrMessage.isOverflow("rate-limited: too many requests (30 > 10)")) + assertNull(NegErrMessage("s", "rate-limited: too many requests (30 > 10)", 10L).statedCap) + } + + @Test + fun refusalsThatAreNotAboutSizeStateNothing() { + listOf( + "auth-required: we only serve negentropy to authenticated users", + "blocked: pubkey is banned", + "error: negentropy disabled", + "closed: unknown subscription handle", + ).forEach { + assertFalse(NegErrMessage.isOverflow(it), "read as an overflow: $it") + assertNull(NegErrMessage("s", it, 42L).statedCap, "read a cap from: $it") + } + } + + @Test + fun theWordingsThatDoMeanOverflow() { + listOf( + "blocked: query matches too many records (5 > 1)", + "blocked: too many query results", + "error: result set too large", + "blocked: results too large", + "blocked: max_sync_events exceeded", + ).forEach { assertTrue(NegErrMessage.isOverflow(it), "not read as an overflow: $it") } + } + + @Test + fun aNonsensicalCapIsRefused() { + // Zero would wedge a client at a window that can never fit. + assertNull(NegErrMessage("s", "blocked: too many query results", 0L).statedCap) + assertNull(NegErrMessage("s", "blocked: too many records (5 > 0)").statedCap) + assertNull(NegErrMessage("s", "blocked: too many query results", -1L).statedCap) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt index 7e45082421..a2d3e10df3 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageDeserializer.kt @@ -121,10 +121,18 @@ class MessageDeserializer : StdDeserializer(Message::class.java) { } NegErrMessage.LABEL -> { - NegErrMessage( - subId = jp.nextTextValue(), - reason = jp.nextTextValue() ?: "", - ) + val subId = jp.nextTextValue() + val reason = jp.nextTextValue() ?: "" + // The optional fourth element, the relay's own cap. Read by + // stepping one token: anything that is not a number leaves + // the loop below to drain the frame, as before. + val cap = + if (jp.nextToken() == JsonToken.VALUE_NUMBER_INT) { + jp.longValue + } else { + null + } + NegErrMessage(subId, reason, cap) } else -> { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index 86274bf1e6..76671a396f 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -129,6 +129,7 @@ class MessageSerializer : StdSerializer(Message::class.java) { is NegErrMessage -> { gen.writeString(msg.subId) gen.writeString(msg.reason) + msg.cap?.let { gen.writeNumber(it) } } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt index 8b3bce89ba..7a71cc9a1b 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt @@ -124,6 +124,70 @@ class Nip77SerializationTest { assertEquals(msg.reason, jacksonDeserialized.reason) } + @Test + fun serializeNegErrMessageWithCap_matchesJackson() { + val msg = NegErrMessage("neg-sub1", "blocked: too many query results", 1_000_000L) + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + + assertEquals(jacksonJson, kotlinJson) + assertEquals("""["NEG-ERR","neg-sub1","blocked: too many query results",1000000]""", kotlinJson) + } + + @Test + fun serializeNegErrMessageWithoutCap_staysThreeElements() { + // NIP-77 describes a three-element NEG-ERR. A refusal with no cap to + // state must stay exactly that, rather than growing a fourth element + // every existing reader then has to tolerate. + val msg = NegErrMessage("neg-sub1", "closed: timeout") + assertEquals("""["NEG-ERR","neg-sub1","closed: timeout"]""", KotlinSerializationMapper.toJson(msg)) + assertEquals("""["NEG-ERR","neg-sub1","closed: timeout"]""", JacksonMapper.toJson(msg)) + } + + @Test + fun deserializeNegErrMessageWithCap_bothMappers() { + val json = """["NEG-ERR","neg-sub1","blocked: too many query results",1000000]""" + + val jackson = JacksonMapper.fromJsonToMessage(json) + assertTrue(jackson is NegErrMessage) + assertEquals(1_000_000L, jackson.cap) + assertEquals(1_000_000L, jackson.statedCap) + + val kotlin = KotlinSerializationMapper.fromJsonToMessage(json) + assertTrue(kotlin is NegErrMessage) + assertEquals(1_000_000L, kotlin.cap) + } + + @Test + fun deserializeNegErrMessageWithGarbageFourthElement_bothMappers() { + // A relay that puts something else there is telling us nothing; it must + // not break the frame that carries the reason. + val json = """["NEG-ERR","neg-sub1","blocked: too many query results","soon"]""" + + val jackson = JacksonMapper.fromJsonToMessage(json) + assertTrue(jackson is NegErrMessage) + assertEquals("blocked: too many query results", jackson.reason) + assertEquals(null, jackson.cap) + + val kotlin = KotlinSerializationMapper.fromJsonToMessage(json) + assertTrue(kotlin is NegErrMessage) + assertEquals("blocked: too many query results", kotlin.reason) + assertEquals(null, kotlin.cap) + } + + @Test + fun negErrMessageWithCap_crossDeserialization() { + val msg = NegErrMessage("neg-sub1", "blocked: too many records", 500_000L) + + val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(JacksonMapper.toJson(msg)) + assertTrue(kotlinDeserialized is NegErrMessage) + assertEquals(500_000L, kotlinDeserialized.cap) + + val jacksonDeserialized = JacksonMapper.fromJsonToMessage(KotlinSerializationMapper.toJson(msg)) + assertTrue(jacksonDeserialized is NegErrMessage) + assertEquals(500_000L, jacksonDeserialized.cap) + } + // ========================================================================= // NEG-OPEN Command (client-to-relay) Tests // ========================================================================= From 18998c897c655ad30195ff2282d656eb55e0e8d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:42:46 +0000 Subject: [PATCH 144/227] negentropy: size reconcile windows from the caller's own index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A NEG-OPEN is all-or-nothing at both ends of the wire and neither end can see the other's size. The relay half has been handled since windowing landed — refuse, halve, retry. The client half has not: localEntries has to hold every matching (created_at, id) pair before the first NEG-OPEN goes out, so peak memory is a property of the CORPUS, not of the window. On a multi-million-event filter that list is the sync's high-water mark, and it is built even when the sync then splits into windows that each touch a fraction of it. NegentropyLocalIndex is that half. A caller whose store answers by range passes an index instead of a list, and the engine reads a window's worth at a time. count() is what makes it work: a window is sized BEFORE the round trip, so entriesFor() is only ever asked for something bounded. Callers that pass a list are unchanged — internally the list becomes an index that sorts once and binary-searches per window, exactly what the engine did inline before. targetWindow (0 = off, the old behaviour) turns the two signals into one loop. Our count splits a window before asking; their refusal shrinks the target — straight to the relay's stated cap where there is one, halved where there isn't — and windows that reconcile in one piece grow it back toward, never past, the caller's number. Neither side knows anything about the other and the same work queue absorbs both, which is what makes it adapt rather than need tuning. peerCap carries the relay's number back out, so a caller can persist it and start the NEXT sync at a window that fits. The local pre-split deliberately does NOT count against MAX_WINDOWS: that backstop exists for an overflow loop that never converges, while this split is driven by a number that provably halves with the range. Also here, because it is the same loop: page the window that overflowed rather than the whole filter. A second dense enough to exceed the cap is reachable — created_at has second granularity and is author-controlled — and negentropySyncOrFetch used to answer it by re-paging everything, including every window that had already reconciled cleanly. reconcileWindows now takes onUnreconcilableWindow and hands that window over; the sweep carries on with the rest of the range, so a dense second costs that second. Raw negentropySync/negentropyReconcile callers that pass no hook still get the exception, unchanged. pagedFallback stays conservative and now means "any part of this range came over REQ rather than a reconcile", with pagedWindows saying how much — the distinction matters to anyone recording coverage, since a paged walk booked as a completed reconcile would claim a range nothing compared. The existing over-cap test is updated rather than deleted: its ten events share one created_at, so the whole filter IS the un-reconcilable window — same events, now via the window path instead of by abandoning the sync. Its sibling test, that raw negentropySync still throws, is untouched. --- .../accessories/NegentropyLocalIndex.kt | 118 +++++++ .../accessories/NegentropySyncException.kt | 4 + .../NostrClientNegentropyFanOutExt.kt | 9 +- .../NostrClientNegentropySyncExt.kt | 330 +++++++++++++----- .../accessories/NegentropyLocalIndexTest.kt | 90 +++++ .../relay/NostrClientNegentropySyncTest.kt | 169 ++++++++- 6 files changed, 622 insertions(+), 98 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyLocalIndex.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyLocalIndexTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyLocalIndex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyLocalIndex.kt new file mode 100644 index 0000000000..6ed4d51619 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyLocalIndex.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.IdAndTime + +/** + * The caller's own matching set, read one `created_at` window at a time. + * + * The list overloads of [negentropySync] / [negentropyReconcile] need every + * matching `(created_at, id)` pair before the first NEG-OPEN goes out, which + * makes peak memory a property of the corpus: a multi-million-event filter is + * a multi-million-entry list held for the whole sync, whether or not the sync + * ends up splitting into windows that each touch a fraction of it. + * + * A caller whose store can answer by range doesn't need that. Passing an index + * instead lets the window engine ask for a window's worth at a time, so the + * high-water mark becomes the size of one window — which the engine also sizes, + * from [count], before spending a round trip on it. + * + * Both methods are called on the reconciler coroutines, possibly concurrently + * when `reconcileConcurrency > 1`, and possibly more than once for the same + * window (a window that overflows is re-asked as halves). Implementations + * should be cheap and side-effect free; a store-backed one usually is, since + * these are index scans. + */ +interface NegentropyLocalIndex { + /** + * How many local events fall inside [window], or null when the store + * cannot answer cheaply. + * + * This is what lets the engine split a window BEFORE asking the relay for + * it — the only signal available about our own side, and the one that + * bounds what [entriesFor] will have to materialise. Null disables that + * pre-split for the window; the relay's own refusal is then the only thing + * that shrinks it, exactly as before this method existed. + */ + suspend fun count(window: Filter): Int? + + /** The `(created_at, id)` pairs inside [window]. Order does not matter. */ + suspend fun entriesFor(window: Filter): List + + companion object { + /** Nothing held locally: the sync downloads the relay's whole matched set. */ + val Empty: NegentropyLocalIndex = + object : NegentropyLocalIndex { + override suspend fun count(window: Filter) = 0 + + override suspend fun entriesFor(window: Filter) = emptyList() + } + + /** + * An index over a list already in memory — what the list overloads use, + * so they behave exactly as they did: sorted once, then binary-searched + * per window. + */ + fun of(entries: List): NegentropyLocalIndex = if (entries.isEmpty()) Empty else SortedListIndex(entries.sortedBy { it.createdAt }) + } +} + +private class SortedListIndex( + private val sorted: List, +) : NegentropyLocalIndex { + override suspend fun count(window: Filter): Int = slice(window).size + + override suspend fun entriesFor(window: Filter): List = slice(window) + + /** + * The `createdAt`-range slice of [sorted] (ascending by `createdAt`) that + * belongs to `[since, until]` (both inclusive, NIP-01 semantics). + * Binary-searched so window splits stay O(log n) over multi-million sets. + */ + private fun slice(window: Filter): List { + val since = window.since + val until = window.until + if (sorted.isEmpty() || (since == null && until == null)) return sorted + + val lo = since ?: 0L + val hi = until ?: Long.MAX_VALUE + + // first index with createdAt >= lo + var start = 0 + var e = sorted.size + while (start < e) { + val mid = (start + e) ushr 1 + if (sorted[mid].createdAt < lo) start = mid + 1 else e = mid + } + + // first index with createdAt > hi + var end = start + e = sorted.size + while (end < e) { + val mid = (end + e) ushr 1 + if (sorted[mid].createdAt <= hi) end = mid + 1 else e = mid + } + + return if (start >= end) emptyList() else sorted.subList(start, end) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropySyncException.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropySyncException.kt index f6018a8098..1a6dacc98d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropySyncException.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropySyncException.kt @@ -43,12 +43,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl * @property window the filter slice that failed. * @property reason machine-readable category — branch on this to recover. * @property detail the underlying specifics (a relay's `NEG-ERR` text, `timeout`, …). + * @property cap the relay's own `max_sync_events` when its refusal stated one + * (see [com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage.statedCap]). + * Worth persisting per relay: it is what sizes the first window next time. */ class NegentropySyncException( val relay: NormalizedRelayUrl, val window: Filter, val reason: Reason, val detail: String, + val cap: Long? = null, ) : Exception("NIP-77 sync of $relay failed ($reason): $detail") { enum class Reason { /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt index 7ac08647f5..e941af4417 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt @@ -77,6 +77,8 @@ suspend fun negentropySyncFanOut( relay: NormalizedRelayUrl, filter: Filter, localEntries: List = emptyList(), + localIndex: NegentropyLocalIndex? = null, + targetWindow: Int = 0, maxEvents: Int = 0, reqsPerClient: Int = 10, fetchBatch: Int = 250, @@ -135,8 +137,6 @@ suspend fun negentropySyncFanOut( val producer = launch { try { - val sorted = - if (localEntries.size > 1) localEntries.sortedBy { it.createdAt } else localEntries // Windows reconcile round-robin ACROSS the clients so // server-side snapshot builds parallelize per connection // (a single connection produced ids at only ~9k/s and @@ -145,17 +145,18 @@ suspend fun negentropySyncFanOut( clients = clients, relay = relay, filter = filter, - localEntries = sorted, + local = localIndex ?: NegentropyLocalIndex.of(localEntries), idleTimeoutMs = idleTimeoutMs, batchSize = fetchBatch, reconcileConcurrency = reconcileConcurrency, + targetWindow = targetWindow, onWindow = { windows.incrementAndFetch() }, onNeed = { need.addAndFetch(it) }, onHave = { have.addAndFetch(it) }, sendNeedBatch = { batch -> idBatches.send(batch) }, - sendHaveBatch = if (localEntries.isEmpty()) null else { _ -> }, + sendHaveBatch = if (localEntries.isEmpty() && localIndex == null) null else { _ -> }, ) } finally { idBatches.close() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt index 611720876a..6595cc763e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt @@ -66,12 +66,17 @@ import kotlin.math.min * @property downloaded distinct events actually delivered through `onEvent`. * @property windows number of `created_at` windows the matched set was split * into (`1` when the relay reconciled the whole filter in one shot). + * @property peerCap the relay's own `max_sync_events`, when a refusal during + * this sync stated one. Worth persisting per relay: it is the number that + * sizes the first window of the NEXT sync, and it is not discoverable any + * other way. */ class NegentropySyncResult( val needCount: Int, val haveCount: Int, val downloaded: Int, val windows: Int, + val peerCap: Long? = null, ) /** @@ -162,12 +167,16 @@ suspend fun INostrClient.negentropySync( reconcileConcurrency: Int = 1, idBufferBatches: Int = maxConcurrentReqs * 4, localEntries: List = emptyList(), + localIndex: NegentropyLocalIndex? = null, + targetWindow: Int = 0, + onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropySyncResult { val need = AtomicInt(0) val windows = AtomicInt(0) var downloaded = 0 + var peerCap: Long? = null // Pin the relay in the pool's "desired" set for the whole sync. A NEG-OPEN is not // a REQ, so during a reconcile round (before that window's first download REQ @@ -195,8 +204,11 @@ suspend fun INostrClient.negentropySync( maxConcurrentReqs = maxConcurrentReqs, reconcileConcurrency = reconcileConcurrency, idBufferBatches = idBufferBatches, - localEntries = localEntries, + local = localIndex ?: NegentropyLocalIndex.of(localEntries), + targetWindow = targetWindow, + onUnreconcilableWindow = onUnreconcilableWindow, onWindow = { windows.incrementAndFetch() }, + onPeerCap = { peerCap = it }, // Only accumulate here; progress is reported from the // single consumer loop below so the user callback is never // invoked from two coroutines at once. @@ -228,6 +240,7 @@ suspend fun INostrClient.negentropySync( haveCount = 0, downloaded = downloaded, windows = windows.load(), + peerCap = peerCap, ) } @@ -241,6 +254,9 @@ suspend fun INostrClient.negentropySync( reconcileConcurrency: Int = 1, idBufferBatches: Int = maxConcurrentReqs * 4, localEntries: List = emptyList(), + localIndex: NegentropyLocalIndex? = null, + targetWindow: Int = 0, + onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropySyncResult = @@ -254,6 +270,9 @@ suspend fun INostrClient.negentropySync( reconcileConcurrency = reconcileConcurrency, idBufferBatches = idBufferBatches, localEntries = localEntries, + localIndex = localIndex, + targetWindow = targetWindow, + onUnreconcilableWindow = onUnreconcilableWindow, onProgress = onProgress, onEvent = onEvent, ) @@ -263,16 +282,28 @@ suspend fun INostrClient.negentropySync( * * @property downloaded distinct events delivered through `onEvent` (across whichever * path ran). - * @property pagedFallback `true` if negentropy could not reconcile and the events - * came from [fetchAllPages] instead. + * @property pagedFallback `true` if ANY part of the range came from + * [fetchAllPages] rather than a reconcile — either the whole filter (the + * relay could not reconcile at all) or the individual windows counted by + * [pagedWindows]. Deliberately conservative: a caller recording what it has + * covered must not book a paged walk as a completed reconcile, and one + * un-reconcilable second in the range is enough to make that claim untrue. * @property negentropy the negentropy outcome when it succeeded; `null` on fallback. - * @property fallbackCause why negentropy was abandoned; `null` when it succeeded. + * @property fallbackCause why negentropy was abandoned for the WHOLE filter; + * `null` when it was not — including when individual windows were paged, which + * have no single cause between them. + * @property pagedWindows how many individual `created_at` windows were paged + * inside an otherwise-successful negentropy sync — seconds so dense the relay + * would not reconcile them at any window size. `0` for almost every sync; + * non-zero means part of the range came over REQ and is subject to a paged + * walk's limits rather than a reconcile's guarantees. */ class NegentropyOrFetchResult( val downloaded: Int, val pagedFallback: Boolean, val negentropy: NegentropySyncResult?, val fallbackCause: NegentropySyncException?, + val pagedWindows: Int = 0, ) /** @@ -308,11 +339,14 @@ suspend fun INostrClient.negentropySyncOrFetch( reconcileConcurrency: Int = 1, idBufferBatches: Int = maxConcurrentReqs * 4, localEntries: List = emptyList(), + localIndex: NegentropyLocalIndex? = null, + targetWindow: Int = 0, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropyOrFetchResult { val seen = HashSet() var delivered = 0 + var pagedWindows = 0 // Shared dedup + cap across both phases. Returns true if the event was new and // delivered. Both phases run sequentially, so no concurrent access. @@ -337,9 +371,32 @@ suspend fun INostrClient.negentropySyncOrFetch( reconcileConcurrency = reconcileConcurrency, idBufferBatches = idBufferBatches, localEntries = localEntries, + localIndex = localIndex, + targetWindow = targetWindow, + // One second the relay will not reconcile at any size costs + // that second, not the sync. Without this the exception below + // catches it and re-pages the WHOLE filter — every window that + // already reconciled cleanly walked again over REQ, which on a + // large corpus is the entire cost negentropy was there to save. + onUnreconcilableWindow = { window -> + pagedWindows++ + val pageTimeoutMs = if (idleTimeoutMs > 0) idleTimeoutMs else DEFAULT_DOWNLOAD_IDLE_MS + fetchAllPages(relay, listOf(window), pageTimeoutMs) { event -> + if (accept(event)) onProgress?.invoke(delivered, delivered) + } + }, onProgress = onProgress, ) { accept(it) } - NegentropyOrFetchResult(delivered, pagedFallback = false, negentropy = result, fallbackCause = null) + NegentropyOrFetchResult( + delivered, + // Any paged window makes this not a clean reconcile — see the + // property doc: under-reporting it would let a caller record + // coverage it never compared. + pagedFallback = pagedWindows > 0, + negentropy = result, + fallbackCause = null, + pagedWindows = pagedWindows, + ) } catch (e: NegentropySyncException) { // Negentropy couldn't enumerate the set — page the whole filter instead, // skipping anything the negentropy attempt already delivered. fetchAllPages @@ -349,7 +406,13 @@ suspend fun INostrClient.negentropySyncOrFetch( fetchAllPages(relay, listOf(pageFilter), pageTimeoutMs) { event -> if (accept(event)) onProgress?.invoke(delivered, delivered) } - NegentropyOrFetchResult(delivered, pagedFallback = true, negentropy = null, fallbackCause = e) + NegentropyOrFetchResult( + delivered, + pagedFallback = true, + negentropy = null, + fallbackCause = e, + pagedWindows = pagedWindows, + ) } } @@ -363,6 +426,8 @@ suspend fun INostrClient.negentropySyncOrFetch( reconcileConcurrency: Int = 1, idBufferBatches: Int = maxConcurrentReqs * 4, localEntries: List = emptyList(), + localIndex: NegentropyLocalIndex? = null, + targetWindow: Int = 0, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropyOrFetchResult = @@ -376,6 +441,8 @@ suspend fun INostrClient.negentropySyncOrFetch( reconcileConcurrency = reconcileConcurrency, idBufferBatches = idBufferBatches, localEntries = localEntries, + localIndex = localIndex, + targetWindow = targetWindow, onProgress = onProgress, onEvent = onEvent, ) @@ -411,9 +478,12 @@ private suspend fun INostrClient.syncPipeline( maxConcurrentReqs: Int, reconcileConcurrency: Int, idBufferBatches: Int, - localEntries: List, + local: NegentropyLocalIndex, + targetWindow: Int, onWindow: () -> Unit, onNeed: (Int) -> Unit, + onPeerCap: ((Long) -> Unit)?, + onUnreconcilableWindow: (suspend (Filter) -> Unit)?, deliver: suspend (Event) -> Unit, ) = coroutineScope { val idBatches = Channel>(idBufferBatches.coerceAtLeast(1)) @@ -430,21 +500,20 @@ private suspend fun INostrClient.syncPipeline( } } - // reconcileWindows needs the local set sorted by createdAt (it binary-searches - // each window's slice). Empty/singleton sets are already trivially sorted. - val sortedLocal = if (localEntries.size > 1) localEntries.sortedBy { it.createdAt } else localEntries - reconcileWindows( clients = listOf(this@syncPipeline), relay = relay, filter = filter, - localEntries = sortedLocal, + local = local, idleTimeoutMs = idleTimeoutMs, batchSize = fetchBatch, reconcileConcurrency = reconcileConcurrency, + targetWindow = targetWindow, onWindow = onWindow, onNeed = onNeed, onHave = {}, + onPeerCap = onPeerCap, + onUnreconcilableWindow = onUnreconcilableWindow, sendNeedBatch = { batch -> idBatches.send(batch) }, sendHaveBatch = null, ) @@ -455,16 +524,29 @@ private suspend fun INostrClient.syncPipeline( /** * The shared window engine behind [negentropySync] and [negentropyReconcile]: - * reconciles [filter] against [localEntries], splitting into `created_at` - * windows whenever the relay rejects the set as too large, with up to - * [reconcileConcurrency] windows reconciling at once from a shared work - * queue. Each window's local subset is sliced out of [localEntries] (which - * MUST be sorted by `createdAt`) so both sides always reconcile the same - * slice of the timeline. + * reconciles [filter] against [local], splitting into `created_at` windows, + * with up to [reconcileConcurrency] windows reconciling at once from a shared + * work queue. Each window reconciles against that window's slice of [local], so + * both sides always compare the same slice of the timeline. + * + * Two independent things split a window, and the same queue absorbs both: + * + * - **The relay refuses it** (strfry's `max_sync_events`). Known only after a + * round trip, and the only signal available about THEIR size. + * - **We hold more than [targetWindow] in it**, per [NegentropyLocalIndex.count], + * which is known before the round trip and is what bounds the entries this + * engine asks [local] to materialise. Off when [targetWindow] is `0` (the + * default), which is the pre-existing behaviour: one window until refused. + * + * Neither side can see the other's size, so [targetWindow] adapts within the + * sync: a refusal shrinks it — straight to the relay's own cap when the refusal + * states one ([NegErrMessage.statedCap]), halved when it does not — and windows + * that reconcile in one piece grow it back toward, never past, the caller's + * number. * * Throws [NegentropySyncException] for any window negentropy cannot reconcile - * (a minimal window still over the cap, or an unavailable/erroring relay); the - * failure cancels the whole scope. + * (a minimal window still over the cap with no [onUnreconcilableWindow] to hand + * it to, or an unavailable/erroring relay); the failure cancels the whole scope. */ @OptIn(ExperimentalAtomicApi::class) internal suspend fun reconcileWindows( @@ -474,13 +556,19 @@ internal suspend fun reconcileWindows( clients: List, relay: NormalizedRelayUrl, filter: Filter, - localEntries: List, + local: NegentropyLocalIndex, idleTimeoutMs: Long, batchSize: Int, reconcileConcurrency: Int, + targetWindow: Int = 0, onWindow: () -> Unit, onNeed: (Int) -> Unit, onHave: (Int) -> Unit, + onPeerCap: ((Long) -> Unit)? = null, + // Given a minimal window the relay will not reconcile at any size, instead + // of throwing. The caller drains it however it can (paging it over REQ) and + // the sweep carries on with the rest of the filter. + onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null, sendNeedBatch: suspend (List) -> Unit, sendHaveBatch: (suspend (List) -> Unit)?, ) = coroutineScope { @@ -503,6 +591,33 @@ internal suspend fun reconcileWindows( // the tens–hundreds, so the cap is orders of magnitude above any real sync. val totalWindows = AtomicInt(1) + // The largest window this sync will ask for, in events. Shrinks on a + // refusal, recovers toward the caller's number on clean windows, and is + // read only where a local count exists to compare it against — with + // targetWindow at 0 nothing below this line does anything. + val budget = AtomicInt(targetWindow) + + // Splits a window in two and queues both halves. Returns false when the + // window is already minimal — `created_at` is in seconds, so that is the + // floor, not a tuning choice. + fun splitInto( + pendingWindow: Filter, + lo: Long, + hi: Long, + ): Boolean { + if (hi - lo <= MIN_WINDOW_SECONDS) return false + val mid = lo + (hi - lo) / 2 + remaining.incrementAndFetch() + // The lower child gets the finite midpoint; the upper child KEEPS this + // window's original `until` (which may be null = unbounded). Replacing + // null with `now()` here would drop every event dated after now() + // (clock skew) once any split happens, while the un-split path would + // have included them. + pending.trySend(pendingWindow.copy(since = lo, until = mid)) + pending.trySend(pendingWindow.copy(since = mid + 1, until = pendingWindow.until)) + return true + } + val reconcilers = List(reconcileConcurrency.coerceAtLeast(1)) { reconcilerIndex -> launch { @@ -510,11 +625,27 @@ internal suspend fun reconcileWindows( for (window in pending) { coroutineContext.ensureActive() + val lo = window.since ?: 0L + val hi = window.until ?: TimeUtils.now() + + // Our own side, before the round trip. Deliberately NOT + // counted against MAX_WINDOWS: that backstop guards against + // an overflow loop that never converges, while this split is + // driven by a number that provably halves with the range. + val ceiling = budget.load() + if (ceiling > 0 && hi - lo > MIN_WINDOW_SECONDS) { + val mine = local.count(window) + if (mine != null && mine > ceiling) { + splitInto(window, lo, hi) + continue + } + } + val outcome = client.reconcileStreaming( relay = relay, filter = window, - localEntries = entriesForWindow(localEntries, window.since, window.until), + localEntries = local.entriesFor(window), idleTimeoutMs = idleTimeoutMs, fetchBatch = batchSize, onNeed = onNeed, @@ -526,21 +657,53 @@ internal suspend fun reconcileWindows( when (outcome) { is ReconcileOutcome.Complete -> { onWindow() + // A window that fitted is evidence the budget can + // recover — gently, and never past what the caller + // asked for, so a sync that met one dense stretch + // does not stay small for the rest of the timeline. + if (targetWindow > 0) { + val now = budget.load() + if (now < targetWindow) { + budget.store(minOf(targetWindow, (now * BUDGET_GROWTH).toInt().coerceAtLeast(now + 1))) + } + } if (remaining.decrementAndFetch() == 0) pending.close() } is ReconcileOutcome.Overflow -> { - val lo = window.since ?: 0L - val hi = window.until ?: TimeUtils.now() + // What they will take, when they said so: one step + // instead of a halving ladder, for this sync and — + // via onPeerCap — for whatever the caller persists. + outcome.cap?.let { cap -> + onPeerCap?.invoke(cap) + if (targetWindow > 0) { + val fitted = (cap * CAP_MARGIN).toInt().coerceAtLeast(1) + if (fitted < budget.load()) budget.store(fitted) + } + } + if (outcome.cap == null && targetWindow > 0) { + // No number to go on: halve and find out. + budget.store((budget.load() / 2).coerceAtLeast(1)) + } if (hi - lo <= MIN_WINDOW_SECONDS) { - // A minimal window that still overflows: negentropy - // genuinely can't enumerate this slice. Surface it — - // paging is the caller's call. + // A minimal window that still overflows: + // negentropy genuinely can't enumerate this + // slice. Hand it to the caller if it has a way + // to drain it, otherwise surface it — paging is + // the caller's call either way. + val fallback = onUnreconcilableWindow + if (fallback != null) { + fallback(window) + onWindow() + if (remaining.decrementAndFetch() == 0) pending.close() + continue + } throw NegentropySyncException( relay = relay, window = window, reason = NegentropySyncException.Reason.OVER_MAX_SYNC_EVENTS, detail = "created_at window [$lo, $hi] still exceeds the relay's max_sync_events", + cap = outcome.cap, ) } if (totalWindows.addAndFetch(2) > MAX_WINDOWS) { @@ -554,15 +717,7 @@ internal suspend fun reconcileWindows( detail = "created_at window split exceeded $MAX_WINDOWS windows without converging; the relay likely rejects negentropy with an overflow-looking error", ) } - val mid = lo + (hi - lo) / 2 - remaining.incrementAndFetch() - // The lower child gets the finite midpoint; the upper child - // KEEPS this window's original `until` (which may be null = - // unbounded). Replacing null with `now()` here would drop - // every event dated after now() (clock skew) once any split - // happens, while the un-split path would have included them. - pending.send(window.copy(since = lo, until = mid)) - pending.send(window.copy(since = mid + 1, until = window.until)) + splitInto(window, lo, hi) } is ReconcileOutcome.Failed -> @@ -580,46 +735,17 @@ internal suspend fun reconcileWindows( reconcilers.joinAll() } -/** - * The `createdAt`-range slice of [sorted] (ascending by `createdAt`) that - * belongs to the window `[since, until]` (both inclusive, NIP-01 semantics). - * Binary-searched so window splits stay O(log n) over multi-million local sets. - */ -private fun entriesForWindow( - sorted: List, - since: Long?, - until: Long?, -): List { - if (sorted.isEmpty() || (since == null && until == null)) return sorted - - val lo = since ?: 0L - val hi = until ?: Long.MAX_VALUE - - // first index with createdAt >= lo - var start = 0 - var e = sorted.size - while (start < e) { - val mid = (start + e) ushr 1 - if (sorted[mid].createdAt < lo) start = mid + 1 else e = mid - } - - // first index with createdAt > hi - var end = start - e = sorted.size - while (end < e) { - val mid = (end + e) ushr 1 - if (sorted[mid].createdAt <= hi) end = mid + 1 else e = mid - } - - return if (start >= end) emptyList() else sorted.subList(start, end) -} - private sealed interface ReconcileOutcome { /** Reconciliation completed; every id was streamed to the downloader. */ object Complete : ReconcileOutcome - /** Relay rejected the set as too large (strfry `max_sync_events`). */ - object Overflow : ReconcileOutcome + /** + * Relay rejected the set as too large (strfry `max_sync_events`). + * [cap] is the relay's own limit when the refusal stated one. + */ + class Overflow( + val cap: Long?, + ) : ReconcileOutcome /** Reconciliation could not complete; [detail] says why. */ class Failed( @@ -633,11 +759,14 @@ private sealed interface ReconcileOutcome { * @property needCount ids the relay has that the local set lacks (streamed to `onNeedIds`). * @property haveCount ids the local set has that the relay lacks (streamed to `onHaveIds`). * @property windows number of `created_at` windows the reconcile split into. + * @property peerCap the relay's own `max_sync_events`, when a refusal during + * this reconcile stated one. */ class NegentropyReconcileResult( val needCount: Int, val haveCount: Int, val windows: Int, + val peerCap: Long? = null, ) /** @@ -682,15 +811,19 @@ suspend fun INostrClient.negentropyReconcile( relay: NormalizedRelayUrl, filter: Filter, localEntries: List = emptyList(), + localIndex: NegentropyLocalIndex? = null, + targetWindow: Int = 0, batchSize: Int = 500, idleTimeoutMs: Long = 120_000L, reconcileConcurrency: Int = 1, + onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null, onHaveIds: (suspend (List) -> Unit)? = null, onNeedIds: suspend (List) -> Unit, ): NegentropyReconcileResult { val need = AtomicInt(0) val have = AtomicInt(0) val windows = AtomicInt(0) + var peerCap: Long? = null // Same connection-pinning trick as negentropySync: a NEG-OPEN is not a REQ, // so without a live subscription the pool would consider the relay unwanted @@ -698,24 +831,20 @@ suspend fun INostrClient.negentropyReconcile( val keepAliveSubId = newSubId() subscribe(keepAliveSubId, mapOf(relay to listOf(Filter(ids = listOf(KEEP_ALIVE_ID)))), null) try { - val sorted = - if (localEntries.size > 1) { - localEntries.sortedBy { it.createdAt } - } else { - localEntries - } - reconcileWindows( clients = listOf(this), relay = relay, filter = filter, - localEntries = sorted, + local = localIndex ?: NegentropyLocalIndex.of(localEntries), idleTimeoutMs = idleTimeoutMs, batchSize = batchSize, reconcileConcurrency = reconcileConcurrency, + targetWindow = targetWindow, onWindow = { windows.incrementAndFetch() }, onNeed = { need.addAndFetch(it) }, onHave = { have.addAndFetch(it) }, + onPeerCap = { peerCap = it }, + onUnreconcilableWindow = onUnreconcilableWindow, sendNeedBatch = onNeedIds, sendHaveBatch = onHaveIds, ) @@ -727,6 +856,7 @@ suspend fun INostrClient.negentropyReconcile( needCount = need.load(), haveCount = have.load(), windows = windows.load(), + peerCap = peerCap, ) } @@ -734,9 +864,12 @@ suspend fun INostrClient.negentropyReconcile( relay: String, filter: Filter, localEntries: List = emptyList(), + localIndex: NegentropyLocalIndex? = null, + targetWindow: Int = 0, batchSize: Int = 500, idleTimeoutMs: Long = 120_000L, reconcileConcurrency: Int = 1, + onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null, onHaveIds: (suspend (List) -> Unit)? = null, onNeedIds: suspend (List) -> Unit, ): NegentropyReconcileResult = @@ -744,9 +877,12 @@ suspend fun INostrClient.negentropyReconcile( relay = RelayUrlNormalizer.normalize(relay), filter = filter, localEntries = localEntries, + localIndex = localIndex, + targetWindow = targetWindow, batchSize = batchSize, idleTimeoutMs = idleTimeoutMs, reconcileConcurrency = reconcileConcurrency, + onUnreconcilableWindow = onUnreconcilableWindow, onHaveIds = onHaveIds, onNeedIds = onNeedIds, ) @@ -895,7 +1031,7 @@ private suspend fun INostrClient.reconcileStreaming( clock.bump() if (msg.subId == subId) { sawNegFrame = true - incoming.trySend(NegFrame.Err(msg.reason)) + incoming.trySend(NegFrame.Err(msg.reason, msg.statedCap)) } } @@ -965,7 +1101,11 @@ private suspend fun INostrClient.reconcileStreaming( when (frame) { is NegFrame.Err -> - return if (isOverflow(frame.reason)) ReconcileOutcome.Overflow else ReconcileOutcome.Failed(frame.reason) + return if (isOverflow(frame.reason)) { + ReconcileOutcome.Overflow(frame.cap) + } else { + ReconcileOutcome.Failed(frame.reason) + } is NegFrame.Msg -> { val result = session.processMessage(frame.payload) @@ -1014,6 +1154,8 @@ private sealed interface NegFrame { class Err( val reason: String, + // The relay's own max_sync_events, when the refusal stated one. + val cap: Long? = null, ) : NegFrame } @@ -1041,13 +1183,7 @@ private sealed interface NegFrame { * [reconcileWindows] also caps the total window count as a wording-independent * backstop, so a novel overflow-looking-but-not-shrinking error can never storm. */ -internal fun isOverflow(reason: String): Boolean = - reason.contains("too many records", ignoreCase = true) || - reason.contains("too many results", ignoreCase = true) || - reason.contains("too many query results", ignoreCase = true) || - reason.contains("result set too large", ignoreCase = true) || - reason.contains("results too large", ignoreCase = true) || - reason.contains("max_sync_events", ignoreCase = true) +internal fun isOverflow(reason: String): Boolean = NegErrMessage.isOverflow(reason) /** * A relay that advertises NIP-77 but refuses it at runtime signals the refusal with @@ -1159,6 +1295,22 @@ private const val MIN_WINDOW_SECONDS = 1L */ private const val MAX_WINDOWS = 100_000 +/** + * How much of a relay's stated `max_sync_events` a window actually aims for. + * The margin absorbs what the relay gains between stating that number and + * answering the next NEG-OPEN — asking for exactly the cap would be refused + * again by anything still being written to. + */ +private const val CAP_MARGIN = 0.8 + +/** + * How fast a shrunk window grows back toward the caller's target, per window + * that reconciled in one piece. Multiplicative and gentle on purpose: too small + * costs an extra round trip, too big costs a refused NEG-OPEN plus the snapshot + * scan the relay did before refusing it. + */ +private const val BUDGET_GROWTH = 1.25 + /** Bounded buffer between the download workers and the single delivery consumer. */ private const val DELIVERY_BUFFER = 256 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyLocalIndexTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyLocalIndexTest.kt new file mode 100644 index 0000000000..7967d1695d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyLocalIndexTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.IdAndTime +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The list-backed index is what the `localEntries` overloads become, so its + * slicing has to keep NIP-01's inclusive `since`/`until` exactly: a window that + * dropped its boundary second would leave events neither side ever compares, + * and the two sides of a reconcile would disagree about what the window holds. + */ +class NegentropyLocalIndexTest { + private fun idAt(second: Long) = IdAndTime(second, second.toString().padStart(64, '0')) + + private val index = NegentropyLocalIndex.of((1000L..1009L).map { idAt(it) }) + + private fun window( + since: Long?, + until: Long?, + ) = Filter(kinds = listOf(1), since = since, until = until) + + @Test + fun bothBoundsAreInclusive() = + runTest { + assertEquals(3, index.count(window(1002, 1004))) + assertEquals(listOf(1002L, 1003L, 1004L), index.entriesFor(window(1002, 1004)).map { it.createdAt }) + } + + @Test + fun anUnboundedSideReachesTheEnd() = + runTest { + assertEquals(5, index.count(window(1005, null))) + assertEquals(6, index.count(window(null, 1005))) + assertEquals(10, index.count(window(null, null))) + } + + @Test + fun aWindowOutsideEverythingIsEmpty() = + runTest { + assertEquals(0, index.count(window(2000, 3000))) + assertTrue(index.entriesFor(window(2000, 3000)).isEmpty()) + } + + @Test + fun aSingleSecondWindowHoldsThatSecond() = + runTest { + assertEquals(1, index.count(window(1007, 1007))) + assertEquals(listOf(1007L), index.entriesFor(window(1007, 1007)).map { it.createdAt }) + } + + @Test + fun entriesNeedNotArriveSorted() = + runTest { + val shuffled = NegentropyLocalIndex.of(listOf(idAt(1005), idAt(1001), idAt(1009), idAt(1003))) + assertEquals(2, shuffled.count(window(1001, 1003))) + assertEquals(listOf(1001L, 1003L), shuffled.entriesFor(window(1001, 1003)).map { it.createdAt }) + } + + @Test + fun theEmptyIndexAnswersZeroForEveryWindow() = + runTest { + assertEquals(0, NegentropyLocalIndex.Empty.count(window(1000, 2000))) + assertTrue(NegentropyLocalIndex.Empty.entriesFor(window(1000, 2000)).isEmpty()) + assertEquals(0, NegentropyLocalIndex.of(emptyList()).count(window(null, null))) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt index 52aea9a8b4..6f4b121146 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.geode.testing.preload import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropyLocalIndex import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySync @@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult +import com.vitorpamplona.quartz.nip01Core.store.IdAndTime import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -48,6 +50,8 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class NostrClientNegentropySyncTest : RelayClientTest() { @@ -252,6 +256,11 @@ class NostrClientNegentropySyncTest : RelayClientTest() { * The "try negentropy, else page" combinator: against the same over-cap relay * where raw [negentropySync] throws, [negentropySyncOrFetch] transparently pages * and delivers every event, reporting that it fell back. + * + * Every event here shares one `created_at`, so the whole filter IS the + * un-reconcilable window: it is drained as one paged window rather than by + * abandoning the sync, which is why `fallbackCause` is null. On a filter + * spanning more than this second, everything outside it still reconciles. */ @Test fun orFetchPagesWhenNegentropyCannotReconcile() = @@ -275,11 +284,9 @@ class NostrClientNegentropySyncTest : RelayClientTest() { assertEquals(10, got.map { it.id }.toSet().size, "all events delivered via the paging fallback") assertEquals(10, result.downloaded) - assertTrue(result.pagedFallback, "it should have fallen back to paging") - assertEquals( - NegentropySyncException.Reason.OVER_MAX_SYNC_EVENTS, - result.fallbackCause?.reason, - ) + assertTrue(result.pagedFallback, "part of the range came over REQ, so this was not a clean reconcile") + assertEquals(1, result.pagedWindows, "exactly the one un-reconcilable window was paged") + assertNull(result.fallbackCause, "the sync was not abandoned — one window was drained by paging") } finally { client.disconnect() scope.cancel() @@ -375,4 +382,156 @@ class NostrClientNegentropySyncTest : RelayClientTest() { assertFalse(result.pagedFallback, "negentropy should have handled it") assertEquals(8, result.negentropy?.downloaded) } + + /** + * The caller's own count splits a window BEFORE the relay is asked for it. + * + * Nothing here overflows — the relay would have reconciled the whole filter + * in one NEG-OPEN — so every split is driven by [NegentropyLocalIndex.count] + * against `targetWindow`. That is what bounds the entries a caller has to + * materialise: without it the first (and only) window is the whole filter, + * and the local set for it is the whole corpus. + */ + @Test + fun targetWindowSplitsFromTheLocalCountAlone() = + runBlocking { + // 40 seconds of history, one event each; we already hold the even ones. + val all = (0 until 40).map { SyntheticEvents.fakeEvent(idSeed = it + 1, kind = 1, createdAt = 1000L + it) } + defaultRelay.preload(all) + val ours = all.filterIndexed { i, _ -> i % 2 == 0 }.map { IdAndTime(it.createdAt, it.id) } + + val asked = mutableListOf() + val index = + object : NegentropyLocalIndex { + val inner = NegentropyLocalIndex.of(ours) + + override suspend fun count(window: Filter): Int { + asked += window + return inner.count(window) ?: 0 + } + + override suspend fun entriesFor(window: Filter) = inner.entriesFor(window) + } + + val got = mutableListOf() + val result = + withTimeout(60_000) { + client.negentropySync( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + localIndex = index, + targetWindow = 5, + ) { got.add(it) } + } + + assertEquals(20, got.map { it.id }.toSet().size, "only the half we lacked comes down") + assertTrue(result.windows > 1, "the local count alone must have split the filter") + assertTrue(asked.isNotEmpty(), "windows must be counted before they are asked for") + assertNull(result.peerCap, "nothing was refused, so there is no cap to report") + } + + /** Passing no target keeps the old shape: one window until the relay objects. */ + @Test + fun withoutATargetTheLocalCountIsNeverConsulted() = + runBlocking { + defaultRelay.preload(SyntheticEvents.batch(20, kind = 1)) + var counted = 0 + val index = + object : NegentropyLocalIndex { + override suspend fun count(window: Filter): Int { + counted++ + return 1_000_000 + } + + override suspend fun entriesFor(window: Filter) = emptyList() + } + + val result = + withTimeout(20_000) { + client.negentropySync( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + localIndex = index, + ) { } + } + + assertEquals(0, counted, "targetWindow = 0 must not ask the store anything") + assertEquals(1, result.windows) + assertEquals(20, result.downloaded) + } + + /** + * A relay that refuses for size states its cap, and the client reports it — + * so the next sync can start at a window that fits instead of rediscovering + * it by halving. + */ + @Test + fun theRelaysCapIsReportedBack() = + runBlocking { + val hub = InProcessRelays(negentropySettings = NegentropySettings(maxSyncEvents = 3)) + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(hub, scope) + try { + val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7786/") + hub.getOrCreate(url).preload((0 until 12).map { SyntheticEvents.fakeEvent(idSeed = it + 1, kind = 1, createdAt = 1000L + it) }) + + val result = + withTimeout(60_000) { + client.negentropySync(relay = url, filter = Filter(kinds = listOf(1))) { } + } + + assertEquals(12, result.downloaded) + assertTrue(result.windows > 1) + assertEquals(3L, result.peerCap, "the relay stated its own max_sync_events") + } finally { + client.disconnect() + scope.cancel() + hub.close() + } + } + + /** + * One second the relay will not reconcile at any window size costs that + * second, not the sync. + * + * The whole point of the [NegentropyOrFetchResult.pagedWindows] path: the + * dense second is drained over REQ while everything around it still + * reconciles. Before, the exception from that one window abandoned the whole + * sync and re-paged the entire filter — on a large corpus, exactly the cost + * negentropy was there to avoid. + */ + @Test + fun oneUnreconcilableSecondDoesNotCostTheRestOfTheFilter() = + runBlocking { + val hub = InProcessRelays(negentropySettings = NegentropySettings(maxSyncEvents = 3)) + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(hub, scope) + try { + val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7787/") + // Ten events crammed into one second — no created_at window can + // separate them — plus five ordinary seconds around them. + val dense = (1..10).map { SyntheticEvents.fakeEvent(idSeed = it, kind = 1, createdAt = 1000L) } + val sparse = (0 until 5).map { SyntheticEvents.fakeEvent(idSeed = 100 + it, kind = 1, createdAt = 2000L + it) } + hub.getOrCreate(url).preload(dense + sparse) + + val got = mutableListOf() + val result = + withTimeout(60_000) { + client.negentropySyncOrFetch( + relay = url, + filter = Filter(kinds = listOf(1)), + ) { got.add(it) } + } + + assertEquals(15, got.map { it.id }.toSet().size, "everything is delivered, by whichever route") + assertEquals(1, result.pagedWindows, "only the dense second is paged") + assertNull(result.fallbackCause, "the sync itself was never abandoned") + val negentropy = assertNotNull(result.negentropy, "the rest of the range still reconciled") + assertTrue(negentropy.windows > 1) + } finally { + client.disconnect() + scope.cancel() + hub.close() + } + } } From 8555309492a6fc54039cc06d7d08fa3077cd09bb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 20:12:36 +0000 Subject: [PATCH 145/227] negentropy: audit fixes over the windowing change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A read-back over the two commits before this, rather than a failure — which is the only way these would have turned up, since every one of them lives on a path that runs when something has already gone wrong. **accept() is no longer single-threaded, and its comment said it was.** "Both phases run sequentially, so no concurrent access" was true right up until a paged window started running on a reconciler coroutine while the sync's own delivery consumer was still calling accept(). An unguarded HashSet between two coroutines can corrupt, and the delivered counter can lose updates. Now behind a Mutex — with onEvent kept INSIDE it, because callers are promised it never runs concurrently with itself and some of them keep unsynchronised state in that callback. pagedWindows becomes an AtomicInt for the same reason. **The kotlinx cap parse could take down the whole frame.** `.jsonPrimitive` throws on an object or array, so a relay putting something structured in the fourth element would have failed the NEG-ERR and lost the reason with it — where before that element existed, anything extra was simply ignored. `as?` restores that. Both mappers are now tested against a structured fourth element as well as a string one. **Int overflow in the split fan-out.** `mine + ceiling - 1` wraps when a window holds close to Int.MAX events, which is reachable on exactly the corpora this targets; done in Long now. **The count-driven split cuts N ways, not two.** The work queue is FIFO, so halving means every internal node's count() runs before the first NEG-OPEN goes out: on a corpus ~30,000 windows wide that is ~30,000 store counts of dead time with nothing downloading. Cutting into ceil(count/budget) pieces (capped at 32) reaches the same corpus in about three levels instead of fifteen, and pieces that guess wrong are re-split by the same rule. **The budget moves by CAS.** With reconcileConcurrency > 1 two reconcilers adjust it at once, and a lost SHRINK is the one that costs something real: the next window is then asked at a size the relay has already refused. --- .../kotlinSerialization/MessageKSerializer.kt | 6 +- .../client/accessories/NegentropyStoreSync.kt | 47 +++++- .../NostrClientNegentropySyncExt.kt | 143 +++++++++++++----- .../nip77Negentropy/Nip77SerializationTest.kt | 18 +++ 4 files changed, 173 insertions(+), 41 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 93c0c360cb..c0a7972e10 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -191,8 +191,10 @@ object MessageKSerializer : KSerializer { reason = if (array.size > 2) array[2].jsonPrimitive.content else "", // Optional, and only a number: a relay that puts something // else there is telling us nothing rather than breaking the - // frame. - cap = if (array.size > 3) array[3].jsonPrimitive.longOrNull else null, + // frame. `as?` rather than `.jsonPrimitive`, which THROWS on + // an object or array — that would fail the whole message and + // lose the reason, where before this element was ignored. + cap = if (array.size > 3) (array[3] as? JsonPrimitive)?.longOrNull else null, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyStoreSync.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyStoreSync.kt index a0272abecc..02bb576509 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyStoreSync.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyStoreSync.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.IdAndTime import com.vitorpamplona.quartz.nip01Core.store.verifyAndInsert import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -92,6 +93,15 @@ class NegentropyStoreSync( * @param concurrency relays synced at once by [sync] (a relay's own filters stay sequential). * @param idleTimeoutMs idle watchdog for reconciles / fetches / pages. * @param publishTimeoutSecs OK-confirmation wait per uploaded event. + * @param targetWindow events per reconcile window, or `0` to snapshot the + * whole filter up front (the default, and what this class always did). + * + * Above zero, the store is read one `created_at` window at a time through + * a [NegentropyLocalIndex] instead: the id snapshot stops being O(matched + * set) — it is the largest thing this class holds — at the price of an + * indexed count + range read per window. Worth turning on exactly when the + * filter matches more than fits comfortably in memory; pointless below + * that, where one snapshot shared by the whole group is cheaper. */ class Config( val down: Boolean = true, @@ -105,6 +115,7 @@ class NegentropyStoreSync( val concurrency: Int = 4, val idleTimeoutMs: Long = 30_000L, val publishTimeoutSecs: Long = 15, + val targetWindow: Int = 0, ) /** Outcome of one `(relay, filter)` group. `error` is null on success. */ @@ -166,7 +177,14 @@ class NegentropyStoreSync( // events (~40 B/entry vs ~1 KB), which matters when a relay hosts a large // matched set. The events the reconcile decides to UP-publish (the small // residual haves) are fetched by id on demand in the uploader below. - val localEntries = store.snapshotIdsForNegentropy(listOf(filter)) + // + // With a targetWindow, even those 40 B/entry are read per window rather + // than for the whole filter — on a large store that snapshot is the + // biggest thing this class allocates, and it is allocated before the + // first frame goes out. + val windowed = config.targetWindow > 0 + val localIndex = if (windowed) StoreWindowIndex(store) else null + val localEntries = if (windowed) emptyList() else store.snapshotIdsForNegentropy(listOf(filter)) val downloaded = AtomicInt(0) val uploaded = AtomicInt(0) @@ -210,6 +228,8 @@ class NegentropyStoreSync( relay = relay, filter = filter, localEntries = localEntries, + localIndex = localIndex, + targetWindow = config.targetWindow, batchSize = config.idChunk, idleTimeoutMs = config.idleTimeoutMs, reconcileConcurrency = config.reconcileConcurrency, @@ -311,3 +331,28 @@ class NegentropyStoreSync( return stored.load() } } + +/** + * [NegentropyLocalIndex] over an [IEventStore]: the window engine's per-window + * reads answered straight from the store's `created_at` index. + * + * The windows handed here are the caller's own filter with `since`/`until` + * narrowed, so they can go to the store as-is. A count the store cannot answer + * comes back null rather than throwing — the engine then simply stops + * pre-splitting that window and lets the relay's refusal decide, which is the + * behaviour without an index at all. + */ +private class StoreWindowIndex( + private val store: IEventStore, +) : NegentropyLocalIndex { + override suspend fun count(window: Filter): Int? = + try { + store.count(window) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + + override suspend fun entriesFor(window: Filter): List = store.snapshotIdsForNegentropy(listOf(window)) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt index 6595cc763e..51941e1856 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt @@ -329,6 +329,7 @@ class NegentropyOrFetchResult( * Use [negentropySync] directly if you want to decide the fallback yourself (try * another relay, narrow the filter, abort, …) instead of always paging. */ +@OptIn(ExperimentalAtomicApi::class) suspend fun INostrClient.negentropySyncOrFetch( relay: NormalizedRelayUrl, filter: Filter, @@ -346,18 +347,29 @@ suspend fun INostrClient.negentropySyncOrFetch( ): NegentropyOrFetchResult { val seen = HashSet() var delivered = 0 - var pagedWindows = 0 + val pagedWindows = AtomicInt(0) - // Shared dedup + cap across both phases. Returns true if the event was new and - // delivered. Both phases run sequentially, so no concurrent access. - suspend fun accept(event: Event): Boolean { - if ((maxEvents <= 0 || delivered < maxEvents) && seen.add(event.id)) { - delivered++ - onEvent(event) - return true + // Shared dedup + cap across every path that delivers. + // + // The lock is not optional. The two phases used to run strictly one after + // the other, but a paged window now runs DURING the negentropy phase, on a + // reconciler coroutine, while the sync's own delivery consumer is calling + // this too — an unguarded HashSet between them can corrupt, and the count + // can lose updates. onEvent stays INSIDE the lock deliberately: callers are + // promised it never runs concurrently with itself, and some of them keep + // unsynchronised state in it. + val gate = Mutex() + + suspend fun accept(event: Event): Boolean = + gate.withLock { + if ((maxEvents <= 0 || delivered < maxEvents) && seen.add(event.id)) { + delivered++ + onEvent(event) + true + } else { + false + } } - return false - } return try { val result = @@ -379,7 +391,7 @@ suspend fun INostrClient.negentropySyncOrFetch( // already reconciled cleanly walked again over REQ, which on a // large corpus is the entire cost negentropy was there to save. onUnreconcilableWindow = { window -> - pagedWindows++ + pagedWindows.incrementAndFetch() val pageTimeoutMs = if (idleTimeoutMs > 0) idleTimeoutMs else DEFAULT_DOWNLOAD_IDLE_MS fetchAllPages(relay, listOf(window), pageTimeoutMs) { event -> if (accept(event)) onProgress?.invoke(delivered, delivered) @@ -392,10 +404,10 @@ suspend fun INostrClient.negentropySyncOrFetch( // Any paged window makes this not a clean reconcile — see the // property doc: under-reporting it would let a caller record // coverage it never compared. - pagedFallback = pagedWindows > 0, + pagedFallback = pagedWindows.load() > 0, negentropy = result, fallbackCause = null, - pagedWindows = pagedWindows, + pagedWindows = pagedWindows.load(), ) } catch (e: NegentropySyncException) { // Negentropy couldn't enumerate the set — page the whole filter instead, @@ -411,7 +423,7 @@ suspend fun INostrClient.negentropySyncOrFetch( pagedFallback = true, negentropy = null, fallbackCause = e, - pagedWindows = pagedWindows, + pagedWindows = pagedWindows.load(), ) } } @@ -567,7 +579,9 @@ internal suspend fun reconcileWindows( onPeerCap: ((Long) -> Unit)? = null, // Given a minimal window the relay will not reconcile at any size, instead // of throwing. The caller drains it however it can (paging it over REQ) and - // the sweep carries on with the rest of the filter. + // the sweep carries on with the rest of the filter. It runs ON the reconciler + // that hit the window, so a slow drain holds that reconciler — with + // reconcileConcurrency = 1 the rest of the sweep waits for it. onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null, sendNeedBatch: suspend (List) -> Unit, sendHaveBatch: (suspend (List) -> Unit)?, @@ -597,25 +611,57 @@ internal suspend fun reconcileWindows( // targetWindow at 0 nothing below this line does anything. val budget = AtomicInt(targetWindow) - // Splits a window in two and queues both halves. Returns false when the - // window is already minimal — `created_at` is in seconds, so that is the - // floor, not a tuning choice. - fun splitInto( + // Every budget move goes through here. With reconcileConcurrency > 1 two + // reconcilers adjust it at once, and read-then-store can drop one of them — + // a lost SHRINK being the one that costs something real, since the next + // window is then asked at a size the relay has already refused. + fun budgetTo(next: (Int) -> Int) { + while (true) { + val now = budget.load() + val want = next(now) + if (want == now || budget.compareAndSet(now, want)) return + } + } + + /** + * Cuts `[lo, hi]` into [pieces] equal spans of time and queues them all. A + * window already at the floor is left alone — `created_at` is in seconds, so + * that is where splitting ends, not a tuning choice. Both callers check that + * themselves; the guard here is so a third one cannot silently lose a window. + * + * [pieces] > 2 exists for the count-driven split, where we know HOW FAR over + * the budget a window is and can land near the right size in one step. + * Halving instead costs a store count per level of a tree that can be ~15 + * deep on a large corpus, and — since the queue is FIFO — every one of those + * counts happens before the first window is reconciled at all. + */ + suspend fun splitInto( pendingWindow: Filter, lo: Long, hi: Long, - ): Boolean { - if (hi - lo <= MIN_WINDOW_SECONDS) return false - val mid = lo + (hi - lo) / 2 - remaining.incrementAndFetch() - // The lower child gets the finite midpoint; the upper child KEEPS this - // window's original `until` (which may be null = unbounded). Replacing - // null with `now()` here would drop every event dated after now() - // (clock skew) once any split happens, while the un-split path would - // have included them. - pending.trySend(pendingWindow.copy(since = lo, until = mid)) - pending.trySend(pendingWindow.copy(since = mid + 1, until = pendingWindow.until)) - return true + pieces: Int = 2, + ) { + if (hi - lo <= MIN_WINDOW_SECONDS) return + val span = hi - lo + 1 + // Never more pieces than there are seconds to give them. + val n = pieces.toLong().coerceIn(2L, minOf(span, MAX_SPLIT_FANOUT.toLong())).toInt() + val step = span / n + remaining.addAndFetch(n - 1) + var start = lo + repeat(n) { i -> + val last = i == n - 1 + // The top piece KEEPS this window's original `until` (which may be + // null = unbounded). Replacing null with `now()` here would drop + // every event dated after now() (clock skew) once any split happens, + // while the un-split path would have included them. + if (last) { + pending.send(pendingWindow.copy(since = start, until = pendingWindow.until)) + } else { + val end = start + step - 1 + pending.send(pendingWindow.copy(since = start, until = end)) + start = end + 1 + } + } } val reconcilers = @@ -636,7 +682,14 @@ internal suspend fun reconcileWindows( if (ceiling > 0 && hi - lo > MIN_WINDOW_SECONDS) { val mine = local.count(window) if (mine != null && mine > ceiling) { - splitInto(window, lo, hi) + // How many windows this one is worth, not just "two": + // the count says how far over budget we are, and + // uneven density is corrected by the same check on + // each piece. + // Long arithmetic: `mine` can be near Int.MAX on a + // corpus this size, and the +ceiling would wrap. + val over = (mine.toLong() + ceiling - 1) / ceiling + splitInto(window, lo, hi, pieces = over.coerceAtMost(MAX_SPLIT_FANOUT.toLong()).toInt()) continue } } @@ -662,9 +715,12 @@ internal suspend fun reconcileWindows( // asked for, so a sync that met one dense stretch // does not stay small for the rest of the timeline. if (targetWindow > 0) { - val now = budget.load() - if (now < targetWindow) { - budget.store(minOf(targetWindow, (now * BUDGET_GROWTH).toInt().coerceAtLeast(now + 1))) + budgetTo { now -> + if (now >= targetWindow) { + now + } else { + minOf(targetWindow, (now * BUDGET_GROWTH).toInt().coerceAtLeast(now + 1)) + } } } if (remaining.decrementAndFetch() == 0) pending.close() @@ -677,13 +733,16 @@ internal suspend fun reconcileWindows( outcome.cap?.let { cap -> onPeerCap?.invoke(cap) if (targetWindow > 0) { - val fitted = (cap * CAP_MARGIN).toInt().coerceAtLeast(1) - if (fitted < budget.load()) budget.store(fitted) + val fitted = + (cap * CAP_MARGIN) + .coerceIn(1.0, Int.MAX_VALUE.toDouble()) + .toInt() + budgetTo { now -> minOf(now, fitted) } } } if (outcome.cap == null && targetWindow > 0) { // No number to go on: halve and find out. - budget.store((budget.load() / 2).coerceAtLeast(1)) + budgetTo { now -> (now / 2).coerceAtLeast(1) } } if (hi - lo <= MIN_WINDOW_SECONDS) { // A minimal window that still overflows: @@ -1295,6 +1354,14 @@ private const val MIN_WINDOW_SECONDS = 1L */ private const val MAX_WINDOWS = 100_000 +/** + * Most pieces one count-driven split may cut a window into. Bounds both the + * queue and the depth: with 32, a corpus 30,000 windows wide is reached in + * three levels instead of fifteen, and the pieces that guessed wrong are + * re-split by the same rule. + */ +private const val MAX_SPLIT_FANOUT = 32 + /** * How much of a relay's stated `max_sync_events` a window actually aims for. * The margin absorbs what the relay gains between stating that number and diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt index 7a71cc9a1b..d8d0bcc50b 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip77Negentropy/Nip77SerializationTest.kt @@ -175,6 +175,24 @@ class Nip77SerializationTest { assertEquals(null, kotlin.cap) } + @Test + fun deserializeNegErrMessageWithStructuredFourthElement_bothMappers() { + // A fourth element that is an object or array must degrade to no cap, + // NOT fail the frame — the reason is the part that matters, and before + // this element existed any extra was simply ignored. + val json = """["NEG-ERR","neg-sub1","blocked: too many query results",{"max":10}]""" + + val jackson = JacksonMapper.fromJsonToMessage(json) + assertTrue(jackson is NegErrMessage) + assertEquals("blocked: too many query results", jackson.reason) + assertEquals(null, jackson.cap) + + val kotlin = KotlinSerializationMapper.fromJsonToMessage(json) + assertTrue(kotlin is NegErrMessage) + assertEquals("blocked: too many query results", kotlin.reason) + assertEquals(null, kotlin.cap) + } + @Test fun negErrMessageWithCap_crossDeserialization() { val msg = NegErrMessage("neg-sub1", "blocked: too many records", 500_000L) From 36a79d74dee753bd153e46cd86a5aa34ca4802c7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 6 Aug 2026 17:09:34 -0400 Subject: [PATCH 146/227] Stop the outbox getting slower with every publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PoolEventOutbox kept its pending publishes in an immutable map and rebuilt it on every send: eventOutbox = eventOutbox + Pair(event.id, PoolEventOutboxState(...)) That copies every entry, per event, so publishing N events copies 1 + 2 + … + N. The relay-set bookkeeping alongside it was the same shape — needsToUpdateRelays() and updateRelays() each walk every value, and both ran on every send. Measured on a bulk push against a relay with ~970k entries resident: 22.7ms per event, of which ~20.5ms was the outbox. The store fetch feeding the same loop cost 1.2ms and the configured pace 1ms, so the map was ~90% of the budget — and the rate decayed as the backlog grew, 45.6 -> 44.6 -> 43.2 ev/s across three windows. The map is now LargeCache (ConcurrentHashMap on JVM/Android), so put/get/ remove are O(1) and the cross-thread visibility that @Volatile republishing provided comes from the map itself. The relay set is now maintained asymmetrically, because the two directions are not equally expensive. Adding is exact and cheap: union the event's own relays, touching the flow only when it actually changes. Deciding a relay may LEAVE means asking whether any remaining entry still wants it, which is inherently O(outbox) — so it is swept every SWEEP_EVERY removals, and always when the outbox empties. Keeping a relay a little too long costs an idle connection; scanning a million entries to retire it promptly costs the push. The test asserts the SHAPE of the cost, not a wall-clock budget: equal windows at the start and end of a 60k-publish run, where the late window carries ~29x the backlog. Halves were not enough — over 20k publishes the average backlog only grows 7k to 17k, a 2.4x expected ratio that hid inside JIT noise, and the first version of this test passed against the very code it was written to catch. Co-Authored-By: Claude Opus 5 (1M context) --- .../relay/client/pool/PoolEventOutbox.kt | 109 +++++++++++----- .../client/pool/PoolEventOutboxScaleTest.kt | 121 ++++++++++++++++++ 2 files changed, 201 insertions(+), 29 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxScaleTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt index 928cfd0bc2..a563229115 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt @@ -27,32 +27,61 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlin.concurrent.Volatile class PoolEventOutbox { - // @Volatile so the polling path (INostrClient.pendingPublishRelaysFor) - // sees current state from threads that didn't write the map. Mutations - // still happen on NostrClient's IO scope; this only closes the - // visibility gap for cross-thread readers. - @Volatile - private var eventOutbox = mapOf() + /** + * Pending publishes, keyed by event id. + * + * A concurrent map, NOT a copy-on-write immutable one. It used to be + * `@Volatile var eventOutbox = mapOf(...)` reassigned with + * `eventOutbox + Pair(...)`, which copies EVERY entry on EVERY publish — + * so publishing N events cost O(N^2). Measured on a bulk push with ~970k + * entries resident: 22.7ms per event, of which ~20.5ms was this map, and + * the rate decayed as the outbox grew (45.6 -> 44.6 -> 43.2 ev/s across + * three windows). The store fetch behind the same loop cost 1.2ms. + * + * [LargeCache] is ConcurrentHashMap on JVM/Android, so put/get/remove are + * O(1) and cross-thread visibility no longer needs the volatile republish. + */ + private val eventOutbox = LargeCache() val relays = MutableStateFlow(setOf()) + /** + * Removals since the relay set was last rebuilt. + * + * Deciding whether a relay may leave [relays] means asking whether ANY + * remaining entry still wants it — O(outbox), and doing that per publish + * is the second half of the quadratic. Additions stay exact and cheap (a + * union of the event's own relays); removals are swept in batches, because + * keeping a relay in the set slightly too long only means holding a + * connection a little longer, while scanning a million entries to retire + * it promptly costs the whole push. + */ + @Volatile + private var pendingSweep = 0 + + companion object { + /** Removals between full relay-set rebuilds — see [pendingSweep]. */ + private const val SWEEP_EVERY = 256 + } + fun needsToUpdateRelays(): Boolean { val currentRelays = relays.value var relaysToRemoveCounter = 0 currentRelays.forEach { currentRelay -> - if (eventOutbox.values.none { currentRelay in it.relaysRemaining }) { + if (eventOutbox.values().none { currentRelay in it.relaysRemaining }) { relaysToRemoveCounter++ } } var relaysToAddCounter = 0 - eventOutbox.values.forEach { outboxState -> + eventOutbox.values().forEach { outboxState -> if (outboxState.relaysRemaining.any { it !in currentRelays }) { relaysToAddCounter++ } @@ -67,13 +96,13 @@ class PoolEventOutbox { val relaysToRemove = mutableSetOf() currentRelays.forEach { currentRelay -> - if (eventOutbox.values.none { currentRelay in it.relaysRemaining }) { + if (eventOutbox.values().none { currentRelay in it.relaysRemaining }) { relaysToRemove.add(currentRelay) } } val relaysToAdd = mutableSetOf() - eventOutbox.values.forEach { outboxState -> + eventOutbox.values().forEach { outboxState -> outboxState.relaysRemaining.forEach { relay -> if (relay !in relaysToAdd && relay !in currentRelays) { relaysToAdd.add(relay) @@ -88,7 +117,7 @@ class PoolEventOutbox { fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set { val myEvents = mutableSetOf() - eventOutbox.forEach { (eventId, outboxCache) -> + eventOutbox.forEach { eventId, outboxCache -> if (url in outboxCache.relaysRemaining) { myEvents.add(eventId) } @@ -103,7 +132,7 @@ class PoolEventOutbox { */ fun activeOutboxEventsFor(url: NormalizedRelayUrl): List { val myEvents = mutableListOf() - eventOutbox.forEach { (_, outboxCache) -> + eventOutbox.forEach { _, outboxCache -> if (url in outboxCache.relaysRemaining) { myEvents.add(outboxCache.event) } @@ -117,20 +146,41 @@ class PoolEventOutbox { * Callers can poll this after publish to detect when relays ack: the set shrinks * as OKs arrive, then the entry is removed from the outbox (returns null). */ - fun pendingRelaysFor(eventId: HexKey): Set? = eventOutbox[eventId]?.relaysLeft() + fun pendingRelaysFor(eventId: HexKey): Set? = eventOutbox.get(eventId)?.relaysLeft() fun markAsSending( event: Event, relays: Set, ): Set { - val currentOutbox = eventOutbox[event.id] + val currentOutbox = eventOutbox.get(event.id) if (currentOutbox == null) { - eventOutbox = eventOutbox + Pair(event.id, PoolEventOutboxState(event, relays)) + eventOutbox.put(event.id, PoolEventOutboxState(event, relays)) } else { currentOutbox.updateRelays(relays) } - updateRelays() - return eventOutbox[event.id]?.remainingRelays() ?: emptySet() + // Additions only, and only what is genuinely new: the union is over + // this event's relays, never over the whole outbox. + addRelays(relays) + return eventOutbox.get(event.id)?.remainingRelays() ?: emptySet() + } + + /** Union [wanted] into [relays], touching the flow only when it actually changes. */ + private fun addRelays(wanted: Set) { + val missing = wanted - relays.value + if (missing.isNotEmpty()) relays.update { it + missing } + } + + /** + * An entry left the outbox. Retiring its relays needs a full scan, so that + * is amortised across [SWEEP_EVERY] removals — and always run once the + * outbox empties, which is the case that must not linger. + */ + private fun onRemoved() { + pendingSweep++ + if (pendingSweep >= SWEEP_EVERY || eventOutbox.isEmpty()) { + pendingSweep = 0 + updateRelays() + } } /** Records a send attempt. Returns the event if this attempt exhausted its retry budget for @@ -139,11 +189,11 @@ class PoolEventOutbox { id: HexKey, url: NormalizedRelayUrl, ): Event? { - val waiting = eventOutbox[id] ?: return null + val waiting = eventOutbox.get(id) ?: return null val gaveUp = waiting.newTry(url) if (waiting.isDone()) { - eventOutbox = eventOutbox - waiting.event.id - updateRelays() + eventOutbox.remove(waiting.event.id) + onRemoved() } return if (gaveUp) waiting.event else null } @@ -154,12 +204,12 @@ class PoolEventOutbox { success: Boolean, message: String, ) { - val waiting = eventOutbox[id] + val waiting = eventOutbox.get(id) if (waiting != null) { waiting.newResponse(url, success, message) if (waiting.isDone()) { - eventOutbox = eventOutbox - waiting.event.id - updateRelays() + eventOutbox.remove(waiting.event.id) + onRemoved() } } } @@ -171,8 +221,8 @@ class PoolEventOutbox { relay: NormalizedRelayUrl, sync: (Command) -> Unit, ) { - eventOutbox.forEach { - it.value.forEachUnsentEvent(relay) { + eventOutbox.forEach { _, outboxCache -> + outboxCache.forEachUnsentEvent(relay) { sync(EventCmd(it)) } } @@ -210,15 +260,16 @@ class PoolEventOutbox { relay: NormalizedRelayUrl, errorMessage: String, ) { - eventOutbox.forEach { - if (relay in it.value.relaysRemaining) { - newResponse(it.key, relay, false, errorMessage) + eventOutbox.forEach { id, outboxCache -> + if (relay in outboxCache.relaysRemaining) { + newResponse(id, relay, false, errorMessage) } } } fun destroy() { - eventOutbox = emptyMap() + eventOutbox.clear() + pendingSweep = 0 relays.tryEmit(emptySet()) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxScaleTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxScaleTest.kt new file mode 100644 index 0000000000..03058c51ff --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxScaleTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.TimeSource + +/** + * The outbox must not get slower as it fills. + * + * It used to: the map was immutable and every `markAsSending` rebuilt it with + * `eventOutbox + Pair(...)`, so publishing N events copied 1 + 2 + … + N + * entries. Measured on a real bulk push at ~970k entries resident, that was + * ~20.5ms of the 22.7ms each event cost, and the rate visibly decayed as the + * backlog grew (45.6 -> 44.6 -> 43.2 ev/s over three windows). The relay-set + * bookkeeping was the other half — two full scans of every entry, per publish. + * + * This asserts the SHAPE of the cost rather than a wall-clock budget: a + * quadratic makes the second half of a run dramatically slower than the first, + * whatever the machine. A constant factor cannot be pinned in a unit test, but + * a growth curve can. + */ +class PoolEventOutboxScaleTest { + private val relay = NormalizedRelayUrl("wss://scale.relay.test") + + private fun event(i: Int) = + Event( + id = i.toString(16).padStart(64, '0'), + pubKey = "00".repeat(32), + createdAt = 1_700_000_000L, + kind = 1, + tags = emptyArray(), + content = "hello", + sig = "00".repeat(64), + ) + + @Test + fun `publishing stays flat as the outbox fills`() { + val outbox = PoolEventOutbox() + val relays = setOf(relay) + val clock = TimeSource.Monotonic + val sample = 2_000 + val total = 60_000 + + fun publishRange( + from: Int, + until: Int, + ) { + for (i in from until until) outbox.markAsSending(event(i), relays) + } + + // Equal-sized windows at the START and the END of a long run. Halves + // would not do: over 20k publishes the average backlog only grows from + // ~7k to ~17k, a 2.4x expected ratio that hides inside JIT noise. Here + // the late window carries ~29x the backlog of the early one, so a + // per-entry cost shows up as a per-entry cost. + repeat(sample) { outbox.markAsSending(event(it), relays) } // warm up + val early = + clock.markNow().let { start -> + publishRange(sample, sample * 2) + start.elapsedNow() + } + publishRange(sample * 2, total - sample) + val late = + clock.markNow().let { start -> + publishRange(total - sample, total) + start.elapsedNow() + } + + assertEquals(total, outbox.activeOutboxCacheFor(relay).size, "every publish is tracked") + + val ratio = late.inWholeMicroseconds.toDouble() / early.inWholeMicroseconds.coerceAtLeast(1) + assertTrue( + ratio < 5.0, + "cost per publish must not grow with the backlog: first $sample took ${early.inWholeMilliseconds}ms at " + + "~$sample entries, last $sample took ${late.inWholeMilliseconds}ms at ~$total entries (ratio $ratio)", + ) + } + + @Test + fun `the relay set still reflects what is pending`() { + val outbox = PoolEventOutbox() + val a = NormalizedRelayUrl("wss://a.relay.test") + val b = NormalizedRelayUrl("wss://b.relay.test") + + outbox.markAsSending(event(1), setOf(a)) + assertEquals(setOf(a), outbox.relays.value, "a publish adds its relay immediately") + + outbox.markAsSending(event(2), setOf(b)) + assertEquals(setOf(a, b), outbox.relays.value, "a second relay joins without a rebuild") + + // Draining every entry must clear the set — the sweep is batched, but + // emptying the outbox forces it, so a finished push does not strand a + // connection open forever. + outbox.newResponse(event(1).id, a, true, "") + outbox.newResponse(event(2).id, b, true, "") + assertEquals(emptySet(), outbox.relays.value, "an empty outbox wants no relays") + } +} From d7226b70213a7e13beecacebe43c0acfff686af3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 6 Aug 2026 19:06:25 -0500 Subject: [PATCH 147/227] =?UTF-8?q?concord:=20implement=20CORD-02=20=C2=A7?= =?UTF-8?q?2=20staff-held=20control=5Froot=20write=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track the spec change in concord2 96f0647 (CORD-01 Write-Restricted Streams) and its review follow-up bbc67b6: the Control Plane's stream key splits, the signer keypair deriving from a new control_root held only by the owner and staff (concord/control-signer), while every member holds the delivered control_pk to subscribe and verify, reading under the community_root-derived read key the old concord/control derivation still yields. - ControlPlaneKeys models the three views of an epoch: staff (signer held), member (address held, read-only), legacy (pre-split, one key). ConcordStreamEnvelope gains write-restricted wrap/open forms; wrapping without the write key fails loudly instead of missigning. - Genesis mints the control_root beside the community_root; invites, the kind-13302 join material, and held roots carry control_pk (and, staff-side, control_root) since a split address is held, never derivable. A same-epoch list merge fills either side's missing key material, so a holder's own second device converges (CORD-02 §8); across epochs it is never inherited, being stale by construction. - Promotion delivers the secret inside the staff-making Grant itself: GrantEntity.control_wrap, a 40-byte epoch_be8‖control_root pairwise ciphertext (ControlRootWrap), adopted only when it derives to the held control_pk — fails closed. PIN_MESSAGES claims frozen bit 11 and the staff set is the six Control-writing bits, a normative list. - Refoundings roll the pair: base rekey blobs are now width-per-form (72 channel/legacy, 104 member +control_pk, 136 staff +control_root), a mismatched staff pair is refused, and a legacy 72-byte base blob is honored when reading old rotations, never minted anew — so a legacy community upgrades as a side effect of its next base rotation. - Sessions, the plane registry, the subscription planner, amy, and the app read the plane by held address per epoch; moderation verbs take ControlPlaneKeys, and both amy and the app refuse a Control write without the secret rather than throwing out of the envelope. Rank and possession diverge for as long as a promotee waits on delivery, so the app gates its mod affordances on the write key too. Stored control material only ever backstops its own epoch. The account drains staff-making Grants on the revision tick. Possession stays a spam gate, never authority: every edition is still judged by its sealed actor's rank in the owner-rooted Roster. --- .../vitorpamplona/amethyst/model/Account.kt | 3 + .../amethyst/model/AccountConcordActions.kt | 206 +++++++++++++-- .../concord/ConcordChannelListScreen.kt | 10 +- .../cli/commands/ConcordChannelCommands.kt | 12 +- .../amethyst/cli/commands/ConcordCommands.kt | 44 +++- .../cli/commands/ConcordModCommands.kt | 50 +++- .../amethyst/cli/stores/ConcordStore.kt | 8 + .../commons/actions/ConcordActions.kt | 93 ++++++- .../commons/actions/ConcordModeration.kt | 78 +++++- .../actions/ConcordSubscriptionPlanner.kt | 11 +- .../model/concord/ConcordCommunitySession.kt | 44 +++- .../model/concord/ConcordPlaneRegistry.kt | 40 ++- .../commons/actions/ConcordActionsTest.kt | 32 ++- .../commons/actions/ConcordModerationTest.kt | 82 ++++++ .../actions/ConcordSubscriptionPlannerTest.kt | 20 +- .../concord/ConcordCommunitySessionTest.kt | 6 +- .../model/concord/ConcordPlaneRegistryTest.kt | 2 + .../model/concord/ConcordRollbackFloorTest.kt | 33 ++- .../concord/ConcordSessionManagerTest.kt | 22 +- .../concord/ConcordSessionRegistryTest.kt | 6 +- .../ConcordCommunityFactory.kt | 25 +- .../cord02Community/ConcordCommunityList.kt | 121 ++++++++- .../concord/cord04Roles/AuthorityResolver.kt | 14 ++ .../concord/cord04Roles/ConcordPermissions.kt | 19 +- .../concord/cord04Roles/ControlEntities.kt | 9 + .../concord/cord04Roles/ControlRootWrap.kt | 115 +++++++++ .../concord/cord05Invites/CommunityInvite.kt | 13 + .../cord05Invites/ConcordStrandedRecovery.kt | 8 +- .../concord/cord06Rekey/ConcordRefounding.kt | 139 ++++++++--- .../concord/cord06Rekey/ConcordRekey.kt | 46 +++- .../quartz/concord/cord06Rekey/RekeyBlob.kt | 62 ++++- .../concord/crypto/ConcordKeyDerivation.kt | 22 +- .../quartz/concord/crypto/ConcordLabels.kt | 12 +- .../quartz/concord/crypto/ControlPlaneKeys.kt | 127 ++++++++++ .../concord/envelope/ConcordStreamEnvelope.kt | 99 +++++++- .../ConcordCommunityFactoryTest.kt | 15 +- .../ConcordCommunityListTest.kt | 53 +++- .../cord02Community/ControlPlaneSplitTest.kt | 194 ++++++++++++++ .../cord04Roles/AuthorityResolverTest.kt | 33 +++ .../cord04Roles/ControlRootWrapTest.kt | 164 ++++++++++++ .../ConcordInviteJoinFlowTest.kt | 14 +- .../cord06Rekey/ConcordRefoundingTest.kt | 31 ++- .../cord06Rekey/ControlRootRotationTest.kt | 236 ++++++++++++++++++ 43 files changed, 2158 insertions(+), 215 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrap.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ControlPlaneKeys.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ControlPlaneSplitTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrapTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 966c01cedf..57d6f64e5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3563,6 +3563,9 @@ class Account( refreshConcordChannelIndex() // A revision also bumps when a base-rotation rekey lands; adopt ours if present. runCatching { concord.drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) } + // A promotion to staff delivers the Control Plane write key inside the Grant + // itself (CORD-04 §3), so the fold that seats the role is also when it arrives. + runCatching { concord.drainConcordStaffGrants() }.onFailure { Log.w("Concord", "staff grant drain failed", it) } // A rotation we were *excluded* from produces no rekey to drain, so it can only be // found by re-resolving the invite link we joined through. Rate-limited internally. runCatching { concord.recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index be17eefef0..fba41f5e01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -24,20 +24,28 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withControlRoot import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap +import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.Event @@ -138,6 +146,10 @@ class AccountConcordActions( ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + // The creator is the founding staff member (CORD-02 §2): it keeps the write + // secret and publishes only the derived pubkey to everyone else. + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), relays = relayUrls, name = name, addedAt = TimeUtils.now() * 1000, @@ -168,6 +180,9 @@ class AccountConcordActions( rootEpoch = entry.rootEpoch, name = entry.name, relays = entry.relays, + // The joiner can never derive the Control Plane address, so the bundle carries + // it (CORD-05 §1). Null on a legacy community, which has none to carry. + controlPk = entry.controlPk, ) val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays) @@ -245,6 +260,9 @@ class AccountConcordActions( ownerSalt = bundle.ownerSalt, root = bundle.communityRoot, rootEpoch = bundle.rootEpoch, + // Read access to the Control Plane, never write (CORD-05 §1). Absent = the + // community is still pre-split, so we fold it at the legacy address. + controlPk = bundle.controlPk, relays = bundle.relays, name = bundle.name, addedAt = TimeUtils.now() * 1000, @@ -451,6 +469,24 @@ class AccountConcordActions( // every client's AuthorityResolver, so a call by someone who doesn't outrank the // target is simply dropped on fold. Owner-authored calls always take effect. + /** + * The Control Plane keys for a moderation write, or null when this account cannot + * publish there: on a split epoch only `control_root` holders can mint a wrap that + * verifies at the plane's address (CORD-02 §2), and wrapping without the secret + * throws rather than missigning. Rank and key possession can diverge — a freshly + * promoted staffer writes only once their `control_wrap` is adopted (CORD-04 §3), + * and the UI gates on rank — so every moderation verb no-ops through this check + * instead of crashing on a rank-gated action. + */ + private fun controlKeysForWrite(session: ConcordCommunitySession): ControlPlaneKeys? { + val cp = session.controlPlaneKeys() + if (!cp.canWrite) { + Log.w("Concord") { "Control write refused for ${session.entry.id}: control_root not held at epoch ${session.entry.rootEpoch} (CORD-02 §2)" } + return null + } + return cp + } + /** Grant [member] exactly [roleIds] (empty list revokes their roles). */ suspend fun grantConcordRole( communityId: String, @@ -459,7 +495,23 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val wrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + val cp = controlKeysForWrite(session) ?: return false + // A Grant that first makes its member staff must deliver the control_root in the same + // edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the + // roles carry a Control-writing bit and we hold the secret to hand over. + val wrap = + ConcordModeration.grantWithStaffDelivery( + actor = account.signer, + controlPlane = cp, + communityId = communityId.hexToByteArray(), + member = member, + roleIds = roleIds, + current = session.controlEditions(), + createdAt = TimeUtils.now(), + owner = session.entry.owner, + controlRoot = session.entry.controlRoot?.hexToByteArray(), + epoch = session.entry.rootEpoch, + ) publishConcordWrap(session.entry, wrap) return true } @@ -494,11 +546,11 @@ class AccountConcordActions( val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null if (author == account.signer.pubKey) return null val communityId = channel.channelId.communityId - val state = - account.concordSessions - .sessionFor(communityId) - ?.state - ?.value ?: return null + val session = account.concordSessions.sessionFor(communityId) ?: return null + val state = session.state.value ?: return null + // Rank alone isn't enough on a split epoch: the Grant edition takes the control_root + // (CORD-02 §2), so don't offer an action the verb would refuse. + if (!session.controlPlaneKeys().canWrite) return null if (state.authority.isOwner(author) || !state.authority.isOwner(account.signer.pubKey)) return null val adminRoleId = state.roles.entries @@ -515,7 +567,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = session.controlPlaneKey() + val cp = controlKeysForWrite(session) ?: return false val existing = session.state.value @@ -530,7 +582,22 @@ class AccountConcordActions( roleId.toHexKey() } - val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + // Admin carries every management bit, so this Grant makes its member staff: it must + // deliver the control_root alongside the rank (CORD-04 §3), or the new admin holds + // authority it cannot publish under. + val grantWrap = + ConcordModeration.grantWithStaffDelivery( + actor = account.signer, + controlPlane = cp, + communityId = communityId.hexToByteArray(), + member = member, + roleIds = listOf(roleIdHex), + current = session.controlEditions(), + createdAt = TimeUtils.now(), + owner = session.entry.owner, + controlRoot = session.entry.controlRoot?.hexToByteArray(), + epoch = session.entry.rootEpoch, + ) publishConcordWrap(session.entry, grantWrap) return true } @@ -542,7 +609,8 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val grantWrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + val cp = controlKeysForWrite(session) ?: return false + val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, grantWrap) return true } @@ -568,12 +636,11 @@ class AccountConcordActions( val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null if (author == account.signer.pubKey) return null val communityId = channel.channelId.communityId - val authority = - account.concordSessions - .sessionFor(communityId) - ?.state - ?.value - ?.authority ?: return null + val session = account.concordSessions.sessionFor(communityId) ?: return null + val authority = session.state.value?.authority ?: return null + // Rank alone isn't enough on a split epoch: the banlist edition takes the control_root + // (CORD-02 §2), so don't offer an action the verb would refuse. + if (!session.controlPlaneKeys().canWrite) return null if (authority.isOwner(author)) return null // The owner short-circuits rather than going through canActOn: canActOn starts at // hasPermission, which is false while banned, and a rogue BAN holder *can* currently put @@ -590,7 +657,8 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val wrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + val cp = controlKeysForWrite(session) ?: return false + val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -602,7 +670,8 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val wrap = ConcordModeration.unban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + val cp = controlKeysForWrite(session) ?: return false + val wrap = ConcordModeration.unban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -636,12 +705,16 @@ class AccountConcordActions( if (!iCanBan) return false val removedLower = removed.mapTo(HashSet()) { it.lowercase() } if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false + // A Refounding writes the current plane (the pre-rotation bans) and the new one (the + // compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A + // rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery. + val cp = controlKeysForWrite(session) ?: return false // 1. Ban the removed members on the current Control Plane so the compacted snapshot — // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally // first, so each subsequent edition chains onto the updated banlist head. for (target in removedLower) { - val banWrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + val banWrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, banWrap) } @@ -667,16 +740,27 @@ class AccountConcordActions( // 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs. val entry = session.entry val newRoot = RandomInstance.bytes(32) + // A fresh control_root is minted beside the new root at every Refounding (CORD-02 §2), + // so a demoted staffer's retained secret dies with the epoch — and a legacy community + // upgrades to the split as a side effect of its next ban (CORD-06 §3). + val newControlRoot = RandomInstance.bytes(32) + // The staff set the new secret goes to: the owner plus everyone holding a + // Control-writing bit (CORD-04 §3). They get the 136-byte blob, every other + // recipient the 104-byte one carrying the pubkey alone. (The builder mints a + // blob per recipient, so staff who aren't recipients are simply never reached.) + val staff = authority.staffMembers() val build = ConcordActions.buildRefounding( rotatorSigner = account.signer, communityId = communityId, priorRoot = entry.root.hexToByteArray(), newRoot = newRoot, + newControlRoot = newControlRoot, rootEpoch = entry.rootEpoch, priorControlWraps = session.controlPlaneWraps(), - priorControlKey = session.controlPlaneKey(), + priorControlKeys = cp, recipientsXOnly = recipients, + staffXOnly = staff, createdAt = TimeUtils.now(), ) @@ -690,7 +774,7 @@ class AccountConcordActions( // 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and // re-folds the compacted Control Plane (with the ban), dropping the removed members. - adoptConcordRoot(entry, newRoot, build.newEpoch) + adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot) return true } @@ -710,9 +794,14 @@ class AccountConcordActions( entry: ConcordCommunityListEntry, newRoot: ByteArray, newEpoch: Long, + newControlPk: ByteArray? = null, + newControlRoot: ByteArray? = null, ) { if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return - val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch } + // The epoch we're leaving is banked with the address it was folded at, so its Control + // Plane stays subscribable for the anti-rollback floor (a split epoch's address can + // never be re-derived, only remembered — CORD-02 §2). + val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch } val next = ConcordCommunityListEntry( id = entry.id, @@ -720,6 +809,11 @@ class AccountConcordActions( ownerSalt = entry.ownerSalt, root = newRoot.toHexKey(), rootEpoch = newEpoch, + // A rotation that delivered no control material is a legacy, pre-split one + // (CORD-06 §3): the new epoch keeps folding at the legacy address, and the + // stale prior-epoch values must NOT be carried into it. + controlPk = newControlPk?.toHexKey(), + controlRoot = newControlRoot?.toHexKey(), heldRoots = held, privateChannels = entry.privateChannels, relays = entry.relays, @@ -769,6 +863,7 @@ class AccountConcordActions( wraps = wraps, baseRekey = session.nextBaseRekeyKey(), recipientSigner = account.signer, + communityId = entry.id, priorRoot = entry.root.hexToByteArray(), rootEpoch = entry.rootEpoch, ) ?: continue @@ -779,7 +874,62 @@ class AccountConcordActions( // who has themselves been banned could still rotate the whole community. val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN) if (!authorized) continue - adoptConcordRoot(entry, received.newRoot, received.newEpoch) + adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) + } + } + + /** + * Adopt a `control_root` delivered to us by a staff-making Grant (CORD-04 §3): the + * promoting edition carries the secret in `control_wrap`, NIP-44-encrypted under the + * granter↔member pairwise key, so promotion and key delivery are one signed edition + * with nothing separate to watch an inbox for. + * + * Adoption is gated twice and fails closed both times. The secret is adopted only if + * it derives to exactly the `control_pk` we already hold for the named epoch — a + * garbage wrap is attributable griefing, nothing worse — and only from a Grant our own + * fold honors, so a rogue cannot feed us a key by minting an edition nobody accepts. + * The epoch check matters because compaction re-wraps a Grant head verbatim across + * Refoundings, so a folded head can legitimately carry a wrap minted for a prior epoch. + * + * Idempotent: once the entry holds the secret there is nothing to adopt. Runs on the + * revision tick, like the rekey drain. + */ + internal suspend fun drainConcordStaffGrants() { + if (!account.isWriteable()) return + val me = account.signer.pubKey.lowercase() + for (session in account.concordSessions.sessions()) { + val entry = session.entry + // Already staff at this epoch, or a legacy community with no split to join. + val heldControlPk = entry.controlPk + if (entry.controlRoot != null || heldControlPk == null) continue + val state = session.state.value ?: continue + // Only a Grant our fold honors can deliver: an unauthorized edition hands us nothing. + if (!state.authority.isStaff(me)) continue + + val myGrantCoordinate = + ConcordKeyDerivation + .grantCoordinate(entry.id.hexToByteArray(), me.hexToByteArray()) + .toHexKey() + val delivered = + session + .controlEditions() + .filter { it.entityKind == ControlEntityKind.GRANT && it.entityIdHex == myGrantCoordinate } + // Newest first: a re-issued Grant (a lost key, a head superseded before we + // fetched it) carries the fresher wrap. + .sortedByDescending { it.version } + .firstNotNullOfOrNull { edition -> + val wrap = ConcordJson.decodeOrNull(edition.content)?.controlWrap ?: return@firstNotNullOfOrNull null + val opened = ControlRootWrap.openOrNull(wrap, account.signer, edition.author) ?: return@firstNotNullOfOrNull null + if (opened.epoch != entry.rootEpoch) return@firstNotNullOfOrNull null + // Fails closed: a secret that doesn't derive to the pk we hold is dropped, + // never adopted — we will not split ourselves off from the plane's readers. + if (!ControlRootWrap.derivesTo(opened.controlRoot, entry.id.hexToByteArray(), entry.rootEpoch, heldControlPk)) return@firstNotNullOfOrNull null + opened.controlRoot + } ?: continue + + account.sendMyPublicAndPrivateOutbox( + account.concordChannelList.follow(entry.withControlRoot(delivered.toHexKey())), + ) } } @@ -859,8 +1009,9 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false + val cp = controlKeysForWrite(session) ?: return false val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays) - val wrap = ConcordModeration.editMetadata(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -876,9 +1027,10 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false + val cp = controlKeysForWrite(session) ?: return false val channelId = RandomInstance.bytes(32) val channel = ChannelEntity(name = name.trim()) - val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -891,6 +1043,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false + val cp = controlKeysForWrite(session) ?: return false // Carry the standing definition forward and change only the name. A ChannelEntity built from // scratch defaults `private` and `voice` to false, so renaming a private channel used to // publish an edition declaring it PUBLIC — and a voice channel became a text channel. @@ -900,7 +1053,7 @@ class AccountConcordActions( ?.get(channelIdHex) ?.definition val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false) - val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -913,6 +1066,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false + val cp = controlKeysForWrite(session) ?: return false // Same as rename: preserve the standing flags so a tombstone does not also silently // reclassify the channel it retires. val standing = @@ -921,7 +1075,7 @@ class AccountConcordActions( ?.get(channelIdHex) ?.definition val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true) - val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 1b40e49820..d8c94f5ace 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -167,11 +167,15 @@ fun ConcordChannelListScreen( // Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the // fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer. + // Rank alone isn't enough on a split epoch: publishing any Control edition also takes the + // control_root (CORD-02 §2), which a freshly promoted staffer may not hold yet (CORD-04 §3), + // so the affordance waits for the key too. val canManageChannels = state?.authority?.let { it.isOwner(account.signer.pubKey) || it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS) - } == true + } == true && + session?.controlPlaneKeys()?.canWrite == true // channelIdHex == null → create; else → rename that channel. var channelEditor by remember { mutableStateOf(null) } @@ -234,11 +238,13 @@ fun ConcordChannelListScreen( } }, actions = { + // Rank + the Control write key (CORD-02 §2), like [canManageChannels] above. val canEdit = state?.authority?.let { it.isOwner(account.signer.pubKey) || it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA) - } == true + } == true && + session?.controlPlaneKeys()?.canWrite == true IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) { SymbolIcon(symbol = MaterialSymbols.Group, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_members_title)) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 7f52a986a8..618ae59581 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -150,12 +150,14 @@ object ConcordChannelCommands { ctx: Context, sc: StoredCommunity, ): ConcordCommunityState { - val controlPlane = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + val controlPlane = ConcordCommands.controlPlaneKeysFor(sc) val relays = ConcordCommands.relaysFor(ctx, sc) - // The relays gate the plane's kind-1059 behind NIP-42 as the derived stream key — register - // it so the drain's AUTH challenge is answered as the control plane, not the account. - ctx.registerConcordStreamKeys(relays, listOf(controlPlane.secretKey)) - val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(controlPlane.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + // The relays gate the plane's kind-1059 behind NIP-42 as the stream key — register it so + // the drain's AUTH challenge is answered as the control plane, not the account. On a split + // epoch only staff hold that secret (CORD-02 §2); a plain member registers nothing and + // relies on the relay serving the plane unauthenticated. + ctx.registerConcordStreamKeys(relays, listOfNotNull(controlPlane.signer?.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(controlPlane.address)) }, pendingOnAuthRequired = true).map { it.second } return ConcordActions.foldCommunity(wraps, controlPlane, sc.owner) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 7b41a02f8f..ff151176ef 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -119,6 +120,10 @@ object ConcordCommands { ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + // The creator is the founding staff member: it keeps the write secret and + // publishes the pubkey to everyone else (CORD-02 §2). + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), generalChannelId = community.generalChannelIdHex, relays = relays, ), @@ -179,6 +184,14 @@ object ConcordCommands { val imported = entries.map { e -> val prior = existing[e.id] + // Control key material is per-epoch (CORD-02 §2): a stored value may only + // backstop a list entry from the SAME epoch (e.g. another client republished + // the list without the extension fields). Across a rotation the old pair is + // stale — a prior-epoch control_root would derive a wrong address entirely, + // and a prior-epoch control_pk would shadow a legacy rotation's address — so + // it must never be carried forward (the invariant adoption enforces with its + // derive-check, which this path has no way to run). + val priorSameEpoch = prior?.takeIf { it.rootEpoch == e.rootEpoch } store.upsert( StoredCommunity( name = e.name.ifBlank { prior?.name ?: "" }, @@ -187,15 +200,23 @@ object ConcordCommands { ownerSalt = e.ownerSalt, root = e.root, rootEpoch = e.rootEpoch, + // Carried straight from the list entry: the Control Plane address is + // delivered, never derivable (CORD-02 §2), and the write secret only + // rides the list when this account is staff. Both blank on a legacy + // community, which keeps its old single-key plane. + controlPk = e.controlPk ?: priorSameEpoch?.controlPk ?: "", + controlRoot = e.controlRoot ?: priorSameEpoch?.controlRoot ?: "", generalChannelId = prior?.generalChannelId ?: "", relays = e.relays, - heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key) }, + heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") }, ), ) mapOf( "name" to e.name, "community_id" to e.id, "root_epoch" to e.rootEpoch, + "control_pk" to (e.controlPk ?: ""), + "staff" to (e.controlRoot != null), "held_roots" to e.heldRoots.map { mapOf("epoch" to it.epoch, "root" to it.key) }, ) } @@ -216,7 +237,9 @@ object ConcordCommands { val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle) Context.open(dataDir).use { ctx -> ctx.prepare() - val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays) + // The joiner cannot derive the Control Plane address, so the invite carries it + // (CORD-05 §1); omitted for a legacy community, which has none to carry. + val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null }) val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays) val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it } @@ -257,6 +280,9 @@ object ConcordCommands { ownerSalt = bundle.ownerSalt, root = bundle.communityRoot, rootEpoch = bundle.rootEpoch, + // Read access to the Control Plane, never write (CORD-05 §1). Absent = the + // community is still pre-split and folds at the legacy address. + controlPk = bundle.controlPk ?: "", relays = bundle.relays, ), ) @@ -276,6 +302,20 @@ object ConcordCommands { sc: StoredCommunity, ): Set = normalize(sc.relays).ifEmpty { ctx.outboxRelays() } + /** + * The Control Plane keys for [sc] as this account holds them (CORD-02 §5): staff + * (write key held), member (address held, read-only), or legacy (pre-split, keyed + * by the `community_root` alone). + */ + fun controlPlaneKeysFor(sc: StoredCommunity) = + ConcordActions.controlPlaneKeys( + communityRoot = sc.root.hexToByteArray(), + communityId = sc.communityId.hexToByteArray(), + rootEpoch = sc.rootEpoch, + controlPk = sc.controlPk.ifBlank { null }, + controlRoot = sc.controlRoot.ifBlank { null }, + ) + fun notFound(handle: String): Int { Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`") return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 16fc1b2757..3633b24dbe 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity -import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.RandomInstance @@ -93,6 +93,7 @@ object ConcordModCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val (cp, editions) = load(ctx, sc) + writeGuard(cp)?.let { return it } val roleId = RandomInstance.bytes(32) val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire()) val wrap = ConcordModeration.defineRole(ctx.signer, cp, roleId, role, editions, TimeUtils.now(), owner = sc.owner) @@ -119,7 +120,23 @@ object ConcordModCommands { ctx.prepare() val member = ctx.requireUserHex(userRef) val (cp, editions) = load(ctx, sc) - val wrap = ConcordModeration.grant(ctx.signer, cp, sc.communityId.hexToByteArray(), member, listOf(roleId), editions, TimeUtils.now(), owner = sc.owner) + writeGuard(cp)?.let { return it } + // A Grant that first makes its member staff must carry the write secret in the same + // edition (CORD-04 §3); ConcordModeration wraps it pairwise when the granted roles + // hold a Control-writing bit and we hold the secret to deliver. + val wrap = + ConcordModeration.grantWithStaffDelivery( + actor = ctx.signer, + controlPlane = cp, + communityId = sc.communityId.hexToByteArray(), + member = member, + roleIds = listOf(roleId), + current = editions, + createdAt = TimeUtils.now(), + owner = sc.owner, + controlRoot = sc.controlRoot.ifBlank { null }?.hexToByteArray(), + epoch = sc.rootEpoch, + ) val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } Output.emit(mapOf("member" to member, "roles" to listOf(roleId)) + RawEventSupport.ackFields(ack)) @@ -154,6 +171,7 @@ object ConcordModCommands { ctx.prepare() val member = ctx.requireUserHex(userRef) val (cp, editions) = load(ctx, sc) + writeGuard(cp)?.let { return it } val cid = sc.communityId.hexToByteArray() val wrap = if (ban) { @@ -168,20 +186,34 @@ object ConcordModCommands { } } - /** Drain the control plane and return its key + current editions to chain onto. */ + /** Drain the control plane and return its keys + current editions to chain onto. */ private suspend fun load( ctx: Context, sc: StoredCommunity, - ): Pair> { - val cp = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + ): Pair> { + val cp = ConcordCommands.controlPlaneKeysFor(sc) val relays = ConcordCommands.relaysFor(ctx, sc) - // Concord relays serve the plane's kind-1059 only to a connection AUTHed as the derived - // stream key — register the control key so the drain isn't refused (else the fold is empty). - ctx.registerConcordStreamKeys(relays, listOf(cp.secretKey)) - val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + // Concord relays serve the plane's kind-1059 only to a connection AUTHed as the stream + // key — register it so the drain isn't refused (else the fold is empty). On a split epoch + // that secret is staff-only (CORD-02 §2), and a member simply has nothing to register. + ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } return cp to ConcordActions.controlEditions(wraps, cp) } + /** + * Refuses a moderation command that this account cannot publish: on a split epoch + * only `control_root` holders can mint a wrap the plane accepts (CORD-02 §2), so a + * member would otherwise sign an edition every relay and reader drops. Possession is + * a spam gate, never authority — holding the key still does not make the action + * honored, which the Roster decides at fold (CORD-04 §5). + */ + private fun writeGuard(cp: ControlPlaneKeys): Int? { + if (cp.canWrite) return null + Output.error("forbidden", "this account holds no control_root for the community, so it cannot publish Control Plane editions (CORD-02 §2) — ask a staff member to grant you a Control-writing role") + return 1 + } + private fun permByName(name: String): Int? = when (name.uppercase()) { "MANAGE_ROLES" -> ConcordPermissions.MANAGE_ROLES diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index 379dc44b83..7ae731a877 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -37,6 +37,12 @@ data class StoredCommunity( val ownerSalt: String = "", val root: String = "", val rootEpoch: Long = 0, + // The Control Plane signer's pubkey at [rootEpoch] (CORD-02 §2): read access, never write. + // Blank = a legacy, pre-split community, whose Control Plane is keyed the old way. + val controlPk: String = "", + // The staff write key at [rootEpoch], held only when this account is the owner or staff + // (CORD-02 §2). Blank for a regular member, who can read the plane but not publish to it. + val controlRoot: String = "", val generalChannelId: String = "", val relays: List = emptyList(), // Past access roots kept per epoch (CORD-06 Refounding rotates the root). Lets `read --epoch ` @@ -48,6 +54,8 @@ data class StoredCommunity( data class StoredHeldRoot( val epoch: Long = 0, val root: String = "", + /** That epoch's Control Plane address; blank for a legacy, pre-split epoch (CORD-02 §5). */ + val controlPk: String = "", ) /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index fa23a7ee33..d99679263f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding import com.vitorpamplona.quartz.concord.cord06Rekey.RefoundingBuild import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.Event @@ -89,12 +90,52 @@ data class HistoricalChannelPlane( object ConcordActions { // ---- plane key derivation ------------------------------------------------- + /** + * The **legacy** Control Plane group key (pre-split epochs, CORD-06 §3), which + * doubles as the split epochs' *read* key derivation. For anything that opens + * or writes the Control Plane, prefer [controlPlaneKeys]. + */ fun controlPlane( communityRoot: ByteArray, communityId: ByteArray, rootEpoch: Long, ): GroupKey = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, rootEpoch) + /** + * The Control Plane keys as this account holds them (CORD-02 §5): staff when + * [controlRoot] is held, read-only member when only [controlPk] is, and the + * legacy single-key plane when neither (a pre-split epoch). + */ + fun controlPlaneKeys( + communityRoot: ByteArray, + communityId: ByteArray, + rootEpoch: Long, + controlPk: HexKey? = null, + controlRoot: HexKey? = null, + ): ControlPlaneKeys = ControlPlaneKeys.of(communityRoot, communityId, rootEpoch, controlPk, controlRoot) + + /** The Control Plane keys described by a joined-list [entry]. */ + fun controlPlaneKeysFor(entry: ConcordCommunityListEntry): ControlPlaneKeys = + controlPlaneKeys( + entry.root.hexToByteArray(), + entry.id.hexToByteArray(), + entry.rootEpoch, + entry.controlPk, + entry.controlRoot, + ) + + /** + * The Control Plane's stream address for a subscription: the held `control_pk` + * on a split epoch, else the legacy derived address. Cheaper than + * [controlPlaneKeys] when only the address is needed. + */ + fun controlPlaneAddress( + communityRoot: ByteArray, + communityId: ByteArray, + rootEpoch: Long, + controlPk: HexKey?, + ): HexKey = controlPk?.lowercase() ?: controlPlane(communityRoot, communityId, rootEpoch).publicKeyHex + fun publicChannel( communityRoot: ByteArray, channelId: ByteArray, @@ -178,7 +219,7 @@ object ConcordActions { /** Opens the control-plane [wraps] into their [ControlEdition]s (drops any that don't open/parse). */ fun controlEditions( wraps: List, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, ): List = wraps.mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, controlPlane)?.let { ControlEdition.fromRumor(it.rumor) } @@ -187,7 +228,7 @@ object ConcordActions { /** Opens the control-plane [wraps] and folds them into the live community state. */ fun foldCommunity( wraps: List, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, ownerPubKey: HexKey, ): ConcordCommunityState = ConcordCommunityState.fold(controlEditions(wraps, controlPlane), ownerPubKey) @@ -360,7 +401,12 @@ object ConcordActions { // ---- invites -------------------------------------------------------------- - /** Builds a [CommunityInvite] from a freshly created (or joined) community's public info. */ + /** + * Builds a [CommunityInvite] from a freshly created (or joined) community's + * public info. [controlPk] is the Control Plane's signer pubkey at [rootEpoch] + * (CORD-05 §1) — read access for the joiner, never write; null only for a + * legacy, pre-split community. + */ fun inviteFor( communityIdHex: HexKey, ownerPubKey: HexKey, @@ -369,6 +415,7 @@ object ConcordActions { rootEpoch: Long, name: String, relays: List, + controlPk: HexKey? = null, ): CommunityInvite = CommunityInvite( communityId = communityIdHex, @@ -376,6 +423,7 @@ object ConcordActions { ownerSalt = ownerSaltHex, communityRoot = communityRootHex, rootEpoch = rootEpoch, + controlPk = controlPk, relays = relays, name = name, ) @@ -427,8 +475,18 @@ object ConcordActions { nowMs: Long = TimeUtils.nowMillis(), ): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token, nowMs) - /** Derives the control plane described by a redeemed [invite] so the joiner can read it. */ - fun controlPlaneFor(invite: CommunityInvite): GroupKey = controlPlane(invite.communityRoot.hexToByteArray(), invite.communityId.hexToByteArray(), invite.rootEpoch) + /** + * The Control Plane keys described by a redeemed [invite] so the joiner can + * read it: the bundle's `control_pk` on a split community, the legacy plane + * when absent (CORD-05 §1). Never a writer — an invite delivers no secret. + */ + fun controlPlaneFor(invite: CommunityInvite): ControlPlaneKeys = + controlPlaneKeys( + invite.communityRoot.hexToByteArray(), + invite.communityId.hexToByteArray(), + invite.rootEpoch, + controlPk = invite.controlPk, + ) // ---- guestbook (CORD-02 §5) ---------------------------------------------- @@ -468,19 +526,23 @@ object ConcordActions { /** * Builds a whole-community Refounding (CORD-06 §3): the compacted Control Plane - * re-sealed under [newRoot] plus the base-rotation rekey blobs delivering - * [newRoot] to [recipientsXOnly]. Pure — the caller sources the recipient set - * and owns publish + persistence. + * re-sealed at the new epoch's split Control address plus the base-rotation + * rekey blobs delivering [newRoot] + the new `control_pk` to [recipientsXOnly] + * — the [staffXOnly] subset also receiving [newControlRoot] (CORD-06 §1). Pure + * — the caller sources the recipient and staff sets (the folded Roster's + * `staffMembers()`, CORD-04 §3) and owns publish + persistence. */ suspend fun buildRefounding( rotatorSigner: NostrSigner, communityId: HexKey, priorRoot: ByteArray, newRoot: ByteArray, + newControlRoot: ByteArray, rootEpoch: Long, priorControlWraps: List, - priorControlKey: GroupKey, + priorControlKeys: ControlPlaneKeys, recipientsXOnly: List, + staffXOnly: Set, createdAt: Long, ): RefoundingBuild = ConcordRefounding.build( @@ -488,24 +550,29 @@ object ConcordActions { communityId = communityId.hexToByteArray(), priorRoot = priorRoot, newRoot = newRoot, + newControlRoot = newControlRoot, rootEpoch = rootEpoch, priorControlWraps = priorControlWraps, - priorControlKey = priorControlKey, + priorControlKeys = priorControlKeys, recipientsXOnly = recipientsXOnly, + staffXOnly = staffXOnly, createdAt = createdAt, ) /** * Receives an inbound base rotation for the member behind [recipientSigner]: * finds the delivered new root across the buffered kind-3303 [wraps], verifying - * scope, epoch and continuity against the [priorRoot] the member holds. Returns - * the new root + rotator (for the caller to authorize) or null if not re-keyed. + * scope, epoch and continuity against the [priorRoot] the member holds — and, + * on a staff blob, that the delivered `control_root` derives to the delivered + * `control_pk` (CORD-06 §1). Returns the new root + Control keys + rotator + * (for the caller to authorize) or null if not re-keyed. */ suspend fun openBaseRekey( wraps: List, baseRekey: GroupKey, recipientSigner: NostrSigner, + communityId: HexKey, priorRoot: ByteArray, rootEpoch: Long, - ): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, priorRoot, rootEpoch) + ): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, communityId.hexToByteArray(), priorRoot, rootEpoch) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index 3e3e9b1de0..e5ba1e55d1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -25,14 +25,16 @@ import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation -import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -97,7 +99,7 @@ object ConcordModeration { private suspend fun wrap( actor: NostrSigner, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, kind: ControlEntityKind, entityId: ByteArray, version: Long, @@ -116,7 +118,7 @@ object ConcordModeration { */ suspend fun defineRole( actor: NostrSigner, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, roleId: ByteArray, role: RoleEntity, current: List, @@ -138,7 +140,7 @@ object ConcordModeration { */ suspend fun defineChannel( actor: NostrSigner, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, channelId: ByteArray, channel: ChannelEntity, current: List, @@ -159,7 +161,7 @@ object ConcordModeration { */ suspend fun editMetadata( actor: NostrSigner, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, communityId: ByteArray, metadata: MetadataEntity, current: List, @@ -172,10 +174,18 @@ object ConcordModeration { return wrap(actor, controlPlane, ControlEntityKind.METADATA, communityId, version, prev, content, createdAt, citation) } - /** Grants [member] exactly [roleIds] (replaces their prior grant). Empty list revokes all roles. */ + /** + * Grants [member] exactly [roleIds] (replaces their prior grant). Empty list revokes all roles. + * + * A Grant that first makes its member **staff** must deliver the current + * `control_root` in the same edition (CORD-04 §3): pass [controlWrap] built with + * [ControlRootWrap.build] for the current epoch. A current staffer may also + * re-issue a Grant with a fresh wrap to re-deliver (a lost key, a superseded + * head). Leave null for a non-staff grant, a revoke, or a legacy community. + */ suspend fun grant( actor: NostrSigner, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, communityId: ByteArray, member: HexKey, roleIds: List, @@ -183,17 +193,63 @@ object ConcordModeration { createdAt: Long, citation: AuthorityCitation? = null, owner: HexKey, + controlWrap: String? = null, ): Event { val entityId = ConcordKeyDerivation.grantCoordinate(communityId, member.hexToByteArray()) val (version, prev) = versioning(current, entityId, owner) - val content = ConcordJson.instance.encodeToString(GrantEntity.serializer(), GrantEntity(member = member, roleIds = roleIds)) + val content = ConcordJson.instance.encodeToString(GrantEntity.serializer(), GrantEntity(member = member, roleIds = roleIds, controlWrap = controlWrap)) return wrap(actor, controlPlane, ControlEntityKind.GRANT, entityId, version, prev, content, createdAt, citation) } + /** + * [grant], deciding the staff delivery for the caller: when [roleIds] hands the + * member any Control-writing bit ([ConcordPermissions.STAFF_BITS]) and we hold the + * [controlRoot] to deliver, the edition carries a `control_wrap` fresh for [epoch] + * (CORD-04 §3). A non-staff grant, a revoke, a legacy community, or a granter who + * does not hold the secret all produce a plain Grant. + * + * The role bits are read off the same authority-gated fold the readers use, so a + * role a reader would drop never triggers a delivery — and a role we cannot resolve + * yet (its edition unseen) conservatively doesn't either, which the spec's re-issue + * path covers: any current staffer MAY re-issue a Grant with a fresh wrap. + */ + suspend fun grantWithStaffDelivery( + actor: NostrSigner, + controlPlane: ControlPlaneKeys, + communityId: ByteArray, + member: HexKey, + roleIds: List, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + owner: HexKey, + controlRoot: ByteArray?, + epoch: Long, + ): Event { + val wrap = + if (controlRoot != null && makesStaff(roleIds, current, owner)) { + ControlRootWrap.build(actor, member, epoch, controlRoot) + } else { + null + } + return grant(actor, controlPlane, communityId, member, roleIds, current, createdAt, citation, owner, wrap) + } + + /** True when any of [roleIds] resolves to a role carrying a Control-writing bit (CORD-04 §3). */ + fun makesStaff( + roleIds: List, + current: List, + owner: HexKey, + ): Boolean { + if (roleIds.isEmpty()) return false + val roles = AuthorityResolver.resolve(current, owner).roles() + return roleIds.any { roles[it]?.permissionBits()?.hasAny(ConcordPermissions.STAFF_BITS) == true } + } + /** Adds [member] to the banlist (union with the current head). */ suspend fun ban( actor: NostrSigner, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, communityId: ByteArray, member: HexKey, current: List, @@ -205,7 +261,7 @@ object ConcordModeration { /** Removes [member] from the banlist. */ suspend fun unban( actor: NostrSigner, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, communityId: ByteArray, member: HexKey, current: List, @@ -231,7 +287,7 @@ object ConcordModeration { private suspend fun setBanlist( actor: NostrSigner, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, communityId: ByteArray, banned: Set, current: List, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 52b4436475..213121d0d4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -72,17 +72,20 @@ object ConcordSubscriptionPlanner { entries.flatMap { e -> val communityId = e.id.hexToByteArray() val relays = normalize(e.relays) - val cp = ConcordActions.controlPlane(e.root.hexToByteArray(), communityId, e.rootEpoch) + // The address is the held `control_pk` on a split epoch and the legacy derivation + // otherwise (CORD-02 §5) — a member can never derive the former, so it is read off + // the entry, per epoch, exactly as it was delivered. + val cp = ConcordActions.controlPlaneKeysFor(e) val historical = e.heldRoots .filter { it.epoch < e.rootEpoch } .sortedByDescending { it.epoch } .take(ConcordActions.MAX_BACKFILL_EPOCHS) .mapNotNull { held -> - val key = runCatching { ConcordActions.controlPlane(held.key.hexToByteArray(), communityId, held.epoch) }.getOrNull() ?: return@mapNotNull null - ConcordPlaneSub(channelId = null, pubKeyHex = key.publicKeyHex, relays = relays) + val keys = runCatching { ConcordActions.controlPlaneKeys(held.key.hexToByteArray(), communityId, held.epoch, held.controlPk, held.controlRoot) }.getOrNull() ?: return@mapNotNull null + ConcordPlaneSub(channelId = null, pubKeyHex = keys.address, relays = relays) } - listOf(ConcordPlaneSub(channelId = null, pubKeyHex = cp.publicKeyHex, relays = relays)) + historical + listOf(ConcordPlaneSub(channelId = null, pubKeyHex = cp.address, relays = relays)) + historical } /** 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 49acfd9b1e..32869407e1 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 @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.Event @@ -103,7 +104,12 @@ class ConcordCommunitySession( private val root = entry.root.hexToByteArray() private val communityIdBytes = entry.id.hexToByteArray() - private val controlPlaneKey: GroupKey = ConcordActions.controlPlane(root, communityIdBytes, entry.rootEpoch) + /** + * The Control Plane as this account holds it (CORD-02 §5): split when the entry + * carries a `control_pk` (plus the write key when this account is staff and + * holds the `control_root`), legacy single-key otherwise. + */ + private val controlKeys: ControlPlaneKeys = ConcordActions.controlPlaneKeysFor(entry) /** The Guestbook Plane at this epoch — where member join/leave motions ride (CORD-02 §5). */ private val guestbookKey: GroupKey = ConcordActions.guestbookPlane(root, communityIdBytes, entry.rootEpoch) @@ -116,7 +122,7 @@ class ConcordCommunitySession( private val nextBaseRekeyKey: GroupKey = ConcordActions.nextBaseRekeyPlane(root, communityIdBytes, entry.rootEpoch) /** The Control Plane stream address to subscribe to (known from the entry alone). */ - val controlPlaneAddress: HexKey get() = controlPlaneKey.publicKeyHex + val controlPlaneAddress: HexKey get() = controlKeys.address /** The Guestbook Plane stream address to subscribe to (known from the entry alone). */ val guestbookAddress: HexKey get() = guestbookKey.publicKeyHex @@ -136,14 +142,16 @@ class ConcordCommunitySession( * `heldRoots` is already persisted in the kind-13302 community list, that memory * survives a process restart without any new storage. */ - private val historicalControlKeys: Map> = + private val historicalControlKeys: Map> = entry.heldRoots .filter { it.epoch < entry.rootEpoch } .sortedByDescending { it.epoch } .take(ConcordActions.MAX_BACKFILL_EPOCHS) .mapNotNull { held -> - val key = runCatching { ConcordActions.controlPlane(held.key.hexToByteArray(), communityIdBytes, held.epoch) }.getOrNull() ?: return@mapNotNull null - key.publicKeyHex to (key to held.epoch) + // A held split epoch's address is the banked control_pk (held, never derivable); + // a held legacy epoch derives its address from the root as it always did. + val keys = runCatching { ConcordActions.controlPlaneKeys(held.key.hexToByteArray(), communityIdBytes, held.epoch, held.controlPk, held.controlRoot) }.getOrNull() ?: return@mapNotNull null + keys.address to (keys to held.epoch) }.toMap() /** The prior-epoch Control Plane addresses to subscribe to, so the rollback floor can be rebuilt. */ @@ -289,13 +297,19 @@ class ConcordCommunitySession( * NOT included here: mixing them into the shared control/channel AUTH set starved the * subscription on relays that gate a REQ on stream-key AUTH (control stopped folding, * channels went empty). They AUTH on their own isolated subscription instead. + * + * A **split** Control Plane epoch contributes a key only when this account is staff + * (CORD-02 §2): a regular member holds the `control_pk` but not the secret behind it, + * so it cannot answer an AUTH challenge as the plane — which is the write-restriction + * working as designed, not a gap to paper over. A legacy epoch still contributes, its + * member-held derivation being address and signer at once (CORD-02 §5). */ fun streamKeys(): List = lock.withLock { - listOf(controlPlaneKey) + + listOfNotNull(controlKeys.signer) + // Prior-epoch Control Planes: the anti-rollback floor is folded from them, so the // gated relays must serve their wraps too. - historicalControlKeys.values.map { it.first } + + historicalControlKeys.values.mapNotNull { it.first.signer } + channelKeysByAddress.values.map { it.second } + // Prior-epoch channel stream keys so the gated relays serve their older wraps too. historicalChannelKeysByAddress.values.map { it.second } @@ -305,13 +319,17 @@ 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 { editionsLocked(controlWraps.values.toList(), controlPlaneKey) } + fun controlEditions(): List = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) } /** The raw Control Plane wraps buffered so far — the input a Refounding compacts (CORD-06 §3). */ fun controlPlaneWraps(): List = lock.withLock { controlWraps.values.toList() } - /** The Control Plane key, for authoring moderation editions. */ - fun controlPlaneKey(): GroupKey = controlPlaneKey + /** + * The Control Plane keys as this account holds them, for authoring moderation + * editions. [ControlPlaneKeys.canWrite] is false for a regular member on a split + * epoch (CORD-02 §2) — the caller must not attempt to publish an edition then. + */ + fun controlPlaneKeys(): ControlPlaneKeys = controlKeys /** This account's standing, from the current fold. */ fun membership(): ConcordMembership { @@ -447,7 +465,7 @@ class ConcordCommunitySession( val wraps = controlWraps.values.toList() val folded = ConcordCommunityState.fold( - editionsLocked(wraps, controlPlaneKey), + editionsLocked(wraps, controlKeys), entry.owner, controlFloorsLocked(), ) @@ -486,13 +504,13 @@ class ConcordCommunitySession( */ private fun editionsLocked( wraps: Collection, - planeKey: GroupKey, + planeKeys: ControlPlaneKeys, ): List = wraps.mapNotNull { wrap -> if (editionByWrapId.containsKey(wrap.id)) { editionByWrapId[wrap.id] } else { - val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKey)?.let { ControlEdition.fromRumor(it.rumor) } + val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKeys)?.let { ControlEdition.fromRumor(it.rumor) } editionByWrapId[wrap.id] = edition edition } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt index 86699776e7..cd0cbe7979 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst.commons.model.concord +import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId -import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent @@ -39,13 +39,31 @@ enum class ConcordPlaneKind { CHANNEL, } -/** A known Concord plane: its kind, community, optional channel, and the key to open its wraps. */ +/** + * A known Concord plane: its kind, community, optional channel, and what it takes + * to open its wraps — the stream [address] they must be authored by, and the + * [readConversationKey] their content decrypts under. + * + * The two are separate fields rather than one [GroupKey] because a split Control + * Plane separates them (CORD-01, Write-Restricted Streams): the address is the + * staff-held signer's pubkey, while the read key derives from the `community_root` + * every member holds. On a channel plane and a legacy Control Plane they are the + * two halves of the same key. + */ class ConcordPlane( val kind: ConcordPlaneKind, val communityId: HexKey, val channelId: ConcordChannelId?, - val key: GroupKey, -) + val address: HexKey, + val readConversationKey: ByteArray, +) { + constructor( + kind: ConcordPlaneKind, + communityId: HexKey, + channelId: ConcordChannelId?, + key: GroupKey, + ) : this(kind, communityId, channelId, key.publicKeyHex, key.conversationKey) +} /** The routed result of opening an inbound wrap that belonged to a known plane. */ class RoutedRumor( @@ -72,12 +90,18 @@ class ConcordPlaneRegistry { private val lock = KmpLock() private val planes = HashMap() - /** Registers every joined community's Control Plane address. Idempotent. */ + /** + * Registers every joined community's Control Plane address. Idempotent. + * + * On a split epoch the address is the entry's held `control_pk` and the wraps + * still decrypt under the `community_root`-derived read key (CORD-02 §5); on a + * legacy entry (no `control_pk`) both come from the old single derivation. + */ fun registerControlPlanes(entries: List) = lock.withLock { for (e in entries) { - val cp = ConcordKeyDerivation.controlPlaneKey(e.root.hexToByteArray(), e.id.hexToByteArray(), e.rootEpoch) - planes[cp.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CONTROL, e.id, null, cp) + val cp = ConcordActions.controlPlaneKeysFor(e) + planes[cp.address] = ConcordPlane(ConcordPlaneKind.CONTROL, e.id, null, cp.address, cp.readKey.conversationKey) } } @@ -106,7 +130,7 @@ class ConcordPlaneRegistry { */ fun route(wrap: Event): RoutedRumor? { val plane = planeFor(wrap.pubKey) ?: return null - val opened = ConcordStreamEnvelope.openOrNull(wrap, plane.key) ?: return null + val opened = ConcordStreamEnvelope.openOrNull(wrap, plane.address, plane.readConversationKey) ?: return null return RoutedRumor(plane, opened) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt index 15c73f51d0..a5d8ff772d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.actions +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest @@ -63,6 +64,9 @@ class ConcordActionsTest { rootEpoch = community.rootEpoch, name = "Nostrichs", relays = listOf("wss://r.example"), + // Without this the joiner has no Control Plane address to fold at — the + // bundle is the only place it can come from (CORD-05 §1). + controlPk = community.controlPkHex, ) val minted = ConcordActions.mintInviteLink("https://vector.chat", invite, createdAt = 1L) @@ -103,29 +107,47 @@ class ConcordActionsTest { val carol = NostrSignerInternal(KeyPair()) // removed val newRoot = ByteArray(32) { 0x33 } + // A fresh control_root is minted beside the new root at every Refounding (CORD-02 §2). + val newControlRoot = ByteArray(32) { 0x44 } val build = ConcordActions.buildRefounding( rotatorSigner = owner, communityId = community.communityIdHex, priorRoot = community.communityRoot, newRoot = newRoot, + newControlRoot = newControlRoot, rootEpoch = community.rootEpoch, priorControlWraps = community.genesisWraps, - priorControlKey = community.controlPlane, + priorControlKeys = community.controlPlane, recipientsXOnly = listOf(owner.pubKey, alice.pubKey), + // Only the owner is staff, so only the owner's blob carries the secret. + staffXOnly = setOf(owner.pubKey), createdAt = 5L, ) val baseRekey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch) - val aliceGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, alice, community.communityRoot, community.rootEpoch) - val carolGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, carol, community.communityRoot, community.rootEpoch) + val aliceGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, alice, community.communityIdHex, community.communityRoot, community.rootEpoch) + val carolGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, carol, community.communityIdHex, community.communityRoot, community.rootEpoch) assertNotNull(aliceGot) assertEquals(community.rootEpoch + 1, aliceGot.newEpoch) assertTrue(carolGot == null) - // The compacted Control Plane folds identically under the new root. - val newControl = ConcordActions.controlPlane(aliceGot.newRoot, community.communityId, aliceGot.newEpoch) + // Alice is a plain member: her blob carries the new control_pk to read with, never the + // secret to write with (CORD-06 §1). + val deliveredControlPk = aliceGot.newControlPk + assertNotNull(deliveredControlPk) + assertTrue(aliceGot.newControlRoot == null, "a member's base blob must not carry the write key") + + // The compacted Control Plane folds identically at the new epoch, opened the way a + // member does: the delivered address plus the derived read key. + val newControl = + ConcordActions.controlPlaneKeys( + communityRoot = aliceGot.newRoot, + communityId = community.communityId, + rootEpoch = aliceGot.newEpoch, + controlPk = deliveredControlPk.toHexKey(), + ) val state = ConcordActions.foldCommunity(build.controlWraps, newControl, community.ownerPubKey) assertEquals("Test", state.metadata?.name) assertTrue(state.channels.isNotEmpty()) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt index 77ed81f1ae..c5ae3af6a9 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt @@ -22,10 +22,13 @@ package com.vitorpamplona.amethyst.commons.actions import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold +import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -34,6 +37,8 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class ConcordModerationTest { @@ -214,4 +219,81 @@ class ConcordModerationTest { assertTrue(state.authority.isBanned(troll.pubKey), "the forged unban must not free the troll") assertTrue(state.authority.isBanned(stranger.pubKey)) } + + /** + * The staff-delivery decision (CORD-04 §3): a Grant that hands out a Control-writing + * bit must carry the `control_root` in the same edition (`control_wrap`), and every + * other shape of Grant must not — a non-staff role, a revoke, an unresolvable role, + * or a granter who holds no secret to deliver. + */ + @Test + fun aStaffMakingGrantDeliversTheControlRootAndNothingElseDoes() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val cp = community.controlPlane + val communityId = community.communityId + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + + // A staff role (BAN writes Control editions) and a non-staff one (KICK writes the Guestbook). + val staffRoleId = ByteArray(32) { 0x51 } + val staffRoleIdHex = staffRoleId.toHexKey() + val kickRoleId = ByteArray(32) { 0x52 } + val kickRoleIdHex = kickRoleId.toHexKey() + editions += + ConcordActions.controlEditions( + listOf( + ConcordModeration.defineRole(owner, cp, staffRoleId, RoleEntity(name = "Mod", position = 2, permissions = ConcordPermissions.of(ConcordPermissions.BAN).toWire()), editions, createdAt = 2L, owner = community.ownerPubKey), + ), + cp, + ) + editions += + ConcordActions.controlEditions( + listOf( + ConcordModeration.defineRole(owner, cp, kickRoleId, RoleEntity(name = "Bouncer", position = 3, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire()), editions, createdAt = 3L, owner = community.ownerPubKey), + ), + cp, + ) + + assertTrue(ConcordModeration.makesStaff(listOf(staffRoleIdHex), editions, community.ownerPubKey)) + assertFalse(ConcordModeration.makesStaff(listOf(kickRoleIdHex), editions, community.ownerPubKey)) + assertFalse(ConcordModeration.makesStaff(emptyList(), editions, community.ownerPubKey), "a revoke hands out nothing") + assertFalse(ConcordModeration.makesStaff(listOf("ee".repeat(32)), editions, community.ownerPubKey), "an unresolvable role must not trigger a delivery") + + suspend fun grantEntity( + roleIds: List, + controlRoot: ByteArray?, + ): GrantEntity { + val wrap = + ConcordModeration.grantWithStaffDelivery( + actor = owner, + controlPlane = cp, + communityId = communityId, + member = admin.pubKey, + roleIds = roleIds, + current = editions, + createdAt = 4L, + owner = community.ownerPubKey, + controlRoot = controlRoot, + epoch = community.rootEpoch, + ) + val edition = ConcordActions.controlEditions(listOf(wrap), cp).single() + return ConcordJson.decodeOrNull(edition.content)!! + } + + // A staff-making Grant carries the wrap; the promotee opens it and it derives to the + // control_pk every member holds for the epoch — the adoption gate (CORD-04 §3). + val staffGrant = grantEntity(listOf(staffRoleIdHex), community.controlRoot) + val controlWrap = staffGrant.controlWrap + assertNotNull(controlWrap, "a staff-making Grant must deliver the write key in the same edition") + val opened = ControlRootWrap.openOrNull(controlWrap, admin, owner.pubKey) + assertNotNull(opened, "the promotee must be able to open the delivery") + assertEquals(community.rootEpoch, opened.epoch, "the wrap must be fresh for the current epoch") + assertTrue(ControlRootWrap.derivesTo(opened.controlRoot, communityId, community.rootEpoch, community.controlPkHex)) + + // Every other shape is a plain Grant. + assertNull(grantEntity(listOf(kickRoleIdHex), community.controlRoot).controlWrap, "a Guestbook-writing role needs no key") + assertNull(grantEntity(emptyList(), community.controlRoot).controlWrap, "a revoke delivers nothing") + assertNull(grantEntity(listOf("ee".repeat(32)), community.controlRoot).controlWrap, "an unresolved role conservatively delivers nothing") + assertNull(grantEntity(listOf(staffRoleIdHex), controlRoot = null).controlWrap, "no held secret, no delivery (legacy community or keyless granter)") + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt index d7e02a724e..0412057a24 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt @@ -48,6 +48,8 @@ class ConcordSubscriptionPlannerTest { ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), heldRoots = listOf( com.vitorpamplona.quartz.concord.cord02Community @@ -78,6 +80,8 @@ class ConcordSubscriptionPlannerTest { ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), relays = listOf("wss://r.example"), name = "Nostrichs", ) @@ -85,7 +89,7 @@ class ConcordSubscriptionPlannerTest { // Control-plane sub address must equal the derived control plane pk. val controlSubs = ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry)) assertEquals(1, controlSubs.size) - assertEquals(community.controlPlane.publicKeyHex, controlSubs[0].pubKeyHex) + assertEquals(community.controlPlane.address, controlSubs[0].pubKeyHex) assertTrue(controlSubs[0].channelId == null) // Channel-plane subs cover the folded #general channel. @@ -103,7 +107,7 @@ class ConcordSubscriptionPlannerTest { assertEquals(1, filters.size) // single relay val filter = filters.values.first().first() assertEquals(listOf(1059), filter.kinds) - assertTrue(filter.authors!!.contains(community.controlPlane.publicKeyHex)) + assertTrue(filter.authors!!.contains(community.controlPlane.address)) assertTrue(filter.authors!!.contains(general.pubKeyHex)) } @@ -118,6 +122,8 @@ class ConcordSubscriptionPlannerTest { ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), relays = listOf("wss://r.example"), name = "Nostrichs", ) @@ -147,6 +153,8 @@ class ConcordSubscriptionPlannerTest { ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), relays = listOf("wss://r.example"), name = "Nostrichs", ) @@ -176,6 +184,8 @@ class ConcordSubscriptionPlannerTest { ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), relays = listOf("wss://r.example"), name = "Nostrichs", ) @@ -189,7 +199,7 @@ class ConcordSubscriptionPlannerTest { assertEquals(relay, filters[0].relay) assertEquals(listOf(1059, 21059), filters[0].filter.kinds) assertEquals(1234L, filters[0].filter.since) - assertTrue(filters[0].filter.authors!!.contains(community.controlPlane.publicKeyHex)) + assertTrue(filters[0].filter.authors!!.contains(community.controlPlane.address)) // No planes resolve to a relay -> nothing to subscribe. assertNull(ConcordSubscriptionPlanner.relayBasedFilters(emptyList(), null)) @@ -210,12 +220,14 @@ class ConcordSubscriptionPlannerTest { ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), relays = listOf("wss://r.example"), name = "Nostrichs", ) val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) - val controlPk = community.controlPlane.publicKeyHex + val controlPk = community.controlPlane.address val guestbookPk = ConcordActions.guestbookPlane(community.communityRoot, community.communityId, community.rootEpoch).publicKeyHex val generalPk = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch).publicKeyHex 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 7f7882a14d..95e7721ff8 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 @@ -53,6 +53,8 @@ class ConcordCommunitySessionTest { ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), heldRoots = listOf(HeldRoot(priorEpoch, priorRoot.toHexKey())), relays = listOf("wss://r.example"), name = "Nostrichs", @@ -98,13 +100,15 @@ class ConcordCommunitySessionTest { ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), relays = listOf("wss://r.example"), name = "Nostrichs", ) val captured = mutableListOf>() val session = ConcordCommunitySession(entry, owner.pubKey) { communityId, channelIdHex, rumor, _ -> captured += Triple(communityId, channelIdHex, rumor) } - assertEquals(community.controlPlane.publicKeyHex, session.controlPlaneAddress) + assertEquals(community.controlPlane.address, session.controlPlaneAddress) // 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. diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt index 6f395de18e..25b14d7fd1 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt @@ -48,6 +48,8 @@ class ConcordPlaneRegistryTest { ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), relays = listOf("wss://r.example"), name = "Nostrichs", ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt index 978b17130f..d3c1a50a75 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -73,7 +74,10 @@ class ConcordRollbackFloorTest { // silently dropped. Every wrap it publishes is a genuine, owner-signed edition. val newRoot = ByteArray(32) { 0x33 } val newEpoch = community.rootEpoch + 1 - val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch) + // The rotator mints a fresh control_root beside the new root (CORD-02 §2), so the + // new epoch's plane is split and addressed by the derived signer, not the root. + val newControlRoot = ByteArray(32) { 0x44 } + val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot) val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl) val entry = @@ -83,7 +87,11 @@ class ConcordRollbackFloorTest { ownerSalt = community.ownerSalt.toHexKey(), root = newRoot.toHexKey(), rootEpoch = newEpoch, - heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey())), + controlPk = newControl.address, + controlRoot = newControlRoot.toHexKey(), + // The prior epoch is banked with the address it was folded at: a split epoch's + // Control Plane can never be re-derived, only remembered (CORD-02 §2). + heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey(), community.controlPkHex, community.controlRoot.toHexKey())), relays = listOf("wss://r.example"), name = "Nostrichs", ) @@ -92,11 +100,11 @@ class ConcordRollbackFloorTest { // The prior epoch's Control Plane is subscribed and AUTHed for — that is where the floor // comes from, and without it the client has no memory to check the rotator against. assertTrue( - session.historicalControlPlaneAddresses().contains(community.controlPlane.publicKeyHex), + session.historicalControlPlaneAddresses().contains(community.controlPlane.address), "prior-epoch control plane not subscribed", ) assertTrue( - session.streamKeys().any { it.publicKeyHex == community.controlPlane.publicKeyHex }, + session.streamKeys().any { it.publicKeyHex == community.controlPlane.address }, "prior-epoch control plane not AUTHed", ) @@ -132,7 +140,10 @@ class ConcordRollbackFloorTest { val newRoot = ByteArray(32) { 0x33 } val newEpoch = community.rootEpoch + 1 - val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch) + // The rotator mints a fresh control_root beside the new root (CORD-02 §2), so the + // new epoch's plane is split and addressed by the derived signer, not the root. + val newControlRoot = ByteArray(32) { 0x44 } + val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot) // Honest: compacted from the FULL prior plane, so each entity's head (metadata v1) survives. val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl) @@ -143,7 +154,11 @@ class ConcordRollbackFloorTest { ownerSalt = community.ownerSalt.toHexKey(), root = newRoot.toHexKey(), rootEpoch = newEpoch, - heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey())), + controlPk = newControl.address, + controlRoot = newControlRoot.toHexKey(), + // The prior epoch is banked with the address it was folded at: a split epoch's + // Control Plane can never be re-derived, only remembered (CORD-02 §2). + heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey(), community.controlPkHex, community.controlRoot.toHexKey())), relays = listOf("wss://r.example"), name = "Nostrichs", ) @@ -167,7 +182,10 @@ class ConcordRollbackFloorTest { val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) val newRoot = ByteArray(32) { 0x33 } val newEpoch = community.rootEpoch + 1 - val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch) + // The rotator mints a fresh control_root beside the new root (CORD-02 §2), so the + // new epoch's plane is split and addressed by the derived signer, not the root. + val newControlRoot = ByteArray(32) { 0x44 } + val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot) val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl) val entry = @@ -177,6 +195,7 @@ class ConcordRollbackFloorTest { ownerSalt = community.ownerSalt.toHexKey(), root = newRoot.toHexKey(), rootEpoch = newEpoch, + controlPk = newControl.address, relays = listOf("wss://r.example"), name = "Nostrichs", ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt index 58b0c55253..10a771942e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt @@ -47,6 +47,8 @@ class ConcordSessionManagerTest { ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), relays = listOf("wss://r.example"), name = name, ) @@ -61,7 +63,7 @@ class ConcordSessionManagerTest { testScheduler.runCurrent() // The joined community produced a session, and its control plane is in the subscribe set. - assertTrue(manager.subscribeAddresses().contains(alpha.controlPlane.publicKeyHex)) + assertTrue(manager.subscribeAddresses().contains(alpha.controlPlane.address)) val revAfterSync = manager.revision.value assertTrue(revAfterSync > 0) @@ -87,7 +89,7 @@ class ConcordSessionManagerTest { val beta = ConcordCommunityFactory.create(owner, "Beta", createdAt = 1L, relays = listOf("wss://r.example")) communities.value = listOf(entryFor(alpha, "Alpha"), entryFor(beta, "Beta")) testScheduler.runCurrent() - assertTrue(manager.subscribeAddresses().contains(beta.controlPlane.publicKeyHex)) + assertTrue(manager.subscribeAddresses().contains(beta.controlPlane.address)) // Alpha's fold survived the re-sync. assertEquals( "Alpha", @@ -114,7 +116,13 @@ class ConcordSessionManagerTest { // Before any fold, only the control-plane key must AUTH — and only on the community's relay. val beforeFold = manager.streamAuthSecretsFor(hosted).map { it.toHexKey() } - assertTrue(beforeFold.contains(alpha.controlPlane.secretKey.toHexKey())) + assertTrue( + beforeFold.contains( + alpha.controlPlane.signer!! + .secretKey + .toHexKey(), + ), + ) assertTrue(manager.streamAuthSecretsFor(elsewhere).isEmpty()) // relay-scoped // After the Control Plane folds, the #general channel key joins the AUTH set. @@ -122,7 +130,13 @@ class ConcordSessionManagerTest { testScheduler.runCurrent() val general = ConcordActions.publicChannel(alpha.communityRoot, alpha.generalChannelId, alpha.rootEpoch) val afterFold = manager.streamAuthSecretsFor(hosted).map { it.toHexKey() } - assertTrue(afterFold.contains(alpha.controlPlane.secretKey.toHexKey())) + assertTrue( + afterFold.contains( + alpha.controlPlane.signer!! + .secretKey + .toHexKey(), + ), + ) assertTrue(afterFold.contains(general.secretKey.toHexKey())) assertFalse(manager.streamAuthSecretsFor(elsewhere).any { it.toHexKey() == general.secretKey.toHexKey() }) } 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 0419520238..a8bedadc73 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 @@ -46,6 +46,8 @@ class ConcordSessionRegistryTest { ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), relays = listOf("wss://r.example"), name = name, ) @@ -66,8 +68,8 @@ class ConcordSessionRegistryTest { assertNotNull(registry.sessionFor(beta.communityIdHex)) // Both control-plane addresses are in the subscribe set from the entries alone. - assertTrue(registry.subscribeAddresses().contains(alpha.controlPlane.publicKeyHex)) - assertTrue(registry.subscribeAddresses().contains(beta.controlPlane.publicKeyHex)) + assertTrue(registry.subscribeAddresses().contains(alpha.controlPlane.address)) + assertTrue(registry.subscribeAddresses().contains(beta.controlPlane.address)) // A genesis control wrap routes to Alpha's session and folds it (STRUCTURAL). alpha.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL_FOLD, registry.ingest(it)) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt index f7d320c5ff..bf3dd5fb98 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation -import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -45,9 +45,12 @@ class NewConcordCommunity( val ownerPubKey: String, val ownerSalt: ByteArray, val communityRoot: ByteArray, + /** The staff write key (CORD-02 §2): held by the owner and staff only, never members. */ + val controlRoot: ByteArray, val rootEpoch: Long, val generalChannelId: ByteArray, - val controlPlane: GroupKey, + /** The split Control Plane keys (owner view: signer held, [ControlPlaneKeys.canWrite] true). */ + val controlPlane: ControlPlaneKeys, /** The kind-1059 control-plane wraps to publish (metadata + #general). */ val genesisWraps: List, /** The same editions as parsed [ControlEdition]s, for immediate local folding. */ @@ -55,6 +58,9 @@ class NewConcordCommunity( ) { val communityIdHex: String get() = communityId.toHexKey() val generalChannelIdHex: String get() = generalChannelId.toHexKey() + + /** The Control Plane address (`control_pk`) members hold to subscribe/verify/read. */ + val controlPkHex: String get() = controlPlane.address } /** @@ -63,9 +69,11 @@ class NewConcordCommunity( * `create` mints a random `owner_salt`, derives the self-certifying * `community_id = sha256("concord/community" ‖ owner ‖ salt)`, generates an * independent random `community_root` (so access can rotate while identity stays - * fixed), and emits exactly two owner-signed genesis editions — the community - * metadata and a public `#general` channel — as plaintext-seal wraps on the - * Control Plane at epoch 0. + * fixed) plus the staff-held `control_root` write key (CORD-02 §2), and emits + * exactly two owner-signed genesis editions — the community metadata and a public + * `#general` channel — as plaintext-seal wraps on the split Control Plane at + * epoch 0: signed by the `control_root`-derived signer, readable under the + * `community_root`-derived read key (CORD-02 §5). */ object ConcordCommunityFactory { const val GENERAL_CHANNEL_NAME = "general" @@ -82,9 +90,13 @@ object ConcordCommunityFactory { val ownerSalt = ConcordKeyDerivation.newOwnerSalt() val communityId = ConcordKeyDerivation.communityId(ownerXOnly, ownerSalt) val communityRoot = RandomInstance.bytes(32) + // The staff write key, minted alongside the community_root and kept deliberately + // apart from it (CORD-02 §2): members derive the Control read key from the root, + // but only control_root holders can mint a wrap at the plane's address. + val controlRoot = RandomInstance.bytes(32) val generalChannelId = RandomInstance.bytes(32) val rootEpoch = 0L - val controlPlane = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, rootEpoch) + val controlPlane = ControlPlaneKeys.forStaff(communityRoot, communityId, rootEpoch, controlRoot) val metadataJson = ConcordJson.instance.encodeToString( @@ -127,6 +139,7 @@ object ConcordCommunityFactory { ownerPubKey = ownerSigner.pubKey, ownerSalt = ownerSalt, communityRoot = communityRoot, + controlRoot = controlRoot, rootEpoch = rootEpoch, generalChannelId = generalChannelId, controlPlane = controlPlane, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index e77709ecdf..11ce25695e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -44,6 +44,13 @@ val NoExtras: JsonObject = JsonObject(emptyMap()) /** * A past root key for a specific epoch, kept so historical channel keys stay derivable. * + * [controlPk] is that epoch's Control Plane address (CORD-02 §5) when the epoch was + * split — a `control_pk` is held, never derivable from the root, so without keeping + * it a prior split epoch's Control Plane could not be re-subscribed for the + * anti-rollback floor. Null for a legacy (pre-split) epoch, whose address the root + * still derives. A client-extension field on the wire (`control_pk` inside the held + * root object), preserved verbatim by extras-honoring peers. + * * [extras] carries any key another client wrote inside this held root that we do not * model, so a read-modify-write does not delete it (see [ConcordCommunityList]). */ @@ -51,6 +58,15 @@ val NoExtras: JsonObject = JsonObject(emptyMap()) class HeldRoot( val epoch: Long, val key: String, + val controlPk: String? = null, + /** + * That epoch's staff write key, banked only if we held it. It buys nothing on the + * wire — the epoch is frozen, so writing to it is pointless — but a relay that gates + * a plane's REQ on NIP-42 AUTH as the stream key will not serve the old Control Plane + * without it, and those wraps are what rebuild the anti-rollback floor. A member who + * was never staff simply has none, and reads the epoch wherever the relay allows. + */ + val controlRoot: String? = null, val extras: JsonObject = NoExtras, ) @@ -148,6 +164,21 @@ class ConcordCommunityListEntry( val ownerSalt: String, val root: String, val rootEpoch: Long = 0, + /** + * The Control Plane signer's pubkey at [rootEpoch] (CORD-02 §2/§8): read + * access, never write. Null = a legacy, pre-split epoch — fold Control at the + * legacy address (CORD-06 §3). + */ + val controlPk: String? = null, + /** + * The staff write key at [rootEpoch] (CORD-02 §2), when this account is staff + * and has adopted it (community creation, a Grant's `control_wrap`, or a + * 136-byte base rekey blob). Null for a plain member or a legacy epoch. Part + * of the join material since CORD-02 §8 ("plus `control_root` when the member + * holds it") — the List carries every private key its holder has, so a + * staffer's own devices can write. + */ + val controlRoot: String? = null, val heldRoots: List = emptyList(), val privateChannels: List = emptyList(), val relays: List = emptyList(), @@ -250,6 +281,8 @@ object ConcordCommunityList { private class WireHeldRoot( val epoch: Long, val key: String, + @SerialName("control_pk") val controlPk: String? = null, + @SerialName("control_root") val controlRoot: String? = null, @SerialName(EXTRAS) val extras: JsonObject = NoExtras, ) @@ -260,6 +293,8 @@ object ConcordCommunityList { @SerialName("owner_salt") val ownerSalt: String, @SerialName("community_root") val communityRoot: String, @SerialName("root_epoch") val rootEpoch: Long, + @SerialName("control_pk") val controlPk: String? = null, + @SerialName("control_root") val controlRoot: String? = null, val channels: List< @Serializable(WireChannelSerializer::class) WireChannel, @@ -315,10 +350,12 @@ object ConcordCommunityList { ownerSalt = ownerSalt, communityRoot = root, rootEpoch = rootEpoch, + controlPk = controlPk, + controlRoot = controlRoot, channels = privateChannels.map { WireChannel(it.channelId, it.key, it.epoch, it.name, it.extras) }, relays = relays, name = name, - heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key, it.extras) }, + heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key, it.controlPk, it.controlRoot, it.extras) }, extras = residue.currentExtras, ) @@ -333,7 +370,9 @@ object ConcordCommunityList { ownerSalt = ownerSalt, root = communityRoot, rootEpoch = rootEpoch, - heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key, it.extras) }, + controlPk = controlPk, + controlRoot = controlRoot, + heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key, it.controlPk, it.controlRoot, it.extras) }, privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name, it.extras) }, relays = relays, name = name, @@ -499,19 +538,53 @@ object ConcordCommunityList { val byId = LinkedHashMap() for (e in a + b) { val existing = byId[e.id] - if (existing == null) { - byId[e.id] = e - } else if (e.rootEpoch > existing.rootEpoch) { - // A winner without an invite_ref inherits the loser's: that link is the only anchor - // stranded recovery has, and dropping it on a merge would disarm recovery forever. - byId[e.id] = if (e.inviteRef == null) e.withInviteRef(existing.inviteRef) else e - } else if (existing.inviteRef == null && e.inviteRef != null) { - byId[e.id] = existing.withInviteRef(e.inviteRef) - } + byId[e.id] = + when { + existing == null -> e + // A winner without an invite_ref inherits the loser's: that link is the only anchor + // stranded recovery has, and dropping it on a merge would disarm recovery forever. + // Control key material is NOT inherited across epochs — a lower epoch's + // control_pk/control_root is stale for the winner's planes (CORD-06). + e.rootEpoch > existing.rootEpoch -> e.copyWith(inviteRef = e.inviteRef ?: existing.inviteRef) + e.rootEpoch < existing.rootEpoch -> existing.copyWith(inviteRef = existing.inviteRef ?: e.inviteRef) + else -> + // Same epoch: both sides describe the same planes, so fill whatever key + // material either is missing — e.g. one device was promoted to staff + // (control_root via a Grant's control_wrap) or joined through a newer + // bundle (control_pk) while the other holds the invite anchor. + existing.copyWith( + controlPk = existing.controlPk ?: e.controlPk, + controlRoot = existing.controlRoot ?: e.controlRoot, + inviteRef = existing.inviteRef ?: e.inviteRef, + ) + } } return byId.values.toList() } + /** Field-selective copy (the entry is not a data class). Defaults keep every field. */ + private fun ConcordCommunityListEntry.copyWith( + controlPk: String? = this.controlPk, + controlRoot: String? = this.controlRoot, + inviteRef: String? = this.inviteRef, + ) = ConcordCommunityListEntry( + id = id, + owner = owner, + ownerSalt = ownerSalt, + root = root, + rootEpoch = rootEpoch, + controlPk = controlPk, + controlRoot = controlRoot, + heldRoots = heldRoots, + privateChannels = privateChannels, + relays = relays, + name = name, + addedAt = addedAt, + inviteRef = inviteRef, + excludedAtEpoch = excludedAtEpoch, + residue = residue, + ) + /** Copy of this entry carrying [inviteRef]; every other field untouched. */ fun ConcordCommunityListEntry.withInviteRef(inviteRef: String?) = ConcordCommunityListEntry( @@ -520,6 +593,32 @@ object ConcordCommunityList { ownerSalt = ownerSalt, root = root, rootEpoch = rootEpoch, + controlPk = controlPk, + controlRoot = controlRoot, + heldRoots = heldRoots, + privateChannels = privateChannels, + relays = relays, + name = name, + addedAt = addedAt, + inviteRef = inviteRef, + excludedAtEpoch = excludedAtEpoch, + residue = residue, + ) + + /** + * Copy of this entry holding [controlRoot] — the staff write key, adopted after a + * promotion delivered it (CORD-04 §3) or a base rotation carried it (CORD-06 §1). + * Every other field untouched. + */ + fun ConcordCommunityListEntry.withControlRoot(controlRoot: String?) = + ConcordCommunityListEntry( + id = id, + owner = owner, + ownerSalt = ownerSalt, + root = root, + rootEpoch = rootEpoch, + controlPk = controlPk, + controlRoot = controlRoot, heldRoots = heldRoots, privateChannels = privateChannels, relays = relays, 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 bff6b54070..039a63e7ca 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 @@ -99,6 +99,20 @@ data class AuthorityResolver private constructor( bit: Int, ): Boolean = !isBanned(pubKey) && effectivePermissions(pubKey).has(bit) + /** + * True if the member is **staff** (CORD-04 §3): the owner, or any non-banned + * holder of a Control-writing bit ([ConcordPermissions.STAFF_BITS]) — the set + * that holds the `control_root` (CORD-02 §2). + */ + fun isStaff(pubKey: String): Boolean = isOwner(pubKey) || (!isBanned(pubKey) && effectivePermissions(pubKey).hasAny(ConcordPermissions.STAFF_BITS)) + + /** + * The staff roster (lowercase hex): the owner plus every role-holder whose + * effective permissions carry a staff bit. This is the recipient set that gets + * the `control_root` in a base rotation's 136-byte blobs (CORD-06 §1). + */ + fun staffMembers(): Set = memberRoles.keys.filterTo(hashSetOf(ownerLower)) { isStaff(it) } + /** * Whether [actor] may take the action guarded by permission [bit] against * [target]. Requires: actor not banned, actor holds [bit], the owner is never diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt index 13782071cf..4753cdf64c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt @@ -42,6 +42,9 @@ value class ConcordPermissions( /** True if every bit set in [other] is also set here (used for role-vs-actor checks). */ fun hasAll(other: ConcordPermissions): Boolean = (bits and other.bits) == other.bits + /** True if at least one bit set in [other] is also set here. */ + fun hasAny(other: ConcordPermissions): Boolean = (bits and other.bits) != 0uL + infix fun union(other: ConcordPermissions): ConcordPermissions = ConcordPermissions(bits or other.bits) fun with(bit: Int): ConcordPermissions = ConcordPermissions(bits or (1uL shl bit)) @@ -71,7 +74,21 @@ value class ConcordPermissions( const val VIEW_AUDIT_LOG = 8 const val MENTION_EVERYONE = 9 - // bits 10-12 reserved + // bits 10 and 12 reserved (MANAGE_EMOJI, MANAGE_EVENTS) + + const val PIN_MESSAGES = 11 + + /** + * The **staff** bits (CORD-04 §3): the six permissions whose actions land as + * Control Plane editions. A member holding any of them, plus always the + * owner, is staff — the set that holds the `control_root` (CORD-02 §2). + * `KICK` writes to the Guestbook and `MANAGE_MESSAGES` to Chat planes, so + * neither is here. The spec's list is **normative**: a future CORD + * introducing a permission whose actions are Control editions MUST amend it + * explicitly, so no implementation judges membership of the set for itself — + * extend this constant only when the spec's list changes. + */ + val STAFF_BITS: ConcordPermissions get() = of(MANAGE_ROLES, MANAGE_CHANNELS, MANAGE_METADATA, BAN, CREATE_INVITE, PIN_MESSAGES) fun of(vararg bits: Int): ConcordPermissions { var acc = 0uL 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 71696ba8c9..33c3aba220 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 @@ -92,11 +92,20 @@ data class RoleEntity( * A Grant's content (CORD-04): maps a [member] to the set of [roleIds] they hold. * Honored only if the granting actor outranks every assigned Role and the chain * terminates at the owner (see [AuthorityResolver]). + * + * A staff-making Grant also delivers the Control Plane write secret in + * [controlWrap] (CORD-04 §3): the `control_root` NIP-44-encrypted under the + * granter↔member pairwise conversation key, its plaintext the fixed-width 40 + * bytes `epoch_be[8] ‖ control_root[32]` (see [ControlRootWrap]). Delivery, never + * authority — every reader but the member treats it as opaque bytes, and the + * member adopts the secret only if it derives to the `control_pk` they hold for + * the named epoch. */ @Serializable data class GrantEntity( val member: String = "", @SerialName("role_ids") val roleIds: List = emptyList(), + @SerialName("control_wrap") val controlWrap: String? = null, ) /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrap.kt new file mode 100644 index 0000000000..a08f16b4bf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrap.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** The opened `control_wrap` payload: the `control_root` delivered for [epoch]. */ +class DeliveredControlRoot( + val epoch: Long, + val controlRoot: ByteArray, +) + +/** + * The staff write-key delivery riding a Grant (CORD-04 §3): a staff-making Grant + * carries the current `control_root` in [GrantEntity.controlWrap], NIP-44-encrypted + * under the granter↔member pairwise conversation key — one ECDH either side can + * compute, so a NIP-46 bunker account opens it with a single `nip44Decrypt`. + * + * The plaintext is fixed-width, the rekey-blob discipline (CORD-06 §1): + * `epoch_be[8] ‖ control_root[32]`, 40 bytes. The epoch rides *inside* the + * ciphertext because staleness is structural — compaction re-wraps a Grant head + * verbatim across Refoundings, so a folded head can carry a wrap minted for a + * prior epoch's key. Harmless: the recipient adopts the secret only if it derives + * to exactly the `control_pk` they hold for the named epoch ([derivesTo]), and any + * mismatch is dropped, never adopted. + */ +object ControlRootWrap { + /** `epoch_be[8] ‖ control_root[32]` */ + const val SIZE = 40 + + fun encodePlaintext( + epoch: Long, + controlRoot: ByteArray, + ): ByteArray { + require(controlRoot.size == 32) { "controlRoot must be 32 bytes" } + val out = ByteArray(SIZE) + ConcordKeyDerivation.writeBe64(out, 0, epoch) + controlRoot.copyInto(out, 8) + return out + } + + fun decodePlaintext(bytes: ByteArray): DeliveredControlRoot? { + if (bytes.size != SIZE) return null + var epoch = 0L + for (i in 0 until 8) epoch = (epoch shl 8) or (bytes[i].toLong() and 0xFF) + return DeliveredControlRoot(epoch, bytes.copyOfRange(8, SIZE)) + } + + /** + * Builds the `control_wrap` value a staff-making Grant carries: the 40-byte + * plaintext, base64'd, then NIP-44-encrypted by [granterSigner] to + * [memberPubKey]. A staff-making edition MUST carry a wrap fresh for the + * current [epoch]. + */ + @OptIn(ExperimentalEncodingApi::class) + suspend fun build( + granterSigner: NostrSigner, + memberPubKey: HexKey, + epoch: Long, + controlRoot: ByteArray, + ): String = granterSigner.nip44Encrypt(Base64.Default.encode(encodePlaintext(epoch, controlRoot)), memberPubKey) + + /** + * Opens a received `control_wrap` with the member's own [memberSigner] against + * the Grant edition's author ([granterPubKey]). Null on any failure — a garbage + * wrap is attributable griefing, nothing worse. The caller MUST still gate + * adoption on [derivesTo] against the `control_pk` it holds for the returned + * epoch. + */ + @OptIn(ExperimentalEncodingApi::class) + suspend fun openOrNull( + controlWrap: String, + memberSigner: NostrSigner, + granterPubKey: HexKey, + ): DeliveredControlRoot? = + try { + decodePlaintext(Base64.Default.decode(memberSigner.nip44Decrypt(controlWrap, granterPubKey))) + } catch (_: Exception) { + null + } + + /** + * The adoption check (CORD-04 §3): true when [controlRoot] derives to exactly + * the [heldControlPk] this member holds for [epoch] (CORD-02 §5). A mismatch is + * dropped, never adopted — the check fails closed. + */ + fun derivesTo( + controlRoot: ByteArray, + communityId: ByteArray, + epoch: Long, + heldControlPk: HexKey, + ): Boolean = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, epoch).publicKeyHex == heldControlPk.lowercase() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt index 10bc1c2fbd..d652c70143 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt @@ -58,6 +58,19 @@ class CommunityInvite( @SerialName("owner_salt") val ownerSalt: String, @SerialName("community_root") val communityRoot: String, @SerialName("root_epoch") val rootEpoch: Long = 0, + /** + * The Control Plane's signer pubkey at [rootEpoch] (CORD-02 §5): subscribe, + * verify, read — never write. Absent = a legacy, pre-split Community; the + * joiner folds Control at the legacy address instead (CORD-06 §3). + * + * Taken on trust in a way the other fields are not: it derives from a secret + * the joiner will never hold, so nothing in the bundle can prove it. A wrong + * one is eclipse-class self-harm by the inviter (a stale or empty Control + * read), the same trust class as a hostile [relays] list — never forged + * authority, since every edition still verifies against the owner-rooted + * Roster, and a later base rotation re-delivers the true key. + */ + @SerialName("control_pk") val controlPk: String? = null, val channels: List = emptyList(), val relays: List = emptyList(), val name: String = "", diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt index 1baf91cb5f..4f6e02d447 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt @@ -76,7 +76,10 @@ object ConcordStrandedRecovery { ): ConcordCommunityListEntry? { if (!isStranded(entry, bundle)) return null - val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch } + // Bank the epoch we are leaving with its control_pk, so its Control Plane + // stays re-subscribable for the anti-rollback floor (a split epoch's address + // is held, never derivable — CORD-02 §2). + val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch } return ConcordCommunityListEntry( id = entry.id, @@ -84,6 +87,9 @@ object ConcordStrandedRecovery { ownerSalt = entry.ownerSalt, root = bundle.communityRoot, rootEpoch = bundle.rootEpoch, + // The re-minted bundle carries the new epoch's control_pk (CORD-05 §1); + // absent means the community is (still) legacy at that epoch. + controlPk = bundle.controlPk, heldRoots = held, privateChannels = entry.privateChannels, relays = if (bundle.relays.isNotEmpty()) bundle.relays else entry.relays, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt index 7a7bb34899..18df7e3c46 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord06Rekey import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.Event @@ -34,25 +35,48 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler /** * The events a Refounding produces (CORD-06 §3): the [controlWraps] (the current - * Control Plane, compacted to its per-entity head editions and re-sealed under the - * fresh [newRoot] at [newEpoch]) and the [rekeyWraps] (kind-3303 base-rotation - * blobs, sealed under the **prior** root, that deliver [newRoot] to every retained - * member and to nobody else). Publish [controlWraps] first (the new epoch's state) - * then [rekeyWraps] (the key that unlocks it). + * Control Plane, compacted to its per-entity head editions and re-sealed at the + * new epoch's split Control address — signed by the fresh `control_root`-derived + * signer, readable under the fresh [newRoot]-derived read key) and the + * [rekeyWraps] (kind-3303 base-rotation blobs, sealed under the **prior** root, + * that deliver [newRoot] + the new `control_pk` to every retained member — and + * the [newControlRoot] secret to staff — and to nobody else). Publish + * [controlWraps] first (the new epoch's state) then [rekeyWraps] (the key that + * unlocks it). */ class RefoundingBuild( val newRoot: ByteArray, + /** The fresh staff write key, minted beside [newRoot] (CORD-02 §2). */ + val newControlRoot: ByteArray, val newEpoch: Long, + /** The new epoch's split Control Plane keys (rotator view: signer held). */ + val newControlKeys: ControlPlaneKeys, val controlWraps: List, val rekeyWraps: List, -) +) { + /** The new epoch's Control Plane address, delivered to every member in the base blobs. */ + val newControlPk: ByteArray get() = newControlKeys.address.hexToByteArray() +} -/** A retained member's decrypted rekey result: the [newRoot] delivered at [newEpoch] by [rotator]. */ +/** + * A retained member's decrypted rekey result: the [newRoot] delivered at + * [newEpoch] by [rotator], plus the next epoch's Control Plane keys — the + * [newControlPk] every member's blob carries, and, for a staff recipient, the + * [newControlRoot] write secret (CORD-06 §1). A null [newControlPk] marks a + * legacy, pre-split 72-byte rotation (CORD-06 §3): its acceptor folds that + * epoch's Control at the legacy address, honored when reading old rotations and + * never minted by a compliant Rotator. + */ class ReceivedRefounding( val newRoot: ByteArray, val newEpoch: Long, val rotator: HexKey, -) + val newControlPk: ByteArray? = null, + val newControlRoot: ByteArray? = null, +) { + /** True when this was a legacy pre-split rotation (72-byte base blob). */ + val legacy: Boolean get() = newControlPk == null +} /** * Whole-community Refounding (CORD-06 §3): rotate `community_root` to sever a @@ -60,37 +84,50 @@ class ReceivedRefounding( * derive from the root, so rolling it rotates every plane at once; Private Channels * (independently keyed) are rekeyed separately and are not handled here. * + * A compliant Rotator performing any base rotation MUST mint the `control_root` + * split (CORD-02 §2) — a fresh secret beside the new root, both riding the same + * blobs — so a legacy Community upgrades as a side effect of its next Refounding, + * with nobody deciding to. + * * The builder is pure — the caller sources the retained-recipient set (from the - * Guestbook membership minus the removed/banned) and owns publish + persistence. - * All crypto is signer-based so a NIP-46 bunker owner can refound without exposing - * a raw key. + * Guestbook membership minus the removed/banned) and the staff subset (the folded + * Roster's `staffMembers()`, CORD-04 §3) and owns publish + persistence. All + * crypto is signer-based so a NIP-46 bunker owner can refound without exposing a + * raw key. */ object ConcordRefounding { /** - * Builds a Refounding: compacts the Control Plane under [newRoot] and mints the - * base-rotation rekey blobs delivering [newRoot] to [recipientsXOnly]. + * Builds a Refounding: compacts the Control Plane onto the new epoch's split + * Control address and mints the base-rotation rekey blobs delivering [newRoot] + * + the new `control_pk` to [recipientsXOnly] (the [staffXOnly] subset also + * receiving [newControlRoot]). * * @param priorRoot the community_root being rotated out (at [rootEpoch]) * @param newRoot the freshly generated 32-byte community_root + * @param newControlRoot the freshly minted 32-byte staff write key (CORD-02 §2) * @param priorControlWraps the current Control Plane's kind-1059 wraps (any subset that folds) - * @param priorControlKey the Control Plane group key at [rootEpoch] + * @param priorControlKeys the Control Plane keys at [rootEpoch] (split or legacy) * @param recipientsXOnly the retained members' x-only pubkeys (hex) to re-key + * @param staffXOnly the subset of [recipientsXOnly] that is staff (owner + Control-writing + * permission holders, CORD-04 §3) and receives the 136-byte blob */ suspend fun build( rotatorSigner: NostrSigner, communityId: ByteArray, priorRoot: ByteArray, newRoot: ByteArray, + newControlRoot: ByteArray, rootEpoch: Long, priorControlWraps: List, - priorControlKey: GroupKey, + priorControlKeys: ControlPlaneKeys, recipientsXOnly: List, + staffXOnly: Set, createdAt: Long, ): RefoundingBuild { val newEpoch = rootEpoch + 1 - val newControlKey = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, newEpoch) + val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot) - val controlWraps = compactControlPlane(priorControlWraps, priorControlKey, newControlKey) + val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys) val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch) val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey() @@ -99,33 +136,41 @@ object ConcordRefounding { rotatorSigner = rotatorSigner, baseRekeyKey = baseRekeyKey, recipientsXOnly = recipientsXOnly, + staffXOnly = staffXOnly, newRoot = newRoot, + newControlPk = newControlKeys.address.hexToByteArray(), + newControlRoot = newControlRoot, newEpoch = newEpoch, prevEpoch = rootEpoch, prevCommit = prevCommit, createdAt = createdAt, ) - return RefoundingBuild(newRoot, newEpoch, controlWraps, rekeyWraps) + return RefoundingBuild(newRoot, newControlRoot, newEpoch, newControlKeys, controlWraps, rekeyWraps) } /** - * Compacts [priorWraps] into a slim snapshot re-published under [newControlKey] + * Compacts [priorWraps] into a slim snapshot re-published under [newControlKeys] * (CORD-06 §3): keep only the head (highest-version) edition per entity and * re-wrap its **original plaintext seal** — which carries the original author's - * signature — under the new root. Because Control Plane seals are plaintext - * (CORD-02 §5), re-encryption preserves those signatures, so a fresh joiner - * verifies the compacted state exactly as it verified the full chain. + * signature — at the new epoch's Control address. Because Control Plane seals + * are plaintext (CORD-02 §5), re-encryption preserves those signatures, so a + * fresh joiner verifies the compacted state exactly as it verified the full + * chain. [priorControlKeys] may be legacy (a pre-split epoch's compaction is + * exactly how a Community upgrades to the split) or split; [newControlKeys] + * must hold the new signer. A Rotator MUST NOT mirror editions to the new + * epoch's legacy-derived address to appease stale readers — the mirror + * re-opens exactly the member-writable surface the split closes. */ fun compactControlPlane( priorWraps: List, - priorControlKey: GroupKey, - newControlKey: GroupKey, + priorControlKeys: ControlPlaneKeys, + newControlKeys: ControlPlaneKeys, ): List { // entity coordinate -> (head edition, its verified seal) val heads = HashMap>() for (wrap in priorWraps) { - val opened = ConcordStreamEnvelope.openOrNull(wrap, priorControlKey) ?: continue + val opened = ConcordStreamEnvelope.openOrNull(wrap, priorControlKeys) ?: continue val edition = ControlEdition.fromRumor(opened.rumor) ?: continue val coord = edition.entityKind.wire + ":" + edition.entityIdHex val current = heads[coord] @@ -133,12 +178,14 @@ object ConcordRefounding { heads[coord] = edition to opened.seal } } - return heads.values.map { (_, seal) -> ConcordStreamEnvelope.wrapSeal(seal, newControlKey, createdAt = seal.createdAt) } + return heads.values.map { (_, seal) -> ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt) } } /** - * Mints the base-rotation rekey blobs delivering [newRoot] to [recipientsXOnly], - * chunked at [ConcordRekey.MAX_BLOBS_PER_CHUNK] and wrapped (encrypted seal, + * Mints the base-rotation rekey blobs delivering [newRoot] + [newControlPk] to + * [recipientsXOnly] — the [staffXOnly] subset also receiving [newControlRoot] + * in the 136-byte staff form (CORD-06 §1) — chunked at + * [ConcordRekey.MAX_BLOBS_PER_CHUNK] and wrapped (encrypted seal, * rotator-signed) on the [baseRekeyKey] address so every current member — who * precomputes that address from the prior root — receives it live. */ @@ -146,16 +193,28 @@ object ConcordRefounding { rotatorSigner: NostrSigner, baseRekeyKey: GroupKey, recipientsXOnly: List, + staffXOnly: Set, newRoot: ByteArray, + newControlPk: ByteArray, + newControlRoot: ByteArray, newEpoch: Long, prevEpoch: Long, prevCommit: HexKey, createdAt: Long, ): List { if (recipientsXOnly.isEmpty()) return emptyList() + val staffLower = staffXOnly.mapTo(HashSet()) { it.lowercase() } val blobs = recipientsXOnly.map { recipient -> - ConcordRekey.blobForSigner(rotatorSigner, recipient.hexToByteArray(), ConcordRekey.ROOT_SCOPE, newEpoch, newRoot) + ConcordRekey.blobForSigner( + rotatorSigner = rotatorSigner, + recipientXOnly = recipient.hexToByteArray(), + scopeId = ConcordRekey.ROOT_SCOPE, + newEpoch = newEpoch, + newKey = newRoot, + newControlPk = newControlPk, + newControlRoot = if (recipient.lowercase() in staffLower) newControlRoot else null, + ) } val chunks = blobs.chunked(ConcordRekey.MAX_BLOBS_PER_CHUNK) val total = chunks.size @@ -170,15 +229,19 @@ object ConcordRefounding { * Receives a base rotation for the member behind [recipientSigner]: opens the * kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]), * verifies each is a well-formed root rotation to [newEpoch] whose `prevcommit` - * continues the [priorRoot] the member holds, and returns the delivered new root - * (with the rotator's real pubkey, so the caller can authorize it against the - * folded roster). Null if no chunk carries this member's blob — which only means + * continues the [priorRoot] the member holds, and returns the delivered new + * root and Control Plane keys (with the rotator's real pubkey, so the caller + * can authorize it against the folded roster). A staff blob's delivered secret + * must derive to exactly the delivered `control_pk` (CORD-02 §5) — a + * mismatched pair is refused rather than adopting a plane split from its + * readers. Null if no chunk carries this member's blob — which only means * "removed" once the caller confirms it holds every chunk of the rotation. */ suspend fun findNewRoot( wraps: List, baseRekeyKey: GroupKey, recipientSigner: NostrSigner, + communityId: ByteArray, priorRoot: ByteArray, rootEpoch: Long, ): ReceivedRefounding? { @@ -195,8 +258,16 @@ object ConcordRefounding { val blobs = ConcordRekey.decodeContent(rumor.content) val rotatorXOnly = opened.author.hexToByteArray() - val newRoot = ConcordRekey.findNewKeyWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue - return ReceivedRefounding(newRoot, newEpoch, opened.author) + val payload = ConcordRekey.findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue + val controlRoot = payload.newControlRoot + val controlPk = payload.newControlPk + if (controlRoot != null && controlPk != null) { + // The staff derive-check (CORD-06 §1): refuse a pair whose secret does not + // derive to the pk the other members were handed — fails closed. + val derived = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, newEpoch).publicKey + if (!derived.contentEquals(controlPk)) continue + } + return ReceivedRefounding(payload.newKey, newEpoch, opened.author, controlPk, controlRoot) } return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt index fed5b15eb9..84d9c1910d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt @@ -38,10 +38,12 @@ import kotlin.io.encoding.ExperimentalEncodingApi * * The rotator publishes a kind-3303 rumor whose content is a JSON array of * [RekeyBlob]s, one per remaining member. Each blob's `locator` is the recipient's - * pseudonym (public-input HKDF), and its `wrapped` field is the 72-byte - * [RekeyPayload] (base64 → NIP-44 under the rotator↔recipient pairwise key). A - * recipient computes their own locator, finds the matching blob, and decrypts the - * new key; a member with no matching blob across all chunks of a complete rotation + * pseudonym (public-input HKDF), and its `wrapped` field is the fixed-width + * [RekeyPayload] (base64 → NIP-44 under the rotator↔recipient pairwise key) — + * 72 bytes for a channel rotation, 104/136 for a base rotation's member/staff + * forms carrying the next epoch's Control Plane keys (CORD-02 §2). A recipient + * computes their own locator, finds the matching blob, and decrypts the new + * key(s); a member with no matching blob across all chunks of a complete rotation * has been removed. * * Pinned to the Concord v2 reference client for interop. @@ -57,7 +59,9 @@ object ConcordRekey { val ROOT_SCOPE: ByteArray = ByteArray(32) /** - * Builds a rekey blob delivering [newKey] to one recipient. + * Builds a rekey blob delivering [newKey] to one recipient. On a base rotation + * pass [newControlPk] (every member) and, for a staff recipient, also + * [newControlRoot] (CORD-06 §1) — the widths select the 104/136-byte forms. * * @param rotatorPrivKey the rotator's private key (their real identity) * @param rotatorXOnly the rotator's x-only pubkey @@ -71,9 +75,11 @@ object ConcordRekey { scopeId: ByteArray, newEpoch: Long, newKey: ByteArray, + newControlPk: ByteArray? = null, + newControlRoot: ByteArray? = null, ): RekeyBlob { val locator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey() - val payloadB64 = Base64.Default.encode(RekeyPayload(scopeId, newEpoch, newKey).encode()) + val payloadB64 = Base64.Default.encode(RekeyPayload(scopeId, newEpoch, newKey, newControlPk, newControlRoot).encode()) val convKey = Nip44.v2.getConversationKey(rotatorPrivKey, recipientXOnly) val wrapped = Nip44.v2.encrypt(payloadB64, convKey).encodePayload() return RekeyBlob(locator, wrapped) @@ -125,38 +131,54 @@ object ConcordRekey { scopeId: ByteArray, newEpoch: Long, newKey: ByteArray, + newControlPk: ByteArray? = null, + newControlRoot: ByteArray? = null, ): RekeyBlob { val rotatorXOnly = rotatorSigner.pubKey.hexToByteArray() val locator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey() - val payloadB64 = Base64.Default.encode(RekeyPayload(scopeId, newEpoch, newKey).encode()) + val payloadB64 = Base64.Default.encode(RekeyPayload(scopeId, newEpoch, newKey, newControlPk, newControlRoot).encode()) val wrapped = rotatorSigner.nip44Encrypt(payloadB64, recipientXOnly.toHexKey()) return RekeyBlob(locator, wrapped) } /** - * Finds the recipient's rotated key like [findNewKey], but decrypts the blob via - * [recipientSigner] (bunker-compatible) rather than a raw private key. + * Finds the recipient's whole decrypted [RekeyPayload] (scope and epoch already + * verified against the expectation) via [recipientSigner], or null if no blob + * matches or it fails to open/verify. Base rotations need the full payload — + * the delivered `new_control_pk` / `new_control_root` ride beside the key. */ @OptIn(ExperimentalEncodingApi::class) - suspend fun findNewKeyWithSigner( + suspend fun findPayloadWithSigner( blobs: List, recipientSigner: NostrSigner, rotatorXOnly: ByteArray, scopeId: ByteArray, newEpoch: Long, - ): ByteArray? { + ): RekeyPayload? { val recipientXOnly = recipientSigner.pubKey.hexToByteArray() val myLocator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey() val blob = blobs.firstOrNull { it.locator == myLocator } ?: return null return try { val payload = RekeyPayload.decode(Base64.Default.decode(recipientSigner.nip44Decrypt(blob.wrapped, rotatorXOnly.toHexKey()))) ?: return null if (!payload.scopeId.contentEquals(scopeId) || payload.epoch != newEpoch) return null - payload.newKey + payload } catch (_: Exception) { null } } + /** + * Finds the recipient's rotated key like [findNewKey], but decrypts the blob via + * [recipientSigner] (bunker-compatible) rather than a raw private key. + */ + suspend fun findNewKeyWithSigner( + blobs: List, + recipientSigner: NostrSigner, + rotatorXOnly: ByteArray, + scopeId: ByteArray, + newEpoch: Long, + ): ByteArray? = findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, scopeId, newEpoch)?.newKey + /** * Finds the recipient's rotated key across the [blobs] of one or more chunks, * or null if they were removed. Computes the recipient's locator, matches it, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt index e841183732..41856cbb8b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt @@ -26,8 +26,8 @@ import kotlinx.serialization.Serializable /** * One recipient's entry in a rekey (CORD-06): a [locator] (the recipient's * pseudonym, so only they know it's for them) and the [wrapped] new key (the - * 72-byte payload, base64'd then NIP-44-encrypted under the rotator↔recipient - * pairwise key). + * fixed-width [RekeyPayload], base64'd then NIP-44-encrypted under the + * rotator↔recipient pairwise key). */ @Serializable class RekeyBlob( @@ -36,33 +36,77 @@ class RekeyBlob( ) /** - * The 72-byte rekey payload: `scope_id[32] ‖ epoch_be8 ‖ new_key[32]` - * (CORD-06 §2). Fixed-width so a recipient can verify the scope and epoch it - * decrypts to match what they expected before adopting [newKey]. + * A rekey blob's plaintext (CORD-06 §1), fixed-width per form — the width + * declaring the form: + * + * - **72 bytes** — `scope_id[32] ‖ epoch_be8 ‖ new_key[32]`: a Channel + * rotation's blob, or a legacy pre-split *base* rotation (honored when reading + * old epochs, never minted anew — CORD-06 §3). + * - **104 bytes** — `… ‖ new_control_pk[32]`: a base rotation's member blob, + * also carrying the next epoch's Control Plane address (CORD-02 §2). + * - **136 bytes** — `… ‖ new_control_root[32]`: a base rotation's staff blob, + * additionally delivering the write secret (CORD-04 §3 staff). + * + * Any other width is malformed and the blob is dropped ([decode] returns null). + * The scope and epoch live *inside* the ciphertext so a recipient can verify them + * against the event's tags before adopting anything, making a blob unspliceable; + * a staff recipient additionally requires that [newControlRoot] derive to exactly + * [newControlPk] (CORD-02 §5) before adopting the pair. */ class RekeyPayload( val scopeId: ByteArray, val epoch: Long, val newKey: ByteArray, + /** The next epoch's `control_pk` on a base rotation; null on a channel or legacy blob. */ + val newControlPk: ByteArray? = null, + /** The next epoch's `control_root` on a staff base blob; null otherwise. */ + val newControlRoot: ByteArray? = null, ) { + init { + require(newControlRoot == null || newControlPk != null) { "a control_root is only ever delivered beside its control_pk" } + } + fun encode(): ByteArray { require(scopeId.size == 32) { "scopeId must be 32 bytes" } require(newKey.size == 32) { "newKey must be 32 bytes" } - val out = ByteArray(SIZE) + require(newControlPk == null || newControlPk.size == 32) { "newControlPk must be 32 bytes" } + require(newControlRoot == null || newControlRoot.size == 32) { "newControlRoot must be 32 bytes" } + val size = + when { + newControlRoot != null -> SIZE_BASE_STAFF + newControlPk != null -> SIZE_BASE_MEMBER + else -> SIZE_CHANNEL + } + val out = ByteArray(size) scopeId.copyInto(out, 0) ConcordKeyDerivation.writeBe64(out, 32, epoch) newKey.copyInto(out, 40) + newControlPk?.copyInto(out, 72) + newControlRoot?.copyInto(out, 104) return out } companion object { - const val SIZE = 72 + /** A Channel rotation's blob — also the legacy pre-split base form (CORD-06 §3). */ + const val SIZE_CHANNEL = 72 + + /** A base rotation's member blob: `… ‖ new_control_pk[32]`. */ + const val SIZE_BASE_MEMBER = 104 + + /** A base rotation's staff blob: `… ‖ new_control_root[32]`. */ + const val SIZE_BASE_STAFF = 136 fun decode(bytes: ByteArray): RekeyPayload? { - if (bytes.size != SIZE) return null + if (bytes.size != SIZE_CHANNEL && bytes.size != SIZE_BASE_MEMBER && bytes.size != SIZE_BASE_STAFF) return null var epoch = 0L for (i in 0 until 8) epoch = (epoch shl 8) or (bytes[32 + i].toLong() and 0xFF) - return RekeyPayload(bytes.copyOfRange(0, 32), epoch, bytes.copyOfRange(40, 72)) + return RekeyPayload( + scopeId = bytes.copyOfRange(0, 32), + epoch = epoch, + newKey = bytes.copyOfRange(40, 72), + newControlPk = if (bytes.size >= SIZE_BASE_MEMBER) bytes.copyOfRange(72, 104) else null, + newControlRoot = if (bytes.size >= SIZE_BASE_STAFF) bytes.copyOfRange(104, 136) else null, + ) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt index b59827aadd..6ff9df015d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt @@ -182,13 +182,33 @@ object ConcordKeyDerivation { // ---- Plane keys (CORD-02) ------------------------------------------------- - /** The Control Plane address for a community at [epoch] (holders of the root only). */ + /** + * The Control Plane *read* key for a community at [epoch] (CORD-02 §5): its + * `conversationKey` encrypts the wraps, so every `community_root` holder can read. + * + * On a pre-split (legacy) epoch this derivation alone was the plane — its pk the + * address and wrap signer, its sk held by every member. That use is retained for + * reading legacy epochs (CORD-06 §3); a split epoch's address comes from + * [controlSignerKey] instead. + */ fun controlPlaneKey( communityRoot: ByteArray, communityId: ByteArray, epoch: Long, ): GroupKey = groupKey(ConcordLabels.CONTROL, communityRoot, communityId, epoch) + /** + * The Control Plane *signer* for a community at [epoch] (CORD-02 §5): its pk is + * the plane's address (`control_pk`) and its sk — derivable only from the + * staff-held `control_root` — signs the wraps. Possession is a spam gate, never + * authority: every edition is still judged by its sealed actor's Roster rank. + */ + fun controlSignerKey( + controlRoot: ByteArray, + communityId: ByteArray, + epoch: Long, + ): GroupKey = groupKey(ConcordLabels.CONTROL_SIGNER, controlRoot, communityId, epoch) + /** The Guestbook Plane address for a community at [epoch]. */ fun guestbookPlaneKey( communityRoot: ByteArray, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt index 0edb36ec9f..dcdd50c616 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt @@ -35,9 +35,19 @@ object ConcordLabels { /** Per-Channel Chat Plane key (CORD-03). */ const val CHANNEL = "concord/channel" - /** Control Plane key (CORD-02). */ + /** + * Control Plane *read* key (CORD-02 §5): community_root-derived, its conv_key + * encrypts the wraps. Pre-split epochs used its pk/sk as the plane's address and + * signer too — that use is retained for reading legacy epochs (CORD-06 §3). + */ const val CONTROL = "concord/control" + /** + * Control Plane signer (CORD-02 §5): control_root-derived, its pk is the plane's + * address and its staff-only sk signs the wraps (CORD-01, Write-Restricted Streams). + */ + const val CONTROL_SIGNER = "concord/control-signer" + /** Guestbook Plane key (CORD-02). */ const val GUESTBOOK = "concord/guestbook" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ControlPlaneKeys.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ControlPlaneKeys.kt new file mode 100644 index 0000000000..6cf6c1d73f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ControlPlaneKeys.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.crypto + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray + +/** + * The Control Plane's key material at one epoch (CORD-02 §5). + * + * The plane's stream key is split (CORD-01, Write-Restricted Streams): the + * address-and-signer keypair derives from the staff-held `control_root`, while the + * wraps' content is encrypted under the `community_root`-derived read key every + * member holds. So what an account holds depends on its standing: + * + * - **Member**: the [address] (`control_pk`, held — never derivable) plus the + * [readKey]. Enough to subscribe, verify wrap signatures, and decrypt; [signer] + * is null and [canWrite] false. + * - **Staff / owner**: additionally the [signer] (derived from the `control_root`), + * whose sk mints wraps that verify at the address. + * - **Legacy epoch** (pre-split, CORD-06 §3): the `concord/control` derivation + * alone was the plane — its pk the address and signer, every member holding + * both. [legacy] is true and [signer] == [readKey]. + */ +class ControlPlaneKeys( + /** The plane's stream address (`control_pk` on a split epoch): subscribe + verify. */ + val address: HexKey, + /** The `community_root`-derived read key; its `conversationKey` opens the wraps. */ + val readKey: GroupKey, + /** The keypair whose sk signs wraps at [address]. Null when this account cannot write. */ + val signer: GroupKey?, + /** True for a pre-split epoch keyed by the legacy member-held derivation. */ + val legacy: Boolean, +) { + /** True when this account holds the write key for the plane. */ + val canWrite: Boolean get() = signer != null + + companion object { + /** + * A pre-split epoch's Control Plane: the legacy `concord/control` derivation + * is address, signer, and read key at once — every member holds all three. + */ + fun legacy( + communityRoot: ByteArray, + communityId: ByteArray, + epoch: Long, + ): ControlPlaneKeys { + val key = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, epoch) + return ControlPlaneKeys(key.publicKeyHex, key, signer = key, legacy = true) + } + + /** + * A split epoch as a regular member holds it: the delivered [controlPk] + * (invite / community list / base rekey blob, CORD-02 §2) plus the derived + * read key. Read-only — a member cannot mint a wrap at the address. + */ + fun forMember( + communityRoot: ByteArray, + communityId: ByteArray, + epoch: Long, + controlPk: HexKey, + ): ControlPlaneKeys = + ControlPlaneKeys( + address = controlPk.lowercase(), + readKey = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, epoch), + signer = null, + legacy = false, + ) + + /** + * A split epoch as staff holds it: the signer derives from the held + * [controlRoot], yielding the address and the write key together. + */ + fun forStaff( + communityRoot: ByteArray, + communityId: ByteArray, + epoch: Long, + controlRoot: ByteArray, + ): ControlPlaneKeys { + val signer = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, epoch) + return ControlPlaneKeys( + address = signer.publicKeyHex, + readKey = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, epoch), + signer = signer, + legacy = false, + ) + } + + /** + * Dispatches on what the account holds: the `control_root` secret (staff), + * only the `control_pk` (member), or neither — a legacy, pre-split epoch + * (CORD-06 §3). A held [controlRoot] wins over a held [controlPk]: the pk it + * derives is the plane by definition (a delivered secret is only ever adopted + * after the derive-check, CORD-04 §3). + */ + fun of( + communityRoot: ByteArray, + communityId: ByteArray, + epoch: Long, + controlPk: HexKey? = null, + controlRoot: HexKey? = null, + ): ControlPlaneKeys = + when { + controlRoot != null -> forStaff(communityRoot, communityId, epoch, controlRoot.hexToByteArray()) + controlPk != null -> forMember(communityRoot, communityId, epoch, controlPk) + else -> legacy(communityRoot, communityId, epoch) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt index acd8c2a007..7975d3aa37 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.quartz.concord.envelope +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.verify @@ -96,14 +98,45 @@ object ConcordStreamEnvelope { stream: GroupKey, ephemeral: Boolean = false, createdAt: Long = TimeUtils.now(), + ): Event = wrapSeal(seal, stream, stream.conversationKey, ephemeral, createdAt) + + /** + * Write-restricted variant (CORD-01, Write-Restricted Streams): the wrap is + * signed by [signerKey] (its pk the stream address, its sk held by the writers + * alone) while the content is encrypted under [readConversationKey], the second + * shared key the full readership holds. Concord's Control Plane wraps this way + * on a split epoch (CORD-02 §5). + */ + fun wrapSeal( + seal: Event, + signerKey: GroupKey, + readConversationKey: ByteArray, + ephemeral: Boolean = false, + createdAt: Long = TimeUtils.now(), ): Event { - val streamSigner = NostrSignerSync(KeyPair(privKey = stream.secretKey)) - val content = Nip44.v2.encrypt(seal.toJson(), stream.conversationKey).encodePayload() + val streamSigner = NostrSignerSync(KeyPair(privKey = signerKey.secretKey)) + val content = Nip44.v2.encrypt(seal.toJson(), readConversationKey).encodePayload() val ephemeralP = KeyPair().pubKey.toHexKey() val kind = if (ephemeral) KIND_WRAP_EPHEMERAL else KIND_WRAP return streamSigner.signNormal(createdAt, kind, arrayOf(arrayOf("p", ephemeralP)), content) } + /** + * Wraps [seal] onto the Control Plane described by [keys]: signed by its signer + * (which the holder must have — throws when [ControlPlaneKeys.canWrite] is + * false), encrypted under its read key. On a legacy epoch signer == read key + * and this is the classic single-key wrap. + */ + fun wrapSeal( + seal: Event, + keys: ControlPlaneKeys, + ephemeral: Boolean = false, + createdAt: Long = TimeUtils.now(), + ): Event { + val signer = requireNotNull(keys.signer) { "This account cannot write to the Control Plane: control_root not held (CORD-02 §2)" } + return wrapSeal(seal, signer, keys.readKey.conversationKey, ephemeral, createdAt) + } + /** Convenience: [seal] then [wrapSeal] in one call. */ suspend fun wrap( rumor: Event, @@ -114,6 +147,20 @@ object ConcordStreamEnvelope { createdAt: Long = TimeUtils.now(), ): Event = wrapSeal(seal(rumor, stream, authorSigner, encrypted), stream, ephemeral, createdAt) + /** + * Convenience for the Control Plane: seals under [keys]' read key (an encrypted + * seal's rumor must decrypt for every reader, not only writers) and wraps with + * its signer. Throws when the account cannot write (see [wrapSeal]). + */ + suspend fun wrap( + rumor: Event, + keys: ControlPlaneKeys, + authorSigner: NostrSigner, + encrypted: Boolean, + ephemeral: Boolean = false, + createdAt: Long = TimeUtils.now(), + ): Event = wrapSeal(seal(rumor, keys.readKey, authorSigner, encrypted), keys, ephemeral, createdAt) + /** * Opens a stream [wrap] for the [stream] plane and returns the verified author * rumor, or throws if any layer fails to validate: @@ -129,16 +176,31 @@ object ConcordStreamEnvelope { fun open( wrap: Event, stream: GroupKey, + ): OpenedStreamEvent = open(wrap, stream.publicKeyHex, stream.conversationKey) + + /** + * Write-restricted variant (CORD-01, Write-Restricted Streams): opening takes + * only the stream [address] (the writers' pubkey, held by every reader) and the + * [readConversationKey] — never the signer's secret. `wrap.verify()` checks the + * signature against `wrap.pubkey`, which the address equality pins to the + * writers' key, so a wrap minted by anyone else fails here. A verifying wrap + * proves only that *a* writer published it; the seal's actor stays the sole + * authority (CORD-04). + */ + fun open( + wrap: Event, + address: HexKey, + readConversationKey: ByteArray, ): OpenedStreamEvent { require(wrap.kind == KIND_WRAP || wrap.kind == KIND_WRAP_EPHEMERAL) { "Not a Concord stream wrap: kind ${wrap.kind}" } - require(wrap.pubKey == stream.publicKeyHex) { - "Wrap author ${wrap.pubKey} is not the stream address ${stream.publicKeyHex}" + require(wrap.pubKey == address) { + "Wrap author ${wrap.pubKey} is not the stream address $address" } require(wrap.verify()) { "Wrap signature/id is invalid" } - val seal = Event.fromJson(Nip44.v2.decrypt(wrap.content, stream.conversationKey)) + val seal = Event.fromJson(Nip44.v2.decrypt(wrap.content, readConversationKey)) require(seal.kind == KIND_SEAL_ENCRYPTED || seal.kind == KIND_SEAL_PLAINTEXT) { "Not a Concord seal: kind ${seal.kind}" } @@ -146,7 +208,7 @@ object ConcordStreamEnvelope { val rumorJson = if (seal.kind == KIND_SEAL_ENCRYPTED) { - Nip44.v2.decrypt(seal.content, stream.conversationKey) + Nip44.v2.decrypt(seal.content, readConversationKey) } else { seal.content } @@ -160,17 +222,40 @@ object ConcordStreamEnvelope { return OpenedStreamEvent(rumor, seal.kind, seal.pubKey, seal) } + /** + * Opens a Control Plane wrap with [keys] (split or legacy): verified against the + * plane's address, decrypted under its read key. Needs no write key, so a regular + * member reads exactly as staff does (CORD-02 §5). + */ + fun open( + wrap: Event, + keys: ControlPlaneKeys, + ): OpenedStreamEvent = open(wrap, keys.address, keys.readKey.conversationKey) + /** Like [open] but returns null instead of throwing on any validation failure. */ fun openOrNull( wrap: Event, stream: GroupKey, + ): OpenedStreamEvent? = openOrNull(wrap, stream.publicKeyHex, stream.conversationKey) + + /** Like the write-restricted [open] but returns null instead of throwing. */ + fun openOrNull( + wrap: Event, + address: HexKey, + readConversationKey: ByteArray, ): OpenedStreamEvent? = try { - open(wrap, stream) + open(wrap, address, readConversationKey) } catch (_: Exception) { null } + /** Opens a Control Plane wrap with [keys] (split or legacy), or null on failure. */ + fun openOrNull( + wrap: Event, + keys: ControlPlaneKeys, + ): OpenedStreamEvent? = openOrNull(wrap, keys.address, keys.readKey.conversationKey) + private val EMPTY_TAGS = emptyArray>() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt index 0c234713ca..e42b0e0c79 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt @@ -31,6 +31,7 @@ import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -55,12 +56,22 @@ class ConcordCommunityFactoryTest { community.communityId, ) - // Two genesis wraps, both authored by the Control Plane address. + // Two genesis wraps, both authored by the Control Plane address — which on a fresh + // community is the control_root-derived signer, not the community_root (CORD-02 §5). assertEquals(2, community.genesisWraps.size) community.genesisWraps.forEach { assertEquals(ConcordStreamEnvelope.KIND_WRAP, it.kind) - assertEquals(community.controlPlane.publicKeyHex, it.pubKey) + assertEquals(community.controlPlane.address, it.pubKey) } + assertEquals( + ConcordKeyDerivation.controlSignerKey(community.controlRoot, community.communityId, community.rootEpoch).publicKeyHex, + community.controlPkHex, + ) + // The plane is genuinely split: its address is NOT the legacy community_root derivation. + assertNotEquals( + ConcordKeyDerivation.controlPlaneKey(community.communityRoot, community.communityId, community.rootEpoch).publicKeyHex, + community.controlPkHex, + ) // Genesis wraps open with plaintext (20014) seals, authored by the owner. val opened = community.genesisWraps.map { ConcordStreamEnvelope.open(it, community.controlPlane) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt index b6ccaa8f81..4da831f09f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt @@ -35,6 +35,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class ConcordCommunityListTest { @@ -45,14 +46,20 @@ class ConcordCommunityListTest { id: String, name: String, epoch: Long = 0, + controlPk: String? = null, + controlRoot: String? = null, + inviteRef: String? = null, ) = ConcordCommunityListEntry( id = id, owner = "0f".repeat(32), ownerSalt = "aa".repeat(32), root = "bb".repeat(32), rootEpoch = epoch, + controlPk = controlPk, + controlRoot = controlRoot, relays = listOf("wss://relay.example"), name = name, + inviteRef = inviteRef, ) @Test @@ -417,7 +424,7 @@ class ConcordCommunityListTest { ownerSalt = decoded.ownerSalt, root = decoded.root, rootEpoch = decoded.rootEpoch, - heldRoots = decoded.heldRoots.map { HeldRoot(it.epoch, it.key, buildJsonObject { put("key", "STALE") }) }, + heldRoots = decoded.heldRoots.map { HeldRoot(it.epoch, it.key, it.controlPk, it.controlRoot, buildJsonObject { put("key", "STALE") }) }, privateChannels = decoded.privateChannels.map { PrivateChannelKey(it.channelId, it.key, it.epoch, it.name, buildJsonObject { put("name", "STALE") }) }, relays = decoded.relays, name = "Renamed", @@ -486,4 +493,48 @@ class ConcordCommunityListTest { assertEquals(2, merged.size) assertEquals("New", merged.first { it.id == "11".repeat(32) }.name) // higher epoch wins } + + @Test + fun mergeAtTheSameEpochFillsMissingControlKeyMaterialFromEitherSide() { + // The second-device gap (CORD-02 §8): device A was promoted to staff (it adopted the + // control_root via a Grant's control_wrap), device B holds the invite anchor and the + // delivered control_pk. Both describe the same epoch, so a merge must end holding all + // of it — this is how the write secret reaches a staffer's own other devices. + val id = "11".repeat(32) + val promoted = listOf(entry(id, "Nostrichs", epoch = 2, controlPk = "cc".repeat(32), controlRoot = "dd".repeat(32))) + val joined = listOf(entry(id, "Nostrichs", epoch = 2, controlPk = "cc".repeat(32), inviteRef = "https://vector.chat/i/abc")) + + val merged = ConcordCommunityList.merge(promoted, joined).single() + assertEquals("cc".repeat(32), merged.controlPk) + assertEquals("dd".repeat(32), merged.controlRoot, "the staff write key must reach the holder's other devices") + assertEquals("https://vector.chat/i/abc", merged.inviteRef, "the recovery anchor must survive the fill") + + // Order-independent: the fill works whichever side holds the secret. + val reversed = ConcordCommunityList.merge(joined, promoted).single() + assertEquals("dd".repeat(32), reversed.controlRoot) + assertEquals("https://vector.chat/i/abc", reversed.inviteRef) + } + + @Test + fun mergeNeverInheritsControlKeysAcrossEpochs() { + // A lower epoch's control_pk/control_root is stale for the winner's planes (CORD-06): + // the pair rolls at every Refounding, so carrying it forward would point the client at + // a dead address (pk) or derive a wrong one entirely (root). A winner without control + // material is a legacy rotation and must stay that way. + val id = "11".repeat(32) + val stale = listOf(entry(id, "Old", epoch = 1, controlPk = "cc".repeat(32), controlRoot = "dd".repeat(32), inviteRef = "https://vector.chat/i/abc")) + val rotated = listOf(entry(id, "New", epoch = 2)) + + val merged = ConcordCommunityList.merge(stale, rotated).single() + assertEquals(2, merged.rootEpoch) + assertNull(merged.controlPk, "a prior epoch's control_pk must not shadow the new epoch") + assertNull(merged.controlRoot, "a prior epoch's control_root must die with its epoch") + assertEquals("https://vector.chat/i/abc", merged.inviteRef) // the anchor, and only the anchor, survives + + val reversed = ConcordCommunityList.merge(rotated, stale).single() + assertEquals(2, reversed.rootEpoch) + assertNull(reversed.controlPk) + assertNull(reversed.controlRoot) + assertEquals("https://vector.chat/i/abc", reversed.inviteRef) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ControlPlaneSplitTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ControlPlaneSplitTest.kt new file mode 100644 index 0000000000..bbb60b54fc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ControlPlaneSplitTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The Control Plane's write restriction (CORD-01 Write-Restricted Streams, CORD-02 §2/§5). + * + * Every member holds the derived `control_pk` to subscribe, verify and read under the + * `community_root`-derived read key, but only the owner and staff hold the `control_root` + * the signer derives from — so a member can read every edition and mint none. + */ +class ControlPlaneSplitTest { + private val owner = NostrSignerInternal(KeyPair()) + private val now = 1_700_000_000L + + private val communityRoot = ByteArray(32) { 0x11 } + private val controlRoot = ByteArray(32) { 0x22 } + private val communityId = ByteArray(32) { 0x33 } + private val epoch = 0L + + private fun staffView() = ControlPlaneKeys.forStaff(communityRoot, communityId, epoch, controlRoot) + + private fun memberView() = ControlPlaneKeys.forMember(communityRoot, communityId, epoch, staffView().address) + + private suspend fun edition(createdAt: Long = now) = + ControlEditionBuilder.rumor( + authorPubKey = owner.pubKey, + entityKind = ControlEntityKind.METADATA, + entityId = communityId, + version = 0, + prevHash = null, + content = """{"name":"Nostrichs"}""", + createdAt = createdAt, + ) + + @Test + fun theSignerAndTheReadKeyAreDifferentKeysUnderDifferentLabels() { + val staff = staffView() + val legacy = ControlPlaneKeys.legacy(communityRoot, communityId, epoch) + + // The address derives from the control_root, the read key from the community_root. + assertEquals(ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, epoch).publicKeyHex, staff.address) + assertNotEquals(staff.address, staff.readKey.publicKeyHex) + + // The two schemes never collide: different labels, different addresses (CORD-02 §5). + assertNotEquals(legacy.address, staff.address) + + // A legacy epoch is address, signer and read key at once — every member holds all three. + assertTrue(legacy.legacy) + assertTrue(legacy.canWrite) + assertEquals(legacy.address, legacy.readKey.publicKeyHex) + } + + @Test + fun aMemberReadsEveryEditionButHoldsNoWriteKey() = + runTest { + val staff = staffView() + val member = memberView() + + assertTrue(staff.canWrite) + assertFalse(member.canWrite, "a member must never hold the Control Plane write key") + // Same plane: same address to subscribe to, same conversation key to decrypt with. + assertEquals(staff.address, member.address) + assertContentEqualsHex(staff.readKey.conversationKey, member.readKey.conversationKey) + + val wrap = ConcordStreamEnvelope.wrap(edition(), staff, owner, encrypted = false, createdAt = now) + assertEquals(staff.address, wrap.pubKey) + + val opened = ConcordStreamEnvelope.openOrNull(wrap, member) + assertNotNull(opened, "a member must be able to read a staff-written edition") + assertEquals(owner.pubKey, opened.author) + assertNotNull(ControlEdition.fromRumor(opened.rumor)) + } + + @Test + fun aMemberCannotMintAWrapThatVerifiesAtThePlaneAddress() = + runTest { + val member = memberView() + + // The only stream key a member holds is the community_root-derived read key. Signing + // with it produces a wrap at the WRONG address — the spam gate the split exists for. + val forged = ConcordStreamEnvelope.wrap(edition(), member.readKey, owner, encrypted = false, createdAt = now) + assertNotEquals(member.address, forged.pubKey) + assertNull(ConcordStreamEnvelope.openOrNull(forged, member), "a member-signed wrap must not open at the plane") + assertNull(ConcordStreamEnvelope.openOrNull(forged, staffView())) + } + + @Test + fun aWrapFromAnUnrelatedKeyIsRefusedAtTheAddressCheck() = + runTest { + val staff = staffView() + // A spammer who somehow learned the read key still cannot mint at the address: the + // wrap's author must BE the address, and only control_root holders can produce it. + val strangerSecret = RandomInstance.bytes(32) + val stranger = ConcordKeyDerivation.groupKey("concord/whatever", strangerSecret, communityId, epoch) + val forged = ConcordStreamEnvelope.wrapSeal(ConcordStreamEnvelope.seal(edition(), staff.readKey, owner, encrypted = false), stranger, staff.readKey.conversationKey, createdAt = now) + + assertNull(ConcordStreamEnvelope.openOrNull(forged, staff)) + assertNull(ConcordStreamEnvelope.openOrNull(forged, memberView())) + } + + @Test + fun wrappingWithoutTheWriteKeyIsRefusedRatherThanSilentlyMissigned() = + runTest { + val member = memberView() + val seal = ConcordStreamEnvelope.seal(edition(), member.readKey, owner, encrypted = false) + var threw = false + try { + ConcordStreamEnvelope.wrapSeal(seal, member) + } catch (_: IllegalArgumentException) { + threw = true + } + assertTrue(threw, "wrapping on a plane we cannot write to must fail loudly") + } + + @Test + fun aLegacyEpochStaysReadableAfterTheSplitExists() = + runTest { + // A Community minted before the split keyed its plane by the member-held derivation. + // A client MUST retain that reading (CORD-02 §5) — the upgrade is the next Refounding. + val legacy = ControlPlaneKeys.legacy(communityRoot, communityId, epoch) + val wrap = ConcordStreamEnvelope.wrap(edition(), legacy, owner, encrypted = false, createdAt = now) + + val opened = ConcordStreamEnvelope.openOrNull(wrap, legacy) + assertNotNull(opened) + assertEquals(owner.pubKey, opened.author) + + // And it does not leak into the split scheme: the split plane refuses it. + assertNull(ConcordStreamEnvelope.openOrNull(wrap, staffView())) + } + + @Test + fun heldSecretsSelectTheViewOfAnEpoch() { + val staffAddress = staffView().address + + // Holding the secret: staff view, write key derived. + val asStaff = ControlPlaneKeys.of(communityRoot, communityId, epoch, controlPk = staffAddress, controlRoot = controlRoot.toHex()) + assertTrue(asStaff.canWrite) + assertEquals(staffAddress, asStaff.address) + + // Holding only the address: member view, read-only. + val asMember = ControlPlaneKeys.of(communityRoot, communityId, epoch, controlPk = staffAddress) + assertFalse(asMember.canWrite) + assertEquals(staffAddress, asMember.address) + + // Holding neither: a legacy, pre-split epoch. + val asLegacy = ControlPlaneKeys.of(communityRoot, communityId, epoch) + assertTrue(asLegacy.legacy) + assertNotEquals(staffAddress, asLegacy.address) + } + + private fun assertContentEqualsHex( + a: ByteArray, + b: ByteArray, + ) = assertEquals(a.toHex(), b.toHex()) + + private fun ByteArray.toHex(): String = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt index ec6ecae8c6..1d60fe7d91 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt @@ -582,4 +582,37 @@ class AuthorityResolverTest { assertFalse(r.isBanned(alice), "the part it does not outrank is dropped") assertTrue(r.isBanned(carol), "the part it does outrank still lands") } + + @Test + fun staffIsTheOwnerPlusEveryControlWritingBitHolder() { + // Staff (CORD-04 §3) is the set that holds the control_root (CORD-02 §2): the owner + // always, plus every non-banned holder of a Control-writing bit. This set decides who + // receives the 136-byte staff blob at a Refounding (CORD-06 §1). + val pinRole = "33".repeat(32) + // PIN_MESSAGES alone (bit 11 = 2048) writes Control editions, so it is a staff bit. + val pinJson = """{"name":"Curator","position":6,"permissions":"2048"}""" + val r = + AuthorityResolver.resolve( + listOf( + role(adminRole, adminJson), // MANAGE_ROLES|KICK|BAN → staff via MANAGE_ROLES/BAN + role(modRole, modJson), // KICK only → Guestbook writer, NOT staff + role(pinRole, pinJson), + grant("31".repeat(32), alice, listOf(adminRole), granter = owner), + grant("32".repeat(32), bob, listOf(modRole), granter = owner), + grant("33".repeat(32), carol, listOf(adminRole), granter = owner), + grant("34".repeat(32), dave, listOf(pinRole), granter = owner), + banlist(carol), // a banned admin loses staff standing with everything else + ), + owner, + ) + + assertTrue(r.isStaff(owner), "the owner is always staff") + assertTrue(r.isStaff(alice), "a Control-writing bit makes staff") + assertTrue(r.isStaff(dave), "PIN_MESSAGES lands as Control editions, so it is a staff bit") + assertFalse(r.isStaff(bob), "KICK writes to the Guestbook, never the Control Plane") + assertFalse(r.isStaff(carol), "a banned member is not staff") + assertFalse(r.isStaff("e5".repeat(32)), "a roleless member is not staff") + + assertEquals(setOf(owner, alice, dave), r.staffMembers()) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrapTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrapTest.kt new file mode 100644 index 0000000000..5feb9ddeb2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrapTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The staff write-key delivery riding a Grant (CORD-04 §3): promotion and key delivery + * are one signed edition, opaque pairwise ciphertext to every other reader, and adopted + * only after the derive-check — which fails closed. + */ +class ControlRootWrapTest { + private val granter = NostrSignerInternal(KeyPair()) + private val member = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + + private val communityId = ByteArray(32) { 0x33 } + private val controlRoot = ByteArray(32) { 0x22 } + private val epoch = 7L + + private fun controlPkAt( + root: ByteArray = controlRoot, + at: Long = epoch, + ) = ConcordKeyDerivation.controlSignerKey(root, communityId, at).publicKeyHex + + @Test + fun theWirePlaintextIsFortyBytesEpochThenSecret() { + val plaintext = ControlRootWrap.encodePlaintext(epoch, controlRoot) + assertEquals(ControlRootWrap.SIZE, plaintext.size) + assertEquals(40, plaintext.size) + + val decoded = ControlRootWrap.decodePlaintext(plaintext) + assertNotNull(decoded) + assertEquals(epoch, decoded.epoch) + assertContentEquals(controlRoot, decoded.controlRoot) + + // Any other width is malformed — the rekey-blob discipline (CORD-06 §1). + assertNull(ControlRootWrap.decodePlaintext(ByteArray(39))) + assertNull(ControlRootWrap.decodePlaintext(ByteArray(41))) + } + + @Test + fun thePromotedMemberOpensItAndNobodyElseCan() = + runTest { + val wrap = ControlRootWrap.build(granter, member.pubKey, epoch, controlRoot) + + val opened = ControlRootWrap.openOrNull(wrap, member, granter.pubKey) + assertNotNull(opened) + assertEquals(epoch, opened.epoch) + assertContentEquals(controlRoot, opened.controlRoot) + + // Every other reader of the plane sees opaque bytes. + assertNull(ControlRootWrap.openOrNull(wrap, stranger, granter.pubKey)) + } + + @Test + fun theGranterCanReopenItsOwnDeliveryBecauseTheKeyIsPairwise() = + runTest { + // One ECDH either side can compute, so a NIP-46 bunker account opens it with a + // single nip44_decrypt and a re-issuing staffer needs no stored copy. + val wrap = ControlRootWrap.build(granter, member.pubKey, epoch, controlRoot) + val opened = ControlRootWrap.openOrNull(wrap, granter, member.pubKey) + assertNotNull(opened) + assertContentEquals(controlRoot, opened.controlRoot) + } + + @Test + fun adoptionRequiresTheSecretToDeriveToTheHeldAddress() { + assertTrue(ControlRootWrap.derivesTo(controlRoot, communityId, epoch, controlPkAt())) + + // A garbage secret is attributable griefing, nothing worse: it is dropped, never adopted. + assertFalse(ControlRootWrap.derivesTo(ByteArray(32) { 0x77 }, communityId, epoch, controlPkAt())) + + // The epoch binds too — a secret for another epoch derives elsewhere. + assertFalse(ControlRootWrap.derivesTo(controlRoot, communityId, epoch + 1, controlPkAt())) + + // And so does the community: the same secret in another Community is another plane. + assertFalse(ControlRootWrap.derivesTo(controlRoot, ByteArray(32) { 0x44 }, epoch, controlPkAt())) + } + + @Test + fun aWrapMintedForAPriorEpochFailsTheCheckRatherThanBeingAdopted() = + runTest { + // Compaction re-wraps a Grant head verbatim across Refoundings, so a folded head can + // carry a wrap minted for a prior epoch's key. Staleness is structural and harmless. + val staleWrap = ControlRootWrap.build(granter, member.pubKey, epoch, controlRoot) + val opened = ControlRootWrap.openOrNull(staleWrap, member, granter.pubKey) + assertNotNull(opened) + + val currentEpoch = epoch + 1 + val currentControlRoot = ByteArray(32) { 0x55 } + assertEquals(epoch, opened.epoch, "the epoch rides inside the ciphertext, not beside it") + assertFalse( + ControlRootWrap.derivesTo(opened.controlRoot, communityId, currentEpoch, controlPkAt(currentControlRoot, currentEpoch)), + "a stale wrap must fail closed at the current epoch", + ) + } + + @Test + fun aGrantCarriesTheWrapThroughTheWireShapeAndSurvivesARoundTrip() = + runTest { + val wrap = ControlRootWrap.build(granter, member.pubKey, epoch, controlRoot) + val grant = GrantEntity(member = member.pubKey, roleIds = listOf("ab".repeat(32)), controlWrap = wrap) + + val json = ConcordJson.instance.encodeToString(GrantEntity.serializer(), grant) + assertTrue(json.contains("control_wrap"), "the wire field is snake_case (CORD-04 §2)") + + val decoded = ConcordJson.decodeOrNull(json) + assertNotNull(decoded) + assertEquals(wrap, decoded.controlWrap) + + // A plain grant carries none, and a reader must cope with its absence. + val plain = ConcordJson.decodeOrNull("""{"member":"${member.pubKey}","role_ids":[]}""") + assertNotNull(plain) + assertNull(plain.controlWrap) + } + + @Test + fun theStaffBitsAreTheControlWritingPermissions() { + // The six bits whose actions land as Control editions (CORD-04 §3). + val staff = ConcordPermissions.STAFF_BITS + assertTrue(staff.has(ConcordPermissions.MANAGE_ROLES)) + assertTrue(staff.has(ConcordPermissions.MANAGE_CHANNELS)) + assertTrue(staff.has(ConcordPermissions.MANAGE_METADATA)) + assertTrue(staff.has(ConcordPermissions.BAN)) + assertTrue(staff.has(ConcordPermissions.CREATE_INVITE)) + assertTrue(staff.has(ConcordPermissions.PIN_MESSAGES)) + + // KICK writes to the Guestbook and MANAGE_MESSAGES to Chat planes; neither needs the key. + assertFalse(staff.has(ConcordPermissions.KICK)) + assertFalse(staff.has(ConcordPermissions.MANAGE_MESSAGES)) + + // PIN_MESSAGES claims the frozen bit 11 (CORD-04 §3 table). + assertEquals(11, ConcordPermissions.PIN_MESSAGES) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt index 3d365679c8..5d6d9209ca 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.concord.cord05Invites import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition -import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -52,6 +52,9 @@ class ConcordInviteJoinFlowTest { ownerSalt = community.ownerSalt.toHexKey(), communityRoot = community.communityRoot.toHexKey(), rootEpoch = community.rootEpoch, + // Read access to the Control Plane, never write (CORD-05 §1): the joiner cannot + // derive this address, so the bundle is the only place it can come from. + controlPk = community.controlPkHex, relays = listOf("wss://relay.example"), name = "Nostrichs", ) @@ -73,13 +76,18 @@ class ConcordInviteJoinFlowTest { assertTrue(ConcordInviteBundle.validate(invite)) assertEquals(community.communityIdHex, invite.communityId) - // Reconstruct the root, derive the Control Plane, and read the genesis. + // Reconstruct the root and open the Control Plane as a plain member does: the + // delivered control_pk is the address to verify against, the derived read key + // decrypts (CORD-02 §5). No write key anywhere on this path. + assertNotNull(invite.controlPk) val controlPlane = - ConcordKeyDerivation.controlPlaneKey( + ControlPlaneKeys.forMember( invite.communityRoot.hexToByteArray(), invite.communityId.hexToByteArray(), invite.rootEpoch, + invite.controlPk, ) + assertFalse(controlPlane.canWrite, "an invite must never hand a joiner the write key") val editions = community.genesisWraps.mapNotNull { ControlEdition.fromRumor(ConcordStreamEnvelope.open(it, controlPlane).rumor) } val state = ConcordCommunityState.fold(editions, invite.owner) assertEquals("Nostrichs", state.metadata?.name) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt index fa919e9b14..06778c4c71 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt @@ -48,6 +48,9 @@ class ConcordRefoundingTest { private val carol = NostrSignerInternal(KeyPair()) // removed private val newRoot = ByteArray(32) { 0x5A } + + /** The fresh staff write key minted beside [newRoot] at every Refounding (CORD-02 §2). */ + private val newControlRoot = ByteArray(32) { 0x6B } private val now = 1_700_000_000L @Test @@ -64,10 +67,12 @@ class ConcordRefoundingTest { communityId = communityId, priorRoot = priorRoot, newRoot = newRoot, + newControlRoot = newControlRoot, rootEpoch = community.rootEpoch, priorControlWraps = community.genesisWraps, - priorControlKey = priorControl, + priorControlKeys = priorControl, recipientsXOnly = listOf(alice.pubKey, bob.pubKey), + staffXOnly = setOf(owner.pubKey), createdAt = now, ) @@ -77,9 +82,9 @@ class ConcordRefoundingTest { val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, build.newEpoch) // Alice and Bob find the new root; Carol (no blob) does not. - val aliceRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, priorRoot, community.rootEpoch) - val bobRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, bob, priorRoot, community.rootEpoch) - val carolRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, carol, priorRoot, community.rootEpoch) + val aliceRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, communityId, priorRoot, community.rootEpoch) + val bobRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, bob, communityId, priorRoot, community.rootEpoch) + val carolRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, carol, communityId, priorRoot, community.rootEpoch) assertNotNull(aliceRoot) assertContentEquals(newRoot, aliceRoot.newRoot) @@ -101,14 +106,16 @@ class ConcordRefoundingTest { communityId = communityId, priorRoot = community.communityRoot, newRoot = newRoot, + newControlRoot = newControlRoot, rootEpoch = community.rootEpoch, priorControlWraps = community.genesisWraps, - priorControlKey = community.controlPlane, + priorControlKeys = community.controlPlane, recipientsXOnly = listOf(alice.pubKey), + staffXOnly = setOf(owner.pubKey), createdAt = now, ) - val newControl = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, build.newEpoch) + val newControl = build.newControlKeys // Re-open the compacted wraps under the NEW control key and fold: same authority + metadata. val editions = @@ -170,14 +177,16 @@ class ConcordRefoundingTest { communityId = communityId, priorRoot = community.communityRoot, newRoot = newRoot, + newControlRoot = newControlRoot, rootEpoch = community.rootEpoch, priorControlWraps = priorWraps, - priorControlKey = control, + priorControlKeys = control, recipientsXOnly = listOf(alice.pubKey), + staffXOnly = setOf(owner.pubKey), createdAt = now, ) - val newControl = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, build.newEpoch) + val newControl = build.newControlKeys val editions = build.controlWraps.mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, newControl)?.let { ControlEdition.fromRumor(it.rumor) } @@ -207,16 +216,18 @@ class ConcordRefoundingTest { communityId = community.communityId, priorRoot = community.communityRoot, newRoot = newRoot, + newControlRoot = newControlRoot, rootEpoch = community.rootEpoch, priorControlWraps = community.genesisWraps, - priorControlKey = community.controlPlane, + priorControlKeys = community.controlPlane, recipientsXOnly = listOf(alice.pubKey), + staffXOnly = setOf(owner.pubKey), createdAt = now, ) val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch) // Alice claims a different prior root: prevcommit mismatch ⇒ rotation rejected. val wrongRoot = ByteArray(32) { 0x11 } - assertNull(ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, wrongRoot, community.rootEpoch)) + assertNull(ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, community.communityId, wrongRoot, community.rootEpoch)) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt new file mode 100644 index 0000000000..bcd5eec1e5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The `control_root` rolling with the `community_root` at every Refounding + * (CORD-02 §2, CORD-06 §1/§3): base blobs carry the new pk to members and the new + * secret to staff, and the blob's width declares which form it is. + */ +class ControlRootRotationTest { + private val owner = NostrSignerInternal(KeyPair()) // rotator, and staff by definition + private val moderator = NostrSignerInternal(KeyPair()) // staff + private val member = NostrSignerInternal(KeyPair()) // plain member + private val removed = NostrSignerInternal(KeyPair()) + + private val newRoot = ByteArray(32) { 0x5A } + private val newControlRoot = ByteArray(32) { 0x6B } + private val now = 1_700_000_000L + + @Test + fun theBlobWidthDeclaresItsForm() { + val scope = ByteArray(32) { 0x01 } + val key = ByteArray(32) { 0x02 } + val pk = ByteArray(32) { 0x03 } + val secret = ByteArray(32) { 0x04 } + + assertEquals(RekeyPayload.SIZE_CHANNEL, RekeyPayload(scope, 1, key).encode().size) + assertEquals(RekeyPayload.SIZE_BASE_MEMBER, RekeyPayload(scope, 1, key, pk).encode().size) + assertEquals(RekeyPayload.SIZE_BASE_STAFF, RekeyPayload(scope, 1, key, pk, secret).encode().size) + assertEquals(72, RekeyPayload.SIZE_CHANNEL) + assertEquals(104, RekeyPayload.SIZE_BASE_MEMBER) + assertEquals(136, RekeyPayload.SIZE_BASE_STAFF) + + // Round-trips keep exactly what each form carries, and nothing it doesn't. + val channel = RekeyPayload.decode(RekeyPayload(scope, 1, key).encode()) + assertNotNull(channel) + assertNull(channel.newControlPk) + assertNull(channel.newControlRoot) + + val memberBlob = RekeyPayload.decode(RekeyPayload(scope, 1, key, pk).encode()) + assertNotNull(memberBlob) + assertContentEquals(pk, memberBlob.newControlPk) + assertNull(memberBlob.newControlRoot, "a member's blob must never carry the write key") + + val staffBlob = RekeyPayload.decode(RekeyPayload(scope, 1, key, pk, secret).encode()) + assertNotNull(staffBlob) + assertContentEquals(pk, staffBlob.newControlPk) + assertContentEquals(secret, staffBlob.newControlRoot) + + // Any other width is malformed and the blob is dropped. + assertNull(RekeyPayload.decode(ByteArray(71))) + assertNull(RekeyPayload.decode(ByteArray(103))) + assertNull(RekeyPayload.decode(ByteArray(137))) + } + + @Test + fun staffGetTheSecretMembersOnlyThePubkeyAndRemovedNothing() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val build = + ConcordRefounding.build( + rotatorSigner = owner, + communityId = community.communityId, + priorRoot = community.communityRoot, + newRoot = newRoot, + newControlRoot = newControlRoot, + rootEpoch = community.rootEpoch, + priorControlWraps = community.genesisWraps, + priorControlKeys = community.controlPlane, + recipientsXOnly = listOf(owner.pubKey, moderator.pubKey, member.pubKey), + staffXOnly = setOf(owner.pubKey, moderator.pubKey), + createdAt = now, + ) + + val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch) + + suspend fun received(who: NostrSignerInternal) = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekey, who, community.communityId, community.communityRoot, community.rootEpoch) + + val expectedPk = ConcordKeyDerivation.controlSignerKey(newControlRoot, community.communityId, build.newEpoch).publicKey + + val asModerator = received(moderator) + assertNotNull(asModerator) + assertContentEquals(newRoot, asModerator.newRoot) + assertContentEquals(expectedPk, asModerator.newControlPk) + assertContentEquals(newControlRoot, asModerator.newControlRoot, "staff must receive the new write key") + + val asMember = received(member) + assertNotNull(asMember) + assertContentEquals(newRoot, asMember.newRoot) + assertContentEquals(expectedPk, asMember.newControlPk, "every member must receive the new address") + assertNull(asMember.newControlRoot, "a plain member must never receive the write key") + + assertNull(received(removed), "a removed member receives no blob at all") + } + + @Test + fun theRotatedPlaneIsWritableByStaffAndReadableByEveryMember() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now, description = "A place") + val build = + ConcordRefounding.build( + rotatorSigner = owner, + communityId = community.communityId, + priorRoot = community.communityRoot, + newRoot = newRoot, + newControlRoot = newControlRoot, + rootEpoch = community.rootEpoch, + priorControlWraps = community.genesisWraps, + priorControlKeys = community.controlPlane, + recipientsXOnly = listOf(owner.pubKey, member.pubKey), + staffXOnly = setOf(owner.pubKey), + createdAt = now, + ) + + // The rotator's own view writes; a member's view of the same epoch only reads. + assertTrue(build.newControlKeys.canWrite) + val memberView = + ControlPlaneKeys.forMember(newRoot, community.communityId, build.newEpoch, build.newControlKeys.address) + assertFalse(memberView.canWrite) + + // Every compacted wrap sits at the new signer's address and opens for the member. + assertTrue(build.controlWraps.isNotEmpty()) + build.controlWraps.forEach { assertEquals(build.newControlKeys.address, it.pubKey) } + + val editions = + build.controlWraps.mapNotNull { wrap -> + ConcordStreamEnvelope.openOrNull(wrap, memberView)?.let { ControlEdition.fromRumor(it.rumor) } + } + val folded = ConcordCommunityState.fold(editions, owner.pubKey) + assertEquals("Test", folded.metadata?.name) + assertTrue(folded.channels.isNotEmpty()) + } + + @Test + fun aStaffBlobWhoseSecretDoesNotDeriveToItsPubkeyIsRefused() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val newEpoch = community.rootEpoch + 1 + + // A rotator that splits the plane from its own readers: the delivered secret derives + // to a DIFFERENT address than the one every member was handed (CORD-06 §1). + val mismatchedPk = ConcordKeyDerivation.controlSignerKey(ByteArray(32) { 0x7C }, community.communityId, newEpoch).publicKey + val blob = + ConcordRekey.blobForSigner( + rotatorSigner = owner, + recipientXOnly = moderator.pubKey.hexToByteArray(), + scopeId = ConcordRekey.ROOT_SCOPE, + newEpoch = newEpoch, + newKey = newRoot, + newControlPk = mismatchedPk, + newControlRoot = newControlRoot, + ) + + val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch) + val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey() + val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 0, 1) + val rumor = + RumorAssembler.assembleRumor(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob))) + val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now) + + assertNull( + ConcordRefounding.findNewRoot(listOf(wrap), baseRekey, moderator, community.communityId, community.communityRoot, community.rootEpoch), + "a mismatched control pair must be refused rather than adopted", + ) + } + + @Test + fun aLegacySeventyTwoByteBaseBlobStillDeliversItsRoot() = + runTest { + // A pre-split rotation carries no control material; it is honored when reading old + // epochs (CORD-06 §3) and its acceptor keeps folding at the legacy address. + val community = ConcordCommunityFactory.create(owner, "Test", now) + val newEpoch = community.rootEpoch + 1 + + val blob = + ConcordRekey.blobForSigner( + rotatorSigner = owner, + recipientXOnly = member.pubKey.hexToByteArray(), + scopeId = ConcordRekey.ROOT_SCOPE, + newEpoch = newEpoch, + newKey = newRoot, + ) + + val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch) + val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey() + val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 0, 1) + val rumor = + RumorAssembler.assembleRumor(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob))) + val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now) + + val got = ConcordRefounding.findNewRoot(listOf(wrap), baseRekey, member, community.communityId, community.communityRoot, community.rootEpoch) + assertNotNull(got) + assertContentEquals(newRoot, got.newRoot) + assertNull(got.newControlPk, "a legacy base blob announces a pre-split epoch") + assertNull(got.newControlRoot) + } +} From 6472099d3a07ec1bd9f4cdd8bc8804e585523ed3 Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 7 Aug 2026 07:49:17 +0200 Subject: [PATCH 148/227] fix(media): repair bare-subtype imeta mimes so sharing works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A NIP-92 `imeta` is meant to carry a full `type/subtype`, but some clients emit only the subtype — Primal iOS writes `m jpeg` instead of `m image/jpeg`. --- .../amethyst/ui/components/ShareHelper.kt | 23 +++++++ .../ui/components/ZoomableContentView.kt | 6 +- .../amethyst/ui/components/ShareHelperTest.kt | 37 +++++++++++ .../commons/richtext/RichTextParser.kt | 22 ++++++- .../commons/richtext/PdfParserTest.kt | 25 ++++++-- .../RichTextParserMalformedMimeTest.kt | 62 +++++++++++++++++++ 6 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMalformedMimeTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index 02592b6a06..1764f6b2a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -25,6 +25,8 @@ import android.net.Uri import androidx.annotation.VisibleForTesting import androidx.core.content.FileProvider import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap +import com.vitorpamplona.amethyst.commons.richtext.normalizeMimeType import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -65,6 +67,27 @@ object ShareHelper { private val MP4_BRAND_MP42 = "mp42".toByteArray() private val MOV_BRAND_QT = "qt ".toByteArray() + /** + * Picks the MIME type to put on an `ACTION_SEND` intent. + * + * `Intent.type` has to be a real `type/subtype`: an `IntentFilter` matches the two halves + * separately, so a slash-less value like `jpeg` matches nothing and the chooser opens empty — + * the share silently does nothing. Events can absolutely carry such a value, because NIP-92 + * `imeta`/NIP-94 `m` tags are author-supplied and some clients write the bare subtype (Primal + * iOS emits `m jpeg`). Treat the declared type as a hint, not as truth. + * + * [fileExtension] is the safer signal — [getMediaExtension] sniffs it from the file's magic + * numbers rather than trusting the event — so it backs up an unusable declaration. + */ + internal fun resolveShareMimeType( + declaredMimeType: String?, + fileExtension: String, + defaultTypePrefix: String, + ): String = + normalizeMimeType(declaredMimeType) + ?: mimeTypeMap[fileExtension.lowercase()] + ?: "$defaultTypePrefix/$fileExtension" + suspend fun getSharableUriFromUrl( context: Context, imageUrl: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 3e560530cc..a09e69278a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -1103,7 +1103,7 @@ private suspend fun shareImageFile( val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri) // Determine mime type, use provided or derive from extension - val determinedMimeType = mimeType ?: "image/$fileExtension" + val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, fileExtension, "image") // Create share intent val shareIntent = @@ -1161,7 +1161,7 @@ private suspend fun shareVideoFile( sharedFile = sharableFile // Determine mime type - val determinedMimeType = mimeType ?: "video/$extension" + val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, extension, "video") // Create share intent val shareIntent = @@ -1227,7 +1227,7 @@ private suspend fun shareLocalVideoFile( val (uri, extension) = ShareHelper.getSharableUriForLocalVideo(context, localFile) // Determine mime type - val determinedMimeType = mimeType ?: "video/$extension" + val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, extension, "video") // Create share intent val shareIntent = diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/ShareHelperTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/ShareHelperTest.kt index 8bbc8a29ae..e9336b351b 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/ShareHelperTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/ShareHelperTest.kt @@ -162,4 +162,41 @@ class ShareHelperTest { file.writeBytes(bytes) return file } + + // A slash-less Intent.type matches no IntentFilter, so the chooser opens with zero targets and + // the share silently fails. Author-supplied `m` tags can carry exactly that (Primal iOS emits + // `m jpeg`), so a bare subtype must be repaired rather than forwarded. + @Test + fun resolveShareMimeType_bareSubtype_isExpandedToFullMimeType() { + assertEquals("image/jpeg", ShareHelper.resolveShareMimeType("jpeg", "jpg", "image")) + assertEquals("video/mp4", ShareHelper.resolveShareMimeType("mp4", "mp4", "video")) + } + + @Test + fun resolveShareMimeType_wellFormedDeclaration_isPreserved() { + assertEquals("image/png", ShareHelper.resolveShareMimeType("image/png", "png", "image")) + } + + // An unusable declaration falls back to the extension, which is sniffed from the file's magic + // numbers and so is not attacker-controlled. + @Test + fun resolveShareMimeType_unrecognizableDeclaration_fallsBackToSniffedExtension() { + assertEquals("image/png", ShareHelper.resolveShareMimeType("notatype", "png", "image")) + } + + @Test + fun resolveShareMimeType_noDeclaration_usesCanonicalTypeForExtension() { + // Not "image/jpg" -- jpg is not a registered subtype. + assertEquals("image/jpeg", ShareHelper.resolveShareMimeType(null, "jpg", "image")) + assertEquals("video/quicktime", ShareHelper.resolveShareMimeType(null, "mov", "video")) + } + + @Test + fun resolveShareMimeType_alwaysProducesASlashSeparatedType() { + val cases = listOf(null, "", "jpeg", "image/jpeg", "notatype", "JPEG") + cases.forEach { declared -> + val resolved = ShareHelper.resolveShareMimeType(declared, "jpg", "image") + assertTrue("`$declared` resolved to un-matchable type `$resolved`", resolved.contains("/")) + } + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 0ecc3b82f0..51765fe3bb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -59,7 +59,7 @@ class RichTextParser { val tags = eventTags.get(fullUrl)?.properties ?: emptyMap() - val contentType = frags[MimeTypeTag.TAG_NAME] ?: tags[MimeTypeTag.TAG_NAME]?.firstOrNull() + val contentType = normalizeMimeType(frags[MimeTypeTag.TAG_NAME] ?: tags[MimeTypeTag.TAG_NAME]?.firstOrNull()) // Returning null here drops the URL to a plain link, discarding the imeta's `dim`/blurhash // and forcing a URL-preview round-trip to rediscover a type the imeta already declared — @@ -681,3 +681,23 @@ val mimeTypeMap: Map = // Documents "pdf" to "application/pdf", ) + +/** + * NIP-92's `m` property is meant to carry a full `type/subtype`, but several clients emit the + * bare subtype instead — Primal iOS writes `m jpeg` rather than `m image/jpeg`. That value is + * useless as a MIME type: it matches none of the `startsWith("image/")`-style checks, and once + * it is stored on the media model it travels all the way into Android's `ACTION_SEND` as + * `Intent.type = "jpeg"`. No `` filter matches a type without a slash, + * so the share sheet opens with zero targets and the image cannot be shared at all. + * + * Map a bare subtype back onto its canonical MIME so every downstream consumer (the share + * intent, the gallery entry's published `m` tag, the player's type hint) sees a well-formed + * value. Anything already containing a `/` is passed through untouched, and an unrecognised + * bare token is dropped to null rather than propagated — that leaves the caller's + * extension-based detection to decide, which is strictly better than carrying garbage forward. + */ +fun normalizeMimeType(rawMimeType: String?): String? { + if (rawMimeType == null) return null + if (rawMimeType.contains('/')) return rawMimeType + return mimeTypeMap[rawMimeType.lowercase()] +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt index 2dc2f906fa..53c0cfb947 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt @@ -112,10 +112,10 @@ class PdfParserTest { assertTrue(videoMedia is MediaUrlVideo, "Expected MediaUrlVideo despite the malformed `m mp4` mime") } - // A malformed mime on a URL with *no* recognizable extension can't be recovered — it stays a - // link. This documents the boundary of the fallback so it isn't mistaken for a regression. + // A bare-subtype mime is recovered from the imeta itself, so an extensionless URL — the shape + // Blossom hands out, where the imeta is the only type signal there is — still renders. @Test - fun malformedImetaMimeWithoutExtensionStaysUnclassified() { + fun malformedImetaMimeWithoutExtensionIsRecoveredFromTheMime() { val url = "https://files.example.com/abcd1234" val tags = ImmutableListOfLists( @@ -126,6 +126,23 @@ class PdfParserTest { val state = RichTextParser().parseText(url, tags, null) - assertEquals(null, state.mediaForPager[url], "No extension to recover from -> not treated as media") + assertTrue(state.mediaForPager[url] is MediaUrlImage, "`m jpeg` alone is enough to classify the media") + } + + // The boundary: a bare token that maps to no known type, on a URL with no extension, has + // nothing left to recover from. This documents the limit so it isn't mistaken for a regression. + @Test + fun unrecognizableImetaMimeWithoutExtensionStaysUnclassified() { + val url = "https://files.example.com/abcd1234" + val tags = + ImmutableListOfLists( + arrayOf( + arrayOf("imeta", "url $url", "m notarealtype"), + ), + ) + + val state = RichTextParser().parseText(url, tags, null) + + assertEquals(null, state.mediaForPager[url], "Nothing to recover from -> not treated as media") } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMalformedMimeTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMalformedMimeTest.kt new file mode 100644 index 0000000000..f5067d0d4b --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMalformedMimeTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RichTextParserMalformedMimeTest { + private val url = "https://blossom.primal.net/c27d7b7be6e58d69b29006cc275d29d67967760b1772e50a79d5b24f60d62fc5.jpg" + + private fun parse(mime: String): MediaUrlContent? = + RichTextParser().createMediaContent( + fullUrl = url, + eventTags = + mapOf( + url to + IMetaTag( + url = url, + properties = mapOf("m" to listOf(mime), "dim" to listOf("960.0x1358.0")), + ), + ), + description = null, + ) + + @Test + fun malformedImetaMimeStillRendersAsImage() { + val content = parse("jpeg") + assertTrue(content is MediaUrlImage, "bare `m jpeg` must still route to MediaUrlImage") + } + + @Test + fun malformedImetaMimeIsNormalizedForSharing() { + val content = parse("jpeg") as MediaUrlImage + assertEquals("image/jpeg", content.mimeType) + } + + @Test + fun wellFormedImetaMimeIsUntouched() { + val content = parse("image/jpeg") as MediaUrlImage + assertEquals("image/jpeg", content.mimeType) + } +} From ee4a5e5b8928b5367f9e1b171c1543f8b3db93d9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 7 Aug 2026 08:23:45 +0200 Subject: [PATCH 149/227] Code review: - fix(media): stop ogg bypassing the ambiguity guard it is listed in - fix(media): don't guess a family for an ambiguous bare subtype - refactor(media): normalize the mime at the chokepoints, not one call site --- .../amethyst/ui/components/ShareHelper.kt | 19 +++--- .../ui/components/ZoomableContentView.kt | 6 +- .../amethyst/ui/components/ShareHelperTest.kt | 23 ++++--- .../commons/richtext/MediaContentModels.kt | 12 +++- .../commons/richtext/RichTextParser.kt | 58 ++++++++++++---- .../commons/richtext/ClassifyMediaTest.kt | 68 ++++++++++++++++++- .../commons/richtext/PdfParserTest.kt | 51 +++++--------- .../RichTextParserMalformedMimeTest.kt | 62 ----------------- 8 files changed, 164 insertions(+), 135 deletions(-) delete mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMalformedMimeTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index 1764f6b2a9..9a96186328 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -39,6 +39,7 @@ object ShareHelper { private const val DEFAULT_IMAGE_EXTENSION = "jpg" private const val DEFAULT_VIDEO_EXTENSION = "mp4" private const val SHARED_FILE_PREFIX = "shared_media" + private const val GENERIC_BINARY_MIME_TYPE = "application/octet-stream" data class SharableFile( val uri: Uri, @@ -70,23 +71,21 @@ object ShareHelper { /** * Picks the MIME type to put on an `ACTION_SEND` intent. * - * `Intent.type` has to be a real `type/subtype`: an `IntentFilter` matches the two halves - * separately, so a slash-less value like `jpeg` matches nothing and the chooser opens empty — - * the share silently does nothing. Events can absolutely carry such a value, because NIP-92 - * `imeta`/NIP-94 `m` tags are author-supplied and some clients write the bare subtype (Primal - * iOS emits `m jpeg`). Treat the declared type as a hint, not as truth. - * - * [fileExtension] is the safer signal — [getMediaExtension] sniffs it from the file's magic - * numbers rather than trusting the event — so it backs up an unusable declaration. + * `Intent.type` has to be a real `type/subtype` — an `IntentFilter` matches the two halves + * separately, so a slash-less value matches nothing and the chooser opens empty. The declared + * type is author-supplied and may be unusable (see [normalizeMimeType]), so it is treated as a + * hint; [fileExtension] is the safer signal because [getMediaExtension] sniffs it from the + * file's magic numbers rather than trusting the event. */ internal fun resolveShareMimeType( declaredMimeType: String?, fileExtension: String, - defaultTypePrefix: String, ): String = normalizeMimeType(declaredMimeType) ?: mimeTypeMap[fileExtension.lowercase()] - ?: "$defaultTypePrefix/$fileExtension" + // Unreachable today: getMediaExtension only ever returns keys of mimeTypeMap. Kept so + // the return type stays a well-formed MIME if that ever stops holding. + ?: GENERIC_BINARY_MIME_TYPE suspend fun getSharableUriFromUrl( context: Context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index a09e69278a..1122b73a57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -1103,7 +1103,7 @@ private suspend fun shareImageFile( val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri) // Determine mime type, use provided or derive from extension - val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, fileExtension, "image") + val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, fileExtension) // Create share intent val shareIntent = @@ -1161,7 +1161,7 @@ private suspend fun shareVideoFile( sharedFile = sharableFile // Determine mime type - val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, extension, "video") + val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, extension) // Create share intent val shareIntent = @@ -1227,7 +1227,7 @@ private suspend fun shareLocalVideoFile( val (uri, extension) = ShareHelper.getSharableUriForLocalVideo(context, localFile) // Determine mime type - val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, extension, "video") + val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, extension) // Create share intent val shareIntent = diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/ShareHelperTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/ShareHelperTest.kt index e9336b351b..fc15511007 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/ShareHelperTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/components/ShareHelperTest.kt @@ -168,35 +168,38 @@ class ShareHelperTest { // `m jpeg`), so a bare subtype must be repaired rather than forwarded. @Test fun resolveShareMimeType_bareSubtype_isExpandedToFullMimeType() { - assertEquals("image/jpeg", ShareHelper.resolveShareMimeType("jpeg", "jpg", "image")) - assertEquals("video/mp4", ShareHelper.resolveShareMimeType("mp4", "mp4", "video")) + assertEquals("image/jpeg", ShareHelper.resolveShareMimeType("jpeg", "jpg")) + assertEquals("video/mp4", ShareHelper.resolveShareMimeType("mp4", "mp4")) + assertEquals("image/jpeg", ShareHelper.resolveShareMimeType("JPEG", "jpg")) } @Test fun resolveShareMimeType_wellFormedDeclaration_isPreserved() { - assertEquals("image/png", ShareHelper.resolveShareMimeType("image/png", "png", "image")) + assertEquals("image/png", ShareHelper.resolveShareMimeType("image/png", "png")) } // An unusable declaration falls back to the extension, which is sniffed from the file's magic // numbers and so is not attacker-controlled. @Test fun resolveShareMimeType_unrecognizableDeclaration_fallsBackToSniffedExtension() { - assertEquals("image/png", ShareHelper.resolveShareMimeType("notatype", "png", "image")) + assertEquals("image/png", ShareHelper.resolveShareMimeType("notatype", "png")) + assertEquals("image/png", ShareHelper.resolveShareMimeType("", "png")) } @Test fun resolveShareMimeType_noDeclaration_usesCanonicalTypeForExtension() { // Not "image/jpg" -- jpg is not a registered subtype. - assertEquals("image/jpeg", ShareHelper.resolveShareMimeType(null, "jpg", "image")) - assertEquals("video/quicktime", ShareHelper.resolveShareMimeType(null, "mov", "video")) + assertEquals("image/jpeg", ShareHelper.resolveShareMimeType(null, "jpg")) + assertEquals("video/quicktime", ShareHelper.resolveShareMimeType(null, "mov")) } + // The invariant the share sheet depends on, pinned across the whole set of extensions + // getMediaExtension can sniff: whatever the event declared, Intent.type stays matchable. @Test fun resolveShareMimeType_alwaysProducesASlashSeparatedType() { - val cases = listOf(null, "", "jpeg", "image/jpeg", "notatype", "JPEG") - cases.forEach { declared -> - val resolved = ShareHelper.resolveShareMimeType(declared, "jpg", "image") - assertTrue("`$declared` resolved to un-matchable type `$resolved`", resolved.contains("/")) + listOf("jpg", "png", "gif", "webp", "webm", "avi", "mp4", "mov").forEach { extension -> + val resolved = ShareHelper.resolveShareMimeType("notatype", extension) + assertTrue("`$extension` resolved to un-matchable type `$resolved`", resolved.contains("/")) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index be472293f2..14ab09b18c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -44,10 +44,18 @@ abstract class MediaUrlContent( dim: DimensionTag? = null, blurhash: String? = null, val uri: String? = null, - val mimeType: String? = null, + mimeType: String? = null, thumbhash: String? = null, val authorPubKey: String? = null, -) : BaseMediaContent(description, dim, blurhash, thumbhash) +) : BaseMediaContent(description, dim, blurhash, thumbhash) { + /** + * Repaired at construction rather than at each of the eight call sites that build a model from + * an author-supplied `m` tag — a bare subtype reaching this field is what puts `Intent.type = + * "jpeg"` on the share sheet (matching no `IntentFilter`) and what republishes the malformed + * tag under the user's own key when media is added to a gallery. See [normalizeMimeType]. + */ + val mimeType: String? = normalizeMimeType(mimeType) +} @Immutable open class MediaUrlImage( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 51765fe3bb..8d5fd30da0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -59,7 +59,7 @@ class RichTextParser { val tags = eventTags.get(fullUrl)?.properties ?: emptyMap() - val contentType = normalizeMimeType(frags[MimeTypeTag.TAG_NAME] ?: tags[MimeTypeTag.TAG_NAME]?.firstOrNull()) + val contentType = frags[MimeTypeTag.TAG_NAME] ?: tags[MimeTypeTag.TAG_NAME]?.firstOrNull() // Returning null here drops the URL to a plain link, discarding the imeta's `dim`/blurhash // and forcing a URL-preview round-trip to rediscover a type the imeta already declared — @@ -557,10 +557,10 @@ class RichTextParser { * Resolves which renderer can display a declared blob — the single decision every media * renderer must make, from a NIP-94 `m` tag, a NIP-92 imeta, or a bare URL. * - * A declared MIME type wins; the URL extension is the fallback both for the no-MIME case - * and for a *malformed* MIME (Primal iOS emits `m jpeg` rather than `m image/jpeg`, which - * matches no prefix below). `data:` URIs carry their type in the prefix, so a miss there is - * genuine and the base64 payload is never extension-probed. + * A declared MIME type wins, after [normalizeMimeType] repairs the bare-subtype form some + * clients emit; the URL extension is the fallback both for the no-MIME case and for a + * declaration too mangled to repair. `data:` URIs carry their type in the prefix, so a miss + * there is genuine and the base64 payload is never extension-probed. * * Returns **null** when nothing can render the file. Callers must not substitute a media * kind for that null: handing an arbitrary blob — a webxdc app, a zip, an APK — to the @@ -570,8 +570,9 @@ class RichTextParser { */ fun classifyMedia( url: String, - mimeType: String?, + rawMimeType: String?, ): MediaContentKind? { + val mimeType = normalizeMimeType(rawMimeType) if (mimeType != null) { if (mimeType.startsWith("image/")) return MediaContentKind.IMAGE // HLS playlists are advertised with a non-`video/*` MIME; see [isHlsMimeType]. @@ -666,6 +667,9 @@ val mimeTypeMap: Map = // Video "mp4" to "video/mp4", "webm" to "video/webm", + // Dead entry: "ogg" is re-keyed under Audio below and mapOf keeps the last, so every + // lookup of it yields audio/ogg. Kept only to show the extension is genuinely ambiguous — + // see [ambiguousMimeSubtypes]. Don't read this line as reachable. "ogg" to "video/ogg", "mov" to "video/quicktime", "avi" to "video/x-msvideo", @@ -682,6 +686,22 @@ val mimeTypeMap: Map = "pdf" to "application/pdf", ) +/** + * Subtypes that name more than one top-level type: `mpeg`, `mp4`, `ogg`, `webm` and `3gpp` all exist + * as both `audio/` and `video/`, so a bare token spelling one of them identifies no family on its + * own. See [normalizeMimeType] for what that costs them. + */ +private val ambiguousMimeSubtypes = setOf("mpeg", "mp4", "ogg", "webm", "3gpp") + +/** + * The subtype half of every MIME in [mimeTypeMap], so a bare token can be looked up as what it + * actually is. [mimeTypeMap] is keyed by *extension*, which only doubles as a subtype index where + * the two spellings coincide — `quicktime`, `x-matroska` and `svg+xml` are subtypes no extension + * spells. Consulted after [mimeTypeMap] so the extension spelling keeps priority where they + * disagree (`mp4` stays `video/mp4` rather than the later `audio/mp4` entry). + */ +private val mimeSubtypeMap: Map = mimeTypeMap.values.associateBy { it.substringAfter('/') } + /** * NIP-92's `m` property is meant to carry a full `type/subtype`, but several clients emit the * bare subtype instead — Primal iOS writes `m jpeg` rather than `m image/jpeg`. That value is @@ -690,14 +710,28 @@ val mimeTypeMap: Map = * `Intent.type = "jpeg"`. No `` filter matches a type without a slash, * so the share sheet opens with zero targets and the image cannot be shared at all. * - * Map a bare subtype back onto its canonical MIME so every downstream consumer (the share - * intent, the gallery entry's published `m` tag, the player's type hint) sees a well-formed - * value. Anything already containing a `/` is passed through untouched, and an unrecognised - * bare token is dropped to null rather than propagated — that leaves the caller's - * extension-based detection to decide, which is strictly better than carrying garbage forward. + * Map a bare subtype back onto its canonical MIME. This is called from the two chokepoints every + * `m` value passes through — [RichTextParser.classifyMedia] for the render decision and + * [MediaUrlContent] for the value the share intent and the gallery entry's republished `m` tag + * read — so consumers see a well-formed type without each having to remember to repair it. + * + * Anything already containing a `/` is passed through untouched, and an unrecognised bare token is + * dropped to null rather than propagated — that leaves the caller's extension-based detection to + * decide, which is strictly better than carrying garbage forward. + * + * The same refusal covers a token in [ambiguousMimeSubtypes] that would land in `audio/`. `audio/` + * is the one destructive family: it is what [RichTextParser.isAudioContent] reads to drop the + * picture, so guessing it for what may be a video loses content, while guessing `video/` for what + * may be audio only costs some chrome. That asymmetry is why the guard is one-sided rather than a + * blanket refusal — `mp4` and `webm` resolve to `video/` and keep their rescue, so an extensionless + * Blossom URL declaring `m mp4` still renders, whereas `ogg` (whose only live [mimeTypeMap] entry + * is `audio/ogg`, its `video/ogg` one being a dead duplicate key) and `mpeg` decline. */ fun normalizeMimeType(rawMimeType: String?): String? { if (rawMimeType == null) return null if (rawMimeType.contains('/')) return rawMimeType - return mimeTypeMap[rawMimeType.lowercase()] + val token = rawMimeType.lowercase() + val resolved = mimeTypeMap[token] ?: mimeSubtypeMap[token] ?: return null + if (token in ambiguousMimeSubtypes && resolved.startsWith("audio/")) return null + return resolved } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt index 240d4f0baf..551aa7081e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClassifyMediaTest.kt @@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.commons.richtext import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class ClassifyMediaTest { @Test @@ -97,10 +99,67 @@ class ClassifyMediaTest { } @Test - fun extensionRescuesAMalformedMime() { - // Primal iOS emits `m jpeg` instead of `m image/jpeg`; the extension must still win - // over "unknown". Preserves the behaviour createMediaContent already documented. + fun aMalformedMimeIsRepairedRatherThanIgnored() { + // Primal iOS emits `m jpeg` instead of `m image/jpeg`. The bare subtype is mapped back to + // its canonical MIME here, so it classifies even on the extensionless URLs Blossom hands + // out, where the imeta is the only type signal there is. assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg", "jpeg")) + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/abcd1234", "jpeg")) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/abcd1234", "quicktime")) + // A token that maps to no known type has nothing to recover from; the extension decides. + assertNull(RichTextParser.classifyMedia("https://x.com/abcd1234", "notarealtype")) + assertEquals(MediaContentKind.IMAGE, RichTextParser.classifyMedia("https://x.com/a.jpg", "notarealtype")) + } + + // A subtype that names more than one top-level type identifies no family on its own. Guessing + // one is worse than not repairing: `audio/mpeg` classifies as VIDEO either way, but it also + // satisfies isAudioContent, which strips the picture and renders an MPEG video as a bare audio + // track. Leaving it unresolved hands the decision back to the extension, which knows. + @Test + fun anAmbiguousBareSubtypeIsLeftForTheExtensionToDecide() { + assertNull(normalizeMimeType("mpeg"), "`mpeg` names both audio/mpeg and video/mpeg") + // `ogg` reaches the same guard even though mimeTypeMap answers it: the extension table + // holds audio/ogg (its video/ogg entry is a dead duplicate key), so short-circuiting there + // is exactly the audio mis-flag this guard exists to stop. + assertNull(normalizeMimeType("ogg"), "`ogg` names both audio/ogg and video/ogg") + + val url = "https://x.com/clip.mpg" + val media = RichTextParser().createMediaContent(url, mapOf(url to imeta(url, "mpeg")), null) + + assertTrue(media is MediaUrlVideo, "the .mpg extension still classifies it") + assertFalse(RichTextParser.isAudioContent(media.mimeType, url), "an MPEG video is not an audio track") + + // The case that actually reached a user: an OGG video must not be flagged as an audio track. + val ogv = "https://x.com/clip.ogv" + assertFalse( + RichTextParser.isAudioContent(normalizeMimeType("ogg"), ogv), + "an OGG video must not be rendered as a pictureless audio track", + ) + } + + // Declining is reserved for the destructive direction. `audio/` is the one family that strips + // the picture, so an ambiguous token whose extension spelling already resolves to `video/` + // keeps its rescue — an extensionless Blossom URL with `m mp4` still renders. + @Test + fun anAmbiguousSubtypeThatResolvesToVideoKeepsItsRescue() { + assertEquals("video/mp4", normalizeMimeType("mp4")) + assertEquals("video/webm", normalizeMimeType("webm")) + assertEquals(MediaContentKind.VIDEO, RichTextParser.classifyMedia("https://x.com/abcd1234", "mp4")) + + // Unambiguously-audio tokens are untouched by the guard. + assertEquals("audio/mpeg", normalizeMimeType("mp3")) + assertEquals("audio/flac", normalizeMimeType("flac")) + } + + // The unambiguous half must keep working: these are subtypes no extension in the table spells, + // so they resolve only through the subtype index. + @Test + fun anUnambiguousBareSubtypeStillResolves() { + assertEquals("video/quicktime", normalizeMimeType("quicktime")) + assertEquals("video/x-matroska", normalizeMimeType("x-matroska")) + assertEquals("image/svg+xml", normalizeMimeType("svg+xml")) + // An extension spelling that is also a subtype keeps the extension's family. + assertEquals("video/mp4", normalizeMimeType("mp4")) } @Test @@ -135,6 +194,9 @@ class ClassifyMediaTest { "https://x.com/a.xdc" to "application/x-webxdc", "https://x.com/a.zip" to "application/zip", "https://x.com/a.jpg" to "jpeg", + "https://x.com/abcd1234" to "jpeg", + "https://x.com/abcd1234" to "notarealtype", + "https://x.com/clip.mpg" to "mpeg", "data:image/png;base64,AAAA" to null, "data:application/zip;base64,AAAAmp4" to null, ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt index 53c0cfb947..6ec4de6de5 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/PdfParserTest.kt @@ -67,11 +67,10 @@ class PdfParserTest { assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf?sig=abc")) } - // Primal iOS writes a bare subtype (`m jpeg`) instead of a full MIME (`m image/jpeg`). - // The bare subtype matches none of the `image/`/`video/`/`application/pdf` prefixes, so before - // the extension fallback the whole imeta was dropped: the URL rendered as a plain link, losing - // the `dim` needed to reserve the image's height (feed jump) and forcing a URL-preview fetch. - // The `.jpg` extension must still route it to a MediaUrlImage that carries `dim`. + // Primal iOS writes a bare subtype (`m jpeg`) instead of a full MIME (`m image/jpeg`); see + // normalizeMimeType. End-to-end here because the failure was never just the render decision: + // dropping the imeta also lost the `dim` that reserves the image's height (feed jump) and + // forced a URL-preview fetch to rediscover a type the imeta had already declared. @Test fun detectsImageFromMalformedImetaMimeWithImageExtension() { val url = "https://blossom.primal.net/33e7c01afbea894a64e1db44dece460b09a2426108f47143754e1cf4bfdf747c.jpg" @@ -88,6 +87,9 @@ class PdfParserTest { val imageMedia = state.mediaForPager[url] assertTrue(imageMedia is MediaUrlImage, "Expected MediaUrlImage despite the malformed `m jpeg` mime") + // Repaired on the model too, not just for the render decision: this field becomes + // Intent.type when the image is shared, and a slash-less one matches no IntentFilter. + assertEquals("image/jpeg", imageMedia.mimeType, "The bare subtype must not survive onto the model") assertEquals("1009x680", imageMedia.dim?.toString(), "The imeta dim must survive so the loader can reserve space") assertEquals(1009f / 680f, imageMedia.dim?.aspectRatio()) @@ -112,37 +114,20 @@ class PdfParserTest { assertTrue(videoMedia is MediaUrlVideo, "Expected MediaUrlVideo despite the malformed `m mp4` mime") } - // A bare-subtype mime is recovered from the imeta itself, so an extensionless URL — the shape - // Blossom hands out, where the imeta is the only type signal there is — still renders. + // An extensionless URL is the shape Blossom hands out, where the imeta is the only type signal + // there is: a recognizable bare subtype now carries it, and an unrecognizable one leaves + // nothing to recover from. The second half documents the limit so it isn't read as a + // regression. @Test - fun malformedImetaMimeWithoutExtensionIsRecoveredFromTheMime() { + fun malformedImetaMimeWithoutExtensionIsRecoveredFromTheMimeAlone() { val url = "https://files.example.com/abcd1234" - val tags = - ImmutableListOfLists( - arrayOf( - arrayOf("imeta", "url $url", "m jpeg"), - ), - ) - val state = RichTextParser().parseText(url, tags, null) + fun parse(mime: String) = + RichTextParser() + .parseText(url, ImmutableListOfLists(arrayOf(arrayOf("imeta", "url $url", "m $mime"))), null) + .mediaForPager[url] - assertTrue(state.mediaForPager[url] is MediaUrlImage, "`m jpeg` alone is enough to classify the media") - } - - // The boundary: a bare token that maps to no known type, on a URL with no extension, has - // nothing left to recover from. This documents the limit so it isn't mistaken for a regression. - @Test - fun unrecognizableImetaMimeWithoutExtensionStaysUnclassified() { - val url = "https://files.example.com/abcd1234" - val tags = - ImmutableListOfLists( - arrayOf( - arrayOf("imeta", "url $url", "m notarealtype"), - ), - ) - - val state = RichTextParser().parseText(url, tags, null) - - assertEquals(null, state.mediaForPager[url], "Nothing to recover from -> not treated as media") + assertTrue(parse("jpeg") is MediaUrlImage, "`m jpeg` alone is enough to classify the media") + assertEquals(null, parse("notarealtype"), "Nothing to recover from -> not treated as media") } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMalformedMimeTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMalformedMimeTest.kt deleted file mode 100644 index f5067d0d4b..0000000000 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMalformedMimeTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.commons.richtext - -import com.vitorpamplona.quartz.nip92IMeta.IMetaTag -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class RichTextParserMalformedMimeTest { - private val url = "https://blossom.primal.net/c27d7b7be6e58d69b29006cc275d29d67967760b1772e50a79d5b24f60d62fc5.jpg" - - private fun parse(mime: String): MediaUrlContent? = - RichTextParser().createMediaContent( - fullUrl = url, - eventTags = - mapOf( - url to - IMetaTag( - url = url, - properties = mapOf("m" to listOf(mime), "dim" to listOf("960.0x1358.0")), - ), - ), - description = null, - ) - - @Test - fun malformedImetaMimeStillRendersAsImage() { - val content = parse("jpeg") - assertTrue(content is MediaUrlImage, "bare `m jpeg` must still route to MediaUrlImage") - } - - @Test - fun malformedImetaMimeIsNormalizedForSharing() { - val content = parse("jpeg") as MediaUrlImage - assertEquals("image/jpeg", content.mimeType) - } - - @Test - fun wellFormedImetaMimeIsUntouched() { - val content = parse("image/jpeg") as MediaUrlImage - assertEquals("image/jpeg", content.mimeType) - } -} From 8b17624c458a11275e95acb25502ca4b92b3187d Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:27:50 +0000 Subject: [PATCH 150/227] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-hi-rIN/strings.xml | 126 ++++++++++-------- .../src/main/res/values-hu-rHU/strings.xml | 15 +++ .../src/main/res/values-pl-rPL/strings.xml | 16 +++ 3 files changed, 101 insertions(+), 56 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 077e171144..68dc9e575b 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -46,8 +46,8 @@ हिंसा अज्ञात लेखक लेख की अनुकृति करें - लेखक विभेदक की अनुकृति करें - टीका विभेदक की अनुकृति करें + लेखक सूचक की अनुकृति करें + टीका सूचक की अनुकृति करें कच्चा जेसोन॰ की अनुकृति करें प्रसारण समयांकन करें @@ -220,7 +220,7 @@ संचारयन्त्र में अभिलेखन करें चित्र का अभिलेखन किया गया चित्रालय क्रमक में चलचित्र अवरोहण आरम्भ हुआ … - अभिलेख अवरोहण आरम्भ हुआ … + श्रव्यदृश्याभिलेख अवरोहण आरम्भ हुआ … ऊपर टाँकें टाँका हटाएँ ऊपर टँकित @@ -271,7 +271,7 @@ " पुनःप्रसारक" जालस्थान लैटनिंग पता - एनसेक॰ विभेदक (आपका गुप्त पारणशब्द) की अनुकृति करता है टाँकाफलक में सुरक्षित रखने के लिए + एनसेक॰ सूचक (आपका गुप्त पारणशब्द) की अनुकृति करता है टाँकाफलक में सुरक्षित रखने के लिए निजी कुंचिका क्यूआर॰ क्रमचित्र दिखाएँ। रहस्यीकृत निजी कुंचिका क्यूआर॰ क्रमचित्र दिखाएँ। सीधा संदेश भेजें @@ -279,7 +279,7 @@ अनुचरण करें प्रत्यानुचरण करें बाधा हटाएँ - उपयोगकर्ता विभेदक की अनुकृति + प्रयोक्ता सूचक की अनुकृति उपयोगकर्ता बाधा हटाएँ प्रयोक्ता बाधित अथवा मौनकृत। उनके पत्र छिपाए गए। "एनपुब॰, उपयोगकर्ता नाम, लेख" @@ -327,7 +327,7 @@ प्रणाली नाम प्रणाली मिटाएँ क्या प्रणाली मिटा दें। - क्या #%1$s को मिटा दें। इसको पूर्ववत नहीं किया जा सकता। तथा उसी विभेदक के साथ प्रणाली का पुनःउत्पादन नहीं किया जा सकता। + क्या #%1$s को मिटा दें। इसको पूर्ववत नहीं किया जा सकता। तथा उसी सूचक के साथ प्रणाली का पुनःउत्पादन नहीं किया जा सकता। मिटाएँ समुदाय छोडें क्या समुदाय छोडें। @@ -510,7 +510,7 @@ पदक पुरस्कार इनको दिया गया टीका लेख की अनुकृति की गई टाँकाफलक में लेखक के @npub की अनुकृति की गई टाँकाफलक में - टीका विभेदक (@note1) की अनुकृति की गई टाँकाफलक में + टीका सूचक (@note1) की अनुकृति की गई टाँकाफलक में "<निजी संदेश का अरहस्यीकरण असफल>\n\nआप का उल्लेख किया गया एक निजी अथवा रहस्यीकृत संवाद में %1$s तथा %2$s के बीच।" नयी लेखा जोडें लेखाएँ @@ -539,14 +539,14 @@ क्यूआर॰ चित्र जिसमें इस टीका का एक जाल योजक समाविष्ट है क्यूआर॰ चित्र जिसमें इस टीका का एक नोस्टर योजक समाविष्ट है अंगुलचित्र छिपाया गया संवेदनशिल विषयवस्तु के कारण - लेखक विभेदक - टीका विभेदक + लेखक सूचक + टीका सूचक लेख की अनुकृति करें मिटाएँ अनुचरण ना करें अनुचरण करें चित्रालय से मिटाएँ - इस अभिलेख को आपके चित्रालय से हटाएँ। + इस श्रव्यदृश्याभिलेख को आपके चित्रालय से हटाएँ। हटाने की याचना अमेथिस्ट अनुरोध करेगा कि आपका टीका मिटा दिया जाए उन पुनःप्रसारकों से जिनके साथ आप अब जुडे हुए हैं। कोई आश्वासन नहीं कि आपका टीका सर्वदा के लिए मिटा दिया जाएगा उन पुनःप्रसारकों से, अथवा अन्य पुनःप्रसारकों में से जहाँ यह रखा गया हो। बाधित करें @@ -638,7 +638,7 @@ %d पुनःप्रसारक तक %d पुनःप्रसारकों तक - टीका विभेदक की अनुकृति की गई + टीका सूचक की अनुकृति की गई मतदान खुला आवृत @@ -650,7 +650,7 @@ विवरण नियम (विकल्पात्मक) आवरण चित्र जोडें - चित्र चुनने के लिए दबाएँ। वह आपके प्रसारसंगणक तक आरोहित किया जाएगा। + चित्र चुनने के लिए दबाएँ। वह आपके श्रव्यदृश्याभिलेख सेवासंगणक तक आरोहित किया जाएगा। नियामक नियामक अनुमति दे सकते हैं पत्र प्रकाशन के लिए। आप सदैव नियामक हैं। नियामक जोडें @@ -846,7 +846,7 @@ उसका अपना निजी भण्डार लैटनिंग चालान का भुगतान जाल तथा ब्लोस्सम॰ संसाधन ले आएँ - आपके प्रसारसंगणक तक अभिलेखों का आरोहण + आपके श्रव्यदृश्याभिलेख सेवासंगणक तक अभिलेखों का आरोहण प्रदर्शनशैली सूचनाएँ सन्देश प्रेषण @@ -870,7 +870,7 @@ सावधान। यह नोस्टरसंलग्नक्रमक एक लैटनिंग चालान का भुगतान करना चाहता है जिसमें कोई मात्रा स्पष्टीकृत नहीं। प्राप्तकर्ता निर्णय करेगा कितना लिया जाएगा। इसे तभी अनुमति दें यदि आप इस पर विश्वास करते हैं। यह नोस्टर संलग्नक्रमक एक जाल संसाधन लाना चाहता है। - यह नोस्टर संलग्नक्रमक एक अभिलेख को आपके प्रसारसंगणक तक आरोहण करना चाहता है। + यह नोस्टर संलग्नक्रमक एक अभिलेख को आपके श्रव्यदृश्याभिलेख सेवासंगणक तक आरोहण करना चाहता है। यह नोस्टर संलग्नक्रमक आपको सूचनाएँ दिखाना चाहता है। यह स्थान आपकी नोस्टर कुंचिका के साथ एक %1$d प्रकार घटना का हस्ताक्षर करना चाहता है। @@ -1376,7 +1376,7 @@ पूर्वावलोकन अम्श जोडें लघु ध्वनि अथवा दृश्य पूर्वीक्षण पूर्वावलोकन शीर्षक - अभिलेख जालपता + श्रव्यदृश्याभिलेख जालपता आपका पुटप्रसार शीर्षकरहित कोई कडी नहीं अब तक। नयी कडी दबाएँ अपने प्रथम कडी प्रकाशित करने के लिए। @@ -1391,7 +1391,7 @@ प्राप्तकर्ता जोडें पता जोडें स्वयम हाथ से नोस्टर प्रयोक्ता जोडें - नाम अथवा @विभेदक द्वारा ढूँढें + नाम अथवा @लेखासूचक द्वारा ढूँढें इस प्रयोक्ता का कोई लैटनिंग॰ पता नहीं प्राप्तकर्ता हटाएँ नाम (विकल्पात्मक) @@ -1564,7 +1564,7 @@ इसका अर्थ क्या है? यह विषयवस्तु वैसे ही है जैसे पत्र प्रकाशन पर यह विषयवस्तु परिवर्तित हुआ है। हो सकता है लेखक ने परिवर्तन देखा नहीं अथवा अनुमति दिया नहीं। - चित्र चलचित्र जोडें + श्रव्यदृश्याभिलेख जोडें चित्र जोडें चलचित्र जोडें पत्र जोडें @@ -1589,8 +1589,8 @@ नामरहित अभिलेख सेवासंगणक इस अभिलेख का आरोहण करने के लिए सेवासंगणक का चयन करें - प्रसारसंगणक - आपके प्रसारसंगणक आद्यताएँ स्थापित करें। + श्रव्यदृश्याभिलेख सेवासंगणक + आपके श्रव्यदृश्याभिलेख सेवासंगणक आद्यताएँ स्थापित करें। स्थानीय ब्लोस्सम॰ द्रुतस्मृति का प्रयोग करें जब एक ब्लोस्सम॰ द्रुतस्मृति चल रही है इस यन्त्र पर (संयोजनद्वार २४२४२) तब चित्र चलचित्र अवरोहण उसके द्वारा करें। स्थानीय द्रुतस्मृति का पता चला संयोजनद्वार २४२४२ पर। @@ -1601,7 +1601,7 @@ आरोहण व्यवहार प्रतिबिम्ब आरोहण आरोहण पश्चात अभिलेख की अनुकृति आपके अन्य ब्लोस्सम सेवासंगणकों में करें जिससे वह उपलब्ध रहेगा एक संगणक असंयोजित होने पर भी। - सेवासंगणक पर अभिलेखों का अनुकूलन + सेवासंगणक पर श्रव्यदृश्याभिलेखों का अनुकूलन आरोहण करें सेवासंगणक के /media अन्तबिन्दु द्वारा जिससे वह अभिलेख के उपतथ्य मिटा सके तथा संकुचित कर सके। रखा गया अभिलेख मूल से पृथक हो सकता है। रखे गए अभिलेखों का प्रबन्धन मेरे ब्लोस्सम॰ अभिलेख @@ -1662,11 +1662,11 @@ %1$d अभिलेख का आयात %1$d अभिलेखों का आयात - अनुशम्सित प्रसारसंगणक + अनुशम्सित श्रव्यदृश्याभिलेख सेवासंगणक अमेथिस्त की मूलविकल्प सूची। आप एक एक करके जोड सकते हैं अथवा सूची जोड सकते हैं। मूलविकल्प सूची का प्रयोग करें - प्रसारसंगणक जोडें - प्रसारसंगणक मिटाएँ + श्रव्यदृश्याभिलेख सेवासंगणक जोडें + श्रव्यदृश्याभिलेख सेवासंगणक मिटाएँ खींचे पुनःव्यवस्थित करने के लिए। आरोहण के लिए प्रत्येक सेवासंगणक के साथ प्रयास किया जाएगा ऊपर से नीचे। आरोहण प्राथमिकता सेवासंगणक जोडें @@ -1787,14 +1787,14 @@ ज्साप लैटनिंग तथा काशयू व्यापार के लिए टोर का प्रयोग अवश्य करें नोस्ट्र पता सत्यापन निप॰-०५ पता सत्यापन के लिए टोर का प्रयोग अवश्य करें - चित्र चलचित्र अभिलेख आरोहण - चित्र चलचित्र अभिलेख आरोहण के लिए टोर का प्रयोग अवश्य करें + श्रव्यदृश्याभिलेख आरोहण + विषयवस्तु आरोहण के लिए टोर का प्रयोग अवश्य करें आन्तरीय ओर्बोट निष्क्रिय मूलभूत मूलविकल्प - ध्वनिदृश्याभिलेख के अतिरिक्त सभी + श्रव्यदृश्याभिलेख के अतिरिक्त सभी सम्पूर्णतः गुप्त विशिष्ट सेवासंगणक की आवश्यकता होने पर टोर का प्रयोग करें @@ -1853,11 +1853,11 @@ %1$s ने आपके पत्र को पुनःप्रकाशित किया नए पुनःप्रकाशन - ध्वनिचित्राभिलेख + श्रव्यदृश्याभिलेख आपको सूचित करता है जब कोई आपका उल्लेख करते हैं चित्र अथवा चलचित्र में %1$s ने चित्र बाँटा %1$s ने चलचित्र बाँटा - नए ध्वनिचित्राभिलेख + नए श्रव्यदृश्याभिलेख निबन्ध तथा उद्दीप्तव्य आपको सूचित करता है जब कोई आपका उल्लेख अथवा आपको उद्दीप्त करते हैं एक निबन्ध में @@ -1948,14 +1948,14 @@ %1$s \u00b7 %2$d पुनःप्रसारक जालभ्रमण - ध्वनिचित्राभिलेख + श्रव्यदृश्याभिलेख विषयसूचक विषय सूची वार्तालाप खोज लुप्त घटनाओं को ढूँढें घटना अवलोकन - घटनाओं को विभेदक अनुसार ले आता है जिसका उल्लेख आपके पटल पर अमुक करता है पर जिसकी प्राप्ती अभी नहीं हुई। एक उद्धरण अथवा एक प्रत्युत्तर का पूर्वपत्र अथवा एक सूत्र का मूल। + घटनाओं को सूचक अनुसार ले आता है जिसका उल्लेख आपके पटल पर अमुक करता है पर जिसकी प्राप्ती अभी नहीं हुई। एक उद्धरण अथवा एक प्रत्युत्तर का पूर्वपत्र अथवा एक सूत्र का मूल। घटनाओं का अवलोकन करता है जो वर्तमान में प्रदर्शित हो रहे हैं नए प्रत्युत्तर प्रतिक्रियाएँ उद्धरण ज्साप तथा वृत्तान्तों के लिए जिससे गिनतियों का नवीकरण होता है जब आप पढ रहे हैं। संलग्न पुनःप्रसारक जानकारी @@ -2124,7 +2124,7 @@ गणना ग्राहकताएँ (%1$d) निर्गतपेटिका घटनाएँ (%1$d) %1$d लेखक - %1$d विभेदक + %1$d सूचक %1$s से %1$s तक सीमा %1$d @@ -2147,7 +2147,7 @@ अधिकतम ग्राहकताएँ अधिकतम छलनियाँ प्रति ग्राहकता अधिकतम सीमा (घटनाएँ प्रतिफलित) - अधिकतम ग्राहकताविभेदक लम्बाई + अधिकतम ग्राहकतासूचक लम्बाई काशयू अक्षरराशि टकसाल : %1$s चुकाएँ @@ -2222,7 +2222,7 @@ अन्धकारमय क्रमक आद्यताएँ स्वरूप - ध्वनिदृश्याभिलेख तथा जानकारी + श्रव्यदृश्याभिलेख तथा जानकारी सामान्य संयोजन टोर॰ के माध्यम से @@ -2333,7 +2333,7 @@ बाहरी चित्र आरोहण करें एक वर्गाकार चित्र का चयन करें इस अभिलेख की कलाकृती के रूप में। ध्वनि अभिलेख आरोहण - एक एमपीत्री॰ अथवा वेव॰ अथवा फ्लाक॰ का चयन करें। इसका आरोहण किया जाएगा आपके प्रसारसंगणक पर अभिलेखन करने पर। + एक एमपीत्री॰ अथवा वेव॰ अथवा फ्लाक॰ का चयन करें। इसका आरोहण किया जाएगा आपके श्रव्यदृश्याभिलेख सेवासंगणक पर अभिलेखन करने पर। ध्वनि अभिलेख आरोहण के लिए तत्पर बाहरी चित्र तथा ध्वनि अभिलेख का आरोहण तथा प्रकाशन चालू। यह चलता रहेगा आप पटल से विगमन करें तो भी। संगीतसूची सम्पादन @@ -2629,7 +2629,7 @@ चिति की अनुकृति करें टाँकाफलक में अनुकृति करें टाँकाफलक में अनुकृत - टाँकाफलक में एन॰परिचय की अनुकृति करें + टाँकाफलक में एनप्रोफैल॰ की अनुकृति करें टाँकाफलक में एनपुब॰ की अनुकृति करें बाँटें अथवा अभिलेखन करें सीधेसन्देश के रूप में भेजें @@ -2647,10 +2647,10 @@ आपके विचार जोडें… उद्दीप्तव्य टाँकाफलक में जालपता की अनुकृति करें - टाँकाफलक में टीका विभेदक की अनुकृति करें - अभिलेख को चित्रालय में जोडें - अभिलेख जोडा गया - अभिलेख जोडा गया आपके परिचय चित्रालय में + टाँकाफलक में टीका सूचक की अनुकृति करें + श्रव्यदृश्याभिलेख को चित्रालय में जोडें + श्रव्यदृश्याभिलेख जोडा गया + श्रव्यदृश्याभिलेख जोडा गया आपके परिचय चित्रालय में तब बनाया गया दिशा निर्देश नियामक @@ -2767,23 +2767,23 @@ आहार विभिन्न अन्य - चित्र चलचित्र आरोहण असफल + श्रव्यदृश्याभिलेख आरोहण असफल संकुचित अभिलेख को खोल नहीं पाए आरोहण अपक्रम : %1$s सेवासंगणक ने आरोहण पश्चात जालपता नहीं दिया - सेवासंगणक से आरोहणकृत चित्र चलचित्र का अवरोहण नहीं कर पाए + सेवासंगणक से आरोहणकृत श्रव्यदृश्याभिलेख का अवरोहण नहीं कर पाए आरोहण पश्चात अवरोहित अभिलेख की जाँच नहीं हो सकी : %1$s %1$s पर आरोहण असफल : %2$s मिटाने में असफल : %1$s - अभिलेख निप॰-९५ के लिए बहुत बडा है + श्रव्यदृश्याभिलेख निप॰-९५ के लिए बहुत बडा है अभिलेख रहस्यीकरण गोपनीयता के लिए अभिलेखों का रहस्यीकरण करें। कुछ सेवासंगणक रहस्यीकृत अभिलेखों को सम्भाव्यतः अस्वीकार कर सकते हैं निःशुल्क लेखाओं के लिए। रहस्यीकृत आरोहण असफल अनेक सेवासंगणक रहस्यीकृत अभिलेखों को स्वीकार नहीं करते निःशुल्क लेखाओं के लिए। आप पुनःप्रयास कर सकते हैं रहस्यीकरण के बिना। रहस्यीकरण के बिना पुनःप्रयास सावधान : रहस्यीकरण के बिना कोई भी विषयवस्तु देख सकेगा अभिलेख योजक के साथ। - अभिलेख गुणस्तर - निम्न गुणस्तर चुनें अपने अभिलेख को अल्प गुणवत्ता युक्त छोटे आकार अभिलेख तक संकुचित करने के लिए अथवा उच्च गुणस्तर चुनें उच्चतर गुणवत्ता युक्त बृहत्तर अभिलेख तक संकुचित करने के लिए। + श्रव्यदृश्याभिलेख गुणस्तर + निम्न गुणस्तर चुनें अपने श्रव्यदृश्याभिलेख को अल्प गुणवत्ता युक्त छोटे आकार अभिलेख तक संकुचित करने के लिए अथवा उच्च गुणस्तर चुनें उच्चतर गुणवत्ता युक्त बृहत्तर श्रव्यदृश्याभिलेख तक संकुचित करने के लिए। निम्न मध्यम उच्च @@ -2793,11 +2793,11 @@ जिफ॰ से एमपी४॰ में परिवर्तित करें चलन्त जिफ॰ से एमपी४॰ में परिवर्तित करता है सूक्ष्मतर अभिलेख आकार तथा अधिक चालन अनुकूलता के लिए। निजी परितथ्य हटाएँ - निजी परितथ्य हटाने का प्रयास करता है आलम्बित चित्रध्वनिदृश्य अभिलेखों से आरोहण पूर्व + निजी परितथ्य हटाने का प्रयास करता है आलम्बित श्रव्यदृश्याभिलेखों से आरोहण पूर्व परितथ्य हटाने में असफल यह अभिलेख प्रारूप परितथ्य हटाने का अवलम्बन नहीं करता। निजी जानकारी जैसे स्थान तथा यन्त्र विवरण समाविष्ट हो सकते हैं। क्या आरोहण करें। आरोहण करें - अभिलेख से निजी परितथ्य हटाने में असफल। आरोहण निरस्त। + श्रव्यदृश्याभिलेख से निजी परितथ्य हटाने में असफल। आरोहण निरस्त। आरोहण निरस्त एविफ॰ से परितथ्य नहीं मिटा सके : %1$s पाण्डुलिपि सम्पादन @@ -3222,6 +3222,16 @@ सूचनावलियाँ क्रमक तथा जाल अन्य + पार्श्व विकल्पसूची + आपकी पार्श्व विकल्पसूची + एक विभाग खोलें तथा उन पंक्तियों को निष्क्रिय करें जिनका उपयोग आप कभी नहीं करते। स्थापना विकल्प सर्वदा दृश्यमान रहते हैं। जिससे कि आप यहाँ कभी भी लौट सकेंगे। विभाग क्रम स्थायी है। + विभाग + %1$d छिपे हुए + दृश्यमान + छिपे हुए + सर्वदा सक्रिय + सभी दिखाएँ + सभी छिपाएँ मुख्यपटल पृष्ठसूचक चयन करें किन पृष्ठसूचक दिखने चाहिए मुख्यपटल पर। जब एक ही सक्रिय है तब पृष्ठसूचक पट्टी छुपाई जाएगी। सभी @@ -3439,7 +3449,7 @@ क्यूआर॰ क्रमचित्र के रूप में एनपुब॰ को दिखाएँ क्यूआर॰ क्रमचित्र के रूप में एन॰परिचय को दिखाएँ अमान्य पता - अमेथिस्ट को एक वैश्विक वस्तु विभेदक प्राप्त हुआ खोलने के लिए परन्तु वह विभेदक अमान्य था : %1$s + अमेथिस्ट को एक वैश्विकवस्तुसूचक प्राप्त हुआ खोलने के लिए परन्तु वह सूचक अमान्य था : %1$s सीधा संदेश आगतपेटिका पुनःप्रसारक आपके निजी आगतपेटिका पुनःप्रसारकों की स्थापना करें यह स्थापना विकल्प सब को सूचित करता है आपको सन्देश भेजने के लिए कौनसे पुनःप्रसारकों का प्रयोग करना चाहिए। इनके बिना आप कुछ सन्देश प्राप्त नहीं कर पाएँगे। @@ -3595,6 +3605,10 @@ अभिलेखन गिट क्रमलेखकोश प्रमुखताएँ + क्रमलेखकोश छलनी + छलनी आवृत + छालन इनके अनुसार नाम विषय निमन्त्रक परिपालक… + कोई क्रमलेखकोश वर्तमान सूचनावली में इस खोज के अनुकूल नहीं। नोस्टरस्थान : %1$s नोस्टर संलग्नक्रमक : %1$s अनुमतियाँ : @@ -3645,7 +3659,7 @@ पूर्वावस्था असफल - अनुरोध के शीर्षक प्रपत्रस्थानों में निर्दिष्ट पूर्वावस्थाओं की पूर्ति में सेवासंगणक असफल भार अत्याधिक - अनुरोध सेवासंगणक के निरूपित सीमाएँ से बडा है, तथा सेवासंगणक ने इस पर काम करना नकार दिया जालपता लम्बाई अत्याधिक - ग्राहक द्वारा अनुरोधित जालपता की लम्बाई सेवासंगणक के काम के लिए अत्याधिक है। - अनावलम्बित माध्यम प्रकार - अनुरोध में प्रयुक्त माध्यम प्रकार सेवासंगणक द्वारा अवलम्बित नहीं + अनावलम्बित श्रव्यदृश्याभिलेख प्रकार - अनुरोध में प्रयुक्त श्रव्यदृश्याभिलेख प्रकार सेवासंगणक द्वारा अवलम्बित नहीं विस्तार असाध्य - अनुरोध के विस्तार शीर्षक प्रपत्रस्थान में सूचित मूल्य की पूर्ति सेवासंगणक द्वारा असाध्य। अपेक्षा असफल - अनुरोध के अपेक्षा शीर्षक प्रपत्रस्थान में सूचित आवश्यकताओं की पूर्ति सेवासंगणक के लिए असाध्य नवीकरण आवश्यक - ग्राहक वर्तमान से भिन्न संचारविधि तक नवीकरण नहीं करता तो सेवासंगणक इसके अनुरोध पर काम नहीं करेगा। @@ -3659,7 +3673,7 @@ स्मृतिस्थान का अभाव - सेवासंगणक में पर्याप्त स्मृतिस्थान उपलब्ध नहीं अनुरोध पर सफलतापूर्वक काम करने के लिए क्रमचक्र दृष्ट - सेवासंगणक को अनन्त क्रमचक्र का पता चला अनुरोध पर काम करते हुए जाल प्रमाणीकरण आवश्यक - जाल उपलब्ध होने के लिए ग्राहक का प्रमाणीकरण अनिवार्य - ब्लोस्सम॰ प्रसारसंगणक + ब्लोस्सम॰ सेवासंगणक सेवासंगणक जितना चाहें जोडें। किस संगणक का उपयोग करना है उसका चयन कर सकते हैं चित्र का आरोहण करते समय निप॰-९६ सेवासंगणक जोडें ब्लोस्सम॰ प्रसारसंगणक जोडें @@ -3669,7 +3683,7 @@ अवरोहण अभिलेख खोलने में असफल कोई अनेकत्रावरोहण क्रमक स्थापित नहीं अभिलेख खोलने तथा अवरोहण करने के लिए। - अभिलेखविभेदक युक्त जालनिर्देशक बनाने के लिए पर्याप्त जानकारी नहीं है घटना में + चुम्बकजाल योजक बनाने के लिए पर्याप्त जानकारी नहीं है घटना में मेरे सूचियाँ सूचनावली छानने के लिए सूची चुनें सूचनावली @@ -3769,7 +3783,7 @@ जाल अनुरोध विकिरणेन्द्रिय जाग उठना अवरोहणों के लिए (अनुमान) सक्रिय स्थानान्तरण समय - ध्वनिदृश्याभिलेख चलन समय + श्रव्यदृश्याभिलेख चलन समय हस्ताक्षर सत्यापित क्रमवर्तक समय उपयुक्त क्रमकाभ्यन्तर समय @@ -3908,7 +3922,7 @@ खेल आवहन चालू\u2026 खेल अप्राप्त सम्भाव्यतः खेल समाप्त अथवा प्रतिपक्ष की प्रतीक्षा में। - खेल विभेदक : %1$s\u2026 + खेल सूचक : %1$s\u2026 तथा %1$d अन्य %1$s हटाएँ @@ -4067,7 +4081,7 @@ अभी के लिए छोडें अनुचरण सूची प्राप्त की जा रही है… कोई अनुचरित नहीं - "किसी मित्र अथवा समूह नेता का परिचय प्रविष्ट करें। उनके एनपुब॰ अथवा निप॰०५ पता अथवा नामरूप्य नाम जैसे कि alice@example.com अथवा id/alice का उपयोग आप कर सकते हैं खण्डश्रृंखला सत्यापित विभेदकों के लिए।" + "किसी मित्र अथवा समूह नेता का परिचय प्रविष्ट करें। उनके एनपुब॰ अथवा निप॰०५ पता अथवा नामरूप्य नाम जैसे कि alice@example.com अथवा id/alice का उपयोग आप कर सकते हैं खण्डश्रृंखला सत्यापित परिचयसूचकों के लिए।" सभी चुनें %1$d%% समय निरन्तर उपलब्ध नामरूप्य स्थापना विकल्प @@ -4183,7 +4197,7 @@ पढा हुआ चिह्नित करें टीका कार्य परिचय कार्य - अभिलेख कार्य + श्रव्यदृश्याभिलेख कार्य चालन स्वचालित @@ -4342,7 +4356,7 @@ घटना प्रतिबन्धित करें प्रकार को अनुमति दें अंकीय जालपता बाधित करें - घटना विभेदक (षोडशांक) + घटना सूचक (षोडशांक) प्रकार संख्या अंकीय जालपता कारण (विकल्पात्मक) @@ -4718,7 +4732,7 @@ नयी व्यक्तिशैली व्यक्तिशैली सम्पादन - सूचकाम्श (व्यक्तिशैली विभेदक) + सूचकाम्श (व्यक्तिशैली सूचक) a-z तथा 0-9 तथा \'-\' तथा \'_\'। परिवर्तनीय नहीं बनाने के पश्चात। प्रदर्शन नाम यन्त्र प्रेरण diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index bb09b99b56..f559c7e139 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -204,6 +204,7 @@ Az átjátszóhoz való sikeres kapcsolatok százalékos aránya Felhasználó keresése és hozzáadása Egy átjátszó hozzáadása + Az átjátszó címe érvénytelen. Használjon gazdagépnevet vagy zárójelben megadott IP-címet (például: [201:d0e:9ba5:8bbc::1]:8080). Saját @említési név Megjelenítendő név Saját megjelenítendő név @@ -3222,6 +3223,16 @@ Hírfolyamok Alkalmazások és web Egyéb + Oldalsó menü + Saját oldalsó menü + Nyisson meg egy szakaszt, és kapcsolja ki azokat a sorokat, amelyeket soha nem használ. A beállítások mindig láthatók maradnak, így bármikor visszatérhet ide. A szakaszok sorrendje rögzített. + Szakaszok + %1$d rejtett + Látható + Rejtett + Mindig bekapcsolva + Összes megjelenítése + Összes elrejtése Kezdőlap lapjai Válassza ki, mely lapok jelenjenek meg a kezdőlapon. Ha csak egy lap aktív, akkor a lapsáv rejtett. Minden @@ -3595,6 +3606,10 @@ Mentés Git-tárolók Kiemelések + Tárolók szűrése + Szűrő bezárása + Szűrés név, téma, kiszolgáló, karbantartó szerint… + A jelenlegi hírcsatornában nincs olyan tároló, amely megfelelne ennek a keresésnek. nOldal: %1$s nKisalkalmazás: %1$s Engedélyek: diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index db3045cba8..001bb2d0b4 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -211,6 +211,8 @@ Odsetek udanych połączeń z transmiterem Szukaj i dodaj użytkownika Dodaj Transmiter + Nieprawidłowy adres transmitera. Użyj nazwy hosta lub adresu IP w nawiasach (na przykład +[201:d0e:9ba5:8bbc::1]:8080). Moje imię @tag Nazwa użytkownika Mój nick @@ -3347,6 +3349,16 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Kanały Aplikacje & Strony Inne + Menu boczne + Twoje menu boczne + Otwórz sekcję i wyłącz wiersze, których nigdy nie używasz. Ustawienia zawsze pozostają widoczne, więc zawsze możesz tu wrócić. Kolejność sekcji jest ustalona. + Sekcje + %1$d ukrytych + Widoczne + Ukryte + Zawsze włączony + Pokaż wszystkie + Ukryj wszystkie Karty główne Wybierz, które karty pojawiają się na ekranie głównym. Gdy tylko jedna karta jest aktywna, pasek karty jest ukryty. Wszystko @@ -3726,6 +3738,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Zapisz Repozytoria Git Wyróżnienia + Filtruj repozytoria + Zamknij filtr + Filtruj według nazwy, tematu, hosta, opiekuna… + Brak repozytoriów w bieżącym kanale pasujących do tego wyszukiwania. Statyczna Witryna: %1$s nApplet: %1$s Uprawnienia: From 6d7ec4d9376a27e227cea61e19bd9e08213809b9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 7 Aug 2026 17:55:09 +0200 Subject: [PATCH 151/227] i18n: convert drawer hidden-count to plurals and fill missing cs/de/sv/pt strings --- .../loggedIn/settings/DrawerSettingsScreen.kt | 5 ++-- amethyst/src/main/res/values-cs/strings.xml | 19 ++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 17 +++++++++++++ .../src/main/res/values-hi-rIN/strings.xml | 5 +++- .../src/main/res/values-hu-rHU/strings.xml | 5 +++- .../src/main/res/values-pl-rPL/strings.xml | 7 +++++- .../src/main/res/values-pt-rBR/strings.xml | 25 +++++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 17 +++++++++++++ amethyst/src/main/res/values/strings.xml | 5 +++- 9 files changed, 99 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt index 9a32cc8a11..f1f6bef5bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -155,7 +156,7 @@ private fun SummaryCard(totalHidden: Int) { title = stringRes(R.string.drawer_settings_title), trailing = { Text( - text = stringRes(R.string.drawer_settings_hidden_count, totalHidden), + text = pluralStringResource(R.plurals.drawer_settings_hidden_count, totalHidden, totalHidden), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, @@ -189,7 +190,7 @@ private fun SectionCard( trailing = { if (hiddenHere > 0) { Text( - text = stringRes(R.string.drawer_settings_hidden_count, hiddenHere), + text = pluralStringResource(R.plurals.drawer_settings_hidden_count, hiddenHere, hiddenHere), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 854354592d..09e207b16c 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -4905,4 +4905,23 @@ URL avataru (volitelné) Publikování… Publikovat personu + Boční nabídka + Vaše boční nabídka + Otevřete sekci a vypněte řádky, které nikdy nepoužíváte. Nastavení zůstává vždy viditelné, takže se sem vždy vrátíte. Pořadí sekcí je pevné. + Sekce + + %1$d skrytá + %1$d skryté + %1$d skrytých + %1$d skrytých + + Viditelné + Skryté + Vždy zapnuto + Zobrazit vše + Skrýt vše + Filtrovat repozitáře + Zavřít filtr + Filtrovat podle názvu, tématu, hostitele, správce… + Tomuto hledání neodpovídají žádné repozitáře v aktuálním kanálu. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 16abc0f2e7..e7728b8f71 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -4713,4 +4713,21 @@ Avatar-URL (optional) Wird veröffentlicht… Persona veröffentlichen + Seitenmenü + Dein Seitenmenü + Öffne einen Bereich und schalte die Zeilen aus, die du nie nutzt. Einstellungen bleibt immer sichtbar, damit du jederzeit hierher zurückkommst. Die Reihenfolge der Bereiche ist fest. + Bereiche + + %1$d ausgeblendet + %1$d ausgeblendet + + Sichtbar + Ausgeblendet + Immer an + Alle anzeigen + Alle ausblenden + Repositories filtern + Filter schließen + Nach Name, Thema, Host, Betreuer filtern… + Keine Repositories im aktuellen Feed passen zu dieser Suche. diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 68dc9e575b..6faae86163 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -3226,7 +3226,10 @@ आपकी पार्श्व विकल्पसूची एक विभाग खोलें तथा उन पंक्तियों को निष्क्रिय करें जिनका उपयोग आप कभी नहीं करते। स्थापना विकल्प सर्वदा दृश्यमान रहते हैं। जिससे कि आप यहाँ कभी भी लौट सकेंगे। विभाग क्रम स्थायी है। विभाग - %1$d छिपे हुए + + %1$d छिपा हुआ + %1$d छिपे हुए + दृश्यमान छिपे हुए सर्वदा सक्रिय diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index f559c7e139..ec099bd65c 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -3227,7 +3227,10 @@ Saját oldalsó menü Nyisson meg egy szakaszt, és kapcsolja ki azokat a sorokat, amelyeket soha nem használ. A beállítások mindig láthatók maradnak, így bármikor visszatérhet ide. A szakaszok sorrendje rögzített. Szakaszok - %1$d rejtett + + %1$d rejtett + %1$d rejtett + Látható Rejtett Mindig bekapcsolva diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 001bb2d0b4..4d613a32e3 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -3353,7 +3353,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Twoje menu boczne Otwórz sekcję i wyłącz wiersze, których nigdy nie używasz. Ustawienia zawsze pozostają widoczne, więc zawsze możesz tu wrócić. Kolejność sekcji jest ustalona. Sekcje - %1$d ukrytych + + %1$d ukryta + %1$d ukryte + %1$d ukrytych + %1$d ukrytych + Widoczne Ukryte Zawsze włączony diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 33467f8882..380c186b11 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -4711,4 +4711,29 @@ URL do avatar (opcional) Publicando… Publicar persona + Menu lateral + Seu menu lateral + Abra uma seção e desligue as linhas que você nunca usa. Configurações permanece sempre visível, então você sempre pode voltar aqui. A ordem das seções é fixa. + Seções + + %1$d oculta + %1$d ocultas + + Visíveis + Ocultas + Sempre ativo + Mostrar tudo + Ocultar tudo + Filtrar repositórios + Fechar filtro + Filtrar por nome, tópico, host, mantenedor… + Nenhum repositório no feed atual corresponde a esta pesquisa. + + %1$d relay + %1$d relays + + + %1$s \u00b7 %2$d relay + %1$s \u00b7 %2$d relays + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 17d4fe1f2d..32a34b6eac 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -4725,4 +4725,21 @@ Avatar-URL (valfritt) Publicerar… Publicera persona + Sidomeny + Din sidomeny + Öppna en sektion och stäng av raderna du aldrig använder. Inställningar förblir alltid synligt, så du kan alltid ta dig tillbaka hit. Sektionernas ordning är fast. + Sektioner + + %1$d dold + %1$d dolda + + Synliga + Dolda + Alltid på + Visa alla + Dölj alla + Filtrera repositories + Stäng filter + Filtrera på namn, ämne, värd, underhållare… + Inga repositories i det aktuella flödet matchar den här sökningen. diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b6b362ed51..da24418325 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3517,7 +3517,10 @@ Your side menu Open a section and switch off the rows you never use. Settings always stays visible, so you can always get back here. Section order is fixed. Sections - %1$d hidden + + %1$d hidden + %1$d hidden + Visible Hidden Always on From 8b0ea7389c17baae6532fba2a4873a6aaa14d6dd Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:14:52 +0000 Subject: [PATCH 152/227] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-cs/strings.xml | 38 ++++++++--------- .../src/main/res/values-de-rDE/strings.xml | 34 +++++++-------- .../src/main/res/values-pt-rBR/strings.xml | 42 ++++++++----------- .../src/main/res/values-sv-rSE/strings.xml | 34 +++++++-------- docs/changelog/translators.json | 2 + 5 files changed, 72 insertions(+), 78 deletions(-) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 09e207b16c..2fce7e78c2 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -3347,6 +3347,21 @@ Kanály Aplikace a web Ostatní + Boční nabídka + Vaše boční nabídka + Otevřete sekci a vypněte řádky, které nikdy nepoužíváte. Nastavení zůstává vždy viditelné, takže se sem vždy vrátíte. Pořadí sekcí je pevné. + Sekce + + %1$d skrytá + %1$d skryté + %1$d skrytých + %1$d skrytých + + Viditelné + Skryté + Vždy zapnuto + Zobrazit vše + Skrýt vše Záložky domova Vyberte, které záložky se zobrazí na domovské obrazovce. Pokud je aktivní jen jedna záložka, lišta záložek se skryje. Vše @@ -3726,6 +3741,10 @@ Uložit Git repozitáře Zvýraznění + Filtrovat repozitáře + Zavřít filtr + Filtrovat podle názvu, tématu, hostitele, správce… + Tomuto hledání neodpovídají žádné repozitáře v aktuálním kanálu. Statický web: %1$s nApplet: %1$s Oprávnění: @@ -4905,23 +4924,4 @@ URL avataru (volitelné) Publikování… Publikovat personu - Boční nabídka - Vaše boční nabídka - Otevřete sekci a vypněte řádky, které nikdy nepoužíváte. Nastavení zůstává vždy viditelné, takže se sem vždy vrátíte. Pořadí sekcí je pevné. - Sekce - - %1$d skrytá - %1$d skryté - %1$d skrytých - %1$d skrytých - - Viditelné - Skryté - Vždy zapnuto - Zobrazit vše - Skrýt vše - Filtrovat repozitáře - Zavřít filtr - Filtrovat podle názvu, tématu, hostitele, správce… - Tomuto hledání neodpovídají žádné repozitáře v aktuálním kanálu. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index e7728b8f71..b8f2041354 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -3212,6 +3212,19 @@ Feeds Apps & Web Sonstiges + Seitenmenü + Dein Seitenmenü + Öffne einen Bereich und schalte die Zeilen aus, die du nie nutzt. Einstellungen bleibt immer sichtbar, damit du jederzeit hierher zurückkommst. Die Reihenfolge der Bereiche ist fest. + Bereiche + + %1$d ausgeblendet + %1$d ausgeblendet + + Sichtbar + Ausgeblendet + Immer an + Alle anzeigen + Alle ausblenden Startseiten-Tabs Wähle, welche Tabs auf der Startseite erscheinen. Wenn nur ein Tab aktiv ist, wird die Tab-Leiste ausgeblendet. Alles @@ -3584,6 +3597,10 @@ Themen (durch Komma getrennt) Speichern Git Repositories + Repositories filtern + Filter schließen + Nach Name, Thema, Host, Betreuer filtern… + Keine Repositories im aktuellen Feed passen zu dieser Suche. Statische Website: %1$s nApplet: %1$s Berechtigungen: @@ -4713,21 +4730,4 @@ Avatar-URL (optional) Wird veröffentlicht… Persona veröffentlichen - Seitenmenü - Dein Seitenmenü - Öffne einen Bereich und schalte die Zeilen aus, die du nie nutzt. Einstellungen bleibt immer sichtbar, damit du jederzeit hierher zurückkommst. Die Reihenfolge der Bereiche ist fest. - Bereiche - - %1$d ausgeblendet - %1$d ausgeblendet - - Sichtbar - Ausgeblendet - Immer an - Alle anzeigen - Alle ausblenden - Repositories filtern - Filter schließen - Nach Name, Thema, Host, Betreuer filtern… - Keine Repositories im aktuellen Feed passen zu dieser Suche. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 380c186b11..1ceae75a2e 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -3206,6 +3206,19 @@ Feeds Apps e Web Outros + Menu lateral + Seu menu lateral + Abra uma seção e desligue as linhas que você nunca usa. Configurações permanece sempre visível, então você sempre pode voltar aqui. A ordem das seções é fixa. + Seções + + %1$d oculta + %1$d ocultas + + Visíveis + Ocultas + Sempre ativo + Mostrar tudo + Ocultar tudo Abas da Tela Inicial Escolha quais abas aparecem na Tela Inicial. Quando apenas uma aba está ativa, a barra de abas fica oculta. Tudo @@ -3579,6 +3592,10 @@ Salvar Repositórios Git Destaques + Filtrar repositórios + Fechar filtro + Filtrar por nome, tópico, host, mantenedor… + Nenhum repositório no feed atual corresponde a esta pesquisa. Site Estático: %1$s nApplet: %1$s Permissões: @@ -4711,29 +4728,4 @@ URL do avatar (opcional) Publicando… Publicar persona - Menu lateral - Seu menu lateral - Abra uma seção e desligue as linhas que você nunca usa. Configurações permanece sempre visível, então você sempre pode voltar aqui. A ordem das seções é fixa. - Seções - - %1$d oculta - %1$d ocultas - - Visíveis - Ocultas - Sempre ativo - Mostrar tudo - Ocultar tudo - Filtrar repositórios - Fechar filtro - Filtrar por nome, tópico, host, mantenedor… - Nenhum repositório no feed atual corresponde a esta pesquisa. - - %1$d relay - %1$d relays - - - %1$s \u00b7 %2$d relay - %1$s \u00b7 %2$d relays - diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 32a34b6eac..59ec28bf1d 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -3219,6 +3219,19 @@ Flöden Appar & webb Övrigt + Sidomeny + Din sidomeny + Öppna en sektion och stäng av raderna du aldrig använder. Inställningar förblir alltid synligt, så du kan alltid ta dig tillbaka hit. Sektionernas ordning är fast. + Sektioner + + %1$d dold + %1$d dolda + + Synliga + Dolda + Alltid på + Visa alla + Dölj alla Hemflikar Välj vilka flikar som visas på startskärmen. När endast en flik är aktiv döljs flikraden. Allt @@ -3592,6 +3605,10 @@ Spara Git-repositories Höjdpunkter + Filtrera repositories + Stäng filter + Filtrera på namn, ämne, värd, underhållare… + Inga repositories i det aktuella flödet matchar den här sökningen. Statisk webbplats: %1$s nApplet: %1$s Behörigheter: @@ -4725,21 +4742,4 @@ Avatar-URL (valfritt) Publicerar… Publicera persona - Sidomeny - Din sidomeny - Öppna en sektion och stäng av raderna du aldrig använder. Inställningar förblir alltid synligt, så du kan alltid ta dig tillbaka hit. Sektionernas ordning är fast. - Sektioner - - %1$d dold - %1$d dolda - - Synliga - Dolda - Alltid på - Visa alla - Dölj alla - Filtrera repositories - Stäng filter - Filtrera på namn, ämne, värd, underhållare… - Inga repositories i det aktuella flödet matchar den här sökningen. diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index a322a2a269..78f466826a 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -95,6 +95,8 @@ "languages": [ "Czech", "German", + "Hindi", + "Hungarian", "Polish", "Portuguese, Brazilian", "Swedish" From 3f087e5c6080f09c1b3294c9f8716ef131c7b998 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 23:46:30 +0000 Subject: [PATCH 153/227] Quartz: give SyncCoverage's persistence a typed band key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `export`/`restore` handed back `Map` where the string was the INTERNAL key — `" "`. That is fine for a file layer that writes the key back verbatim, and nothing else. A layer that wants its own layout — one object per relay, or per filter, or nested by both — had to split the key apart, and the separator was folklore it could only learn by reading this class. Two of them now do. So the key is a pair, with the joined form kept here as `encode`/`decode` for a file that does want one key per line. geode keeps its format byte-for-byte and stops pattern-matching on somebody else's string. It is also faster on the path that matters. `key()` built a new string per lookup, so a `legs()` over a fan-out COPIED the filter's json — tens of thousands of characters for an author-scoped filter — once per relay per cycle, then hashed all of it, since a freshly built string carries no cached hash. The pair hashes two halves it already holds: the url, and the fingerprint instance the cache above it already returns. No behaviour change: the same pairs key the same bands, a file written before this reads back through `decode`, and the format on disk is untouched. --- .../geode/mirror/SyncCoverageFile.kt | 25 +++++---- .../relay/client/accessories/SyncCoverage.kt | 51 ++++++++++++++++--- .../client/accessories/SyncCoverageTest.kt | 20 ++++++++ 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt index 58b0b23310..ea5f9aad21 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt @@ -96,15 +96,22 @@ class SyncCoverageFile( if (!file.isFile) return runCatching { val root = Json.parseToJsonElement(file.readText()).jsonObject + // The file's key is the joined form, decoded by the class that + // mints it — this layer never has to know the separator. A key it + // cannot read names no pair and is dropped, which costs one + // upstream's re-walk rather than the whole file. coverage.restore( - root.mapValues { (_, v) -> - val o = v.jsonObject - SyncCoverage.Band( - spansOf(o), - o["complete"]?.jsonPrimitive?.boolean ?: false, - o["fullAt"]?.jsonPrimitive?.long ?: 0L, - ) - }, + root.entries + .mapNotNull { (k, v) -> + val key = SyncCoverage.BandKey.decode(k) ?: return@mapNotNull null + val o = v.jsonObject + key to + SyncCoverage.Band( + spansOf(o), + o["complete"]?.jsonPrimitive?.boolean ?: false, + o["fullAt"]?.jsonPrimitive?.long ?: 0L, + ) + }.toMap(), ) }.onFailure { Log.w("SyncCoverageFile") { "could not read ${file.path} (${it.message}); starting fresh" } @@ -142,7 +149,7 @@ class SyncCoverageFile( buildJsonObject { coverage.export().forEach { (key, band) -> put( - key, + key.encode(), buildJsonObject { // min/max are the outer edges across every // kind, and are written for two readers: a diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt index 3eb54ae9fd..c8e2e3aed8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt @@ -49,8 +49,10 @@ import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap * occasional and self-heal on the next filter change or full re-walk. * * Persistence is the caller's: [export] the map on a schedule and [restore] - * it at startup. [onChange] fires whenever a band changes, so a persistence - * layer can mark itself dirty without polling. + * it at startup, both keyed by [BandKey] so a file layer can lay the two + * halves out however it likes without having to know how a key is spelled. + * [onChange] fires whenever a band changes, so a persistence layer can mark + * itself dirty without polling. * * Not to be confused with the `relay.client.paging` package: its * `RelayLoadingCursors` are in-memory POSITIONS for demand-driven UI paging @@ -65,6 +67,41 @@ class SyncCoverage( private val now: () -> Long = { TimeUtils.now() }, private val onChange: () -> Unit = {}, ) { + /** + * What one band is about: the relay's url, and the filter as [Filter.toJson] + * renders it. + * + * A pair, not a joined string, for two reasons. A persistence layer needs + * the halves — one that lays its file out by relay, or by filter, or by + * both, had to split the key back apart, and the separator was folklore it + * could only learn by reading this class. And on the hot path a joined key + * COPIES the filter's json on every lookup: `legs()` runs once per relay + * per cycle, an author-scoped filter's json runs to tens of thousands of + * characters, and a fan-out over thousands of relays paid that copy — plus + * a fresh hash over all of it, since a newly built string has none cached — + * on every one of them. A pair hashes the two halves it already holds. + * + * [encode] and [decode] are the joined form, kept HERE so a file that wants + * one key per line still gets the separator from the class that mints it. + * A normalized relay url contains no space, which is what makes splitting + * at the first one exact. + */ + data class BandKey( + val relay: String, + val filter: String, + ) { + fun encode(): String = "$relay $filter" + + companion object { + /** The inverse of [encode], or null for a key that names no pair. */ + fun decode(key: String): BandKey? { + val at = key.indexOf(' ') + if (at <= 0 || at == key.length - 1) return null + return BandKey(key.substring(0, at), key.substring(at + 1)) + } + } + } + /** A covered `created_at` interval, inclusive at both ends. */ data class Span( val min: Long, @@ -115,7 +152,7 @@ class SyncCoverage( } } - private val bands = ConcurrentMap() + private val bands = ConcurrentMap() // filter -> its canonical json. Filter.toJson() runs to tens of thousands // of characters for author-scoped filters, and a fan-out keys once per @@ -379,10 +416,10 @@ class SyncCoverage( fun size(): Int = bands.size() /** A point-in-time copy of every band, for a persistence layer to write out. */ - fun export(): Map = bands.snapshot() + fun export(): Map = bands.snapshot() /** Load previously [export]ed bands, e.g. at startup. */ - fun restore(entries: Map) { + fun restore(entries: Map) { for ((key, band) in entries) bands[key] = band } @@ -395,7 +432,7 @@ class SyncCoverage( private fun key( url: NormalizedRelayUrl, filter: Filter, - ): String { + ): BandKey { val fingerprint = fingerprints[filter] ?: filter.toJson().also { @@ -404,7 +441,7 @@ class SyncCoverage( // pays the toJson each time instead of growing the heap. if (fingerprints.size() < MAX_FINGERPRINTS) fingerprints[filter] = it } - return "${url.url} $fingerprint" + return BandKey(url.url, fingerprint) } // One line per process, not per walk: the point is to tell a caller it has diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt index 3c26e1891e..abf1113f02 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt @@ -354,6 +354,26 @@ class SyncCoverageTest { assertEquals(1_700_002_000L, band.maxCreatedAt) } + @Test + fun `a band key round-trips through the joined form a file writes`() { + // A file that wants one key per line joins and splits with these, so + // the separator stays in the class that mints the key instead of being + // rediscovered by every persistence layer downstream. + val c = SyncCoverage() + c.record(relay, profiles, 1_700_001_000L, 1_700_002_000L, paged = true) + val key = c.export().keys.single() + + assertEquals(relay.url, key.relay) + assertEquals(profiles.toJson(), key.filter) + assertEquals(key, SyncCoverage.BandKey.decode(key.encode())) + + // A key naming no pair is refused rather than read as a relay with an + // empty filter, which would key a band nothing can ever look up. + assertNull(SyncCoverage.BandKey.decode("no-space-here")) + assertNull(SyncCoverage.BandKey.decode(" {\"kinds\":[0]}")) + assertNull(SyncCoverage.BandKey.decode("wss://relay.example/ ")) + } + @Test fun `onChange fires when a band changes so persistence can mark dirty`() { var changes = 0 From dc03209bb544fbc784da84d9df1b6cb1c26f3663 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 00:41:02 +0000 Subject: [PATCH 154/227] fix: silence Kotlin override-parameter-name and redundant-!! warnings LocalCache implements both Dao and ICacheProvider, which disagreed on the parameter names of getOrCreateUser (hex vs pubkey) and getOrCreateAddressableNote (address vs key), so every override warned about named-argument mismatches. Align both interfaces on pubkey/address and update the implementations that used the other name. Also drop the non-null assertions the compiler already smart-casts away in LimitsPolicy.capLimits and RelayProberFlowTest, and match the WebSocketListener parameter names in NegentropyStallRepro. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017X7C797zGYsiui5yj1JQcY --- .../java/com/vitorpamplona/amethyst/model/Dao.kt | 2 +- .../com/vitorpamplona/amethyst/model/LocalCache.kt | 6 +++--- .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../amethyst/NewMessageTaggerKeyParseTest.kt | 2 +- .../amethyst/commons/model/cache/ICacheProvider.kt | 2 +- .../amethyst/commons/model/ThreadAssemblerTest.kt | 2 +- .../model/concord/ConcordChannelListLeaveTest.kt | 2 +- .../model/concord/ConcordListLateArrivalTest.kt | 2 +- .../amethyst/commons/ui/note/ReplyContextTest.kt | 2 +- .../amethyst/desktop/cache/DesktopLocalCache.kt | 6 +++--- .../nip01Core/relay/server/policies/LimitsPolicy.kt | 4 ++-- .../reachability/RelayProberFlowTest.kt | 6 +++--- .../relay/prodbench/NegentropyStallRepro.kt | 12 ++++++------ 13 files changed, 25 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt index c1e3443d94..00062654eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey * needing the full [LocalCache] API. */ interface Dao { - fun getOrCreateUser(hex: HexKey): User + fun getOrCreateUser(pubkey: HexKey): User fun getOrCreateNote(hex: HexKey): Note diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 7ffa080688..9df341c771 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -685,12 +685,12 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { fun load(keys: Set): Set = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser) - override fun getOrCreateUser(hex: HexKey): User { - require(isValidHex(key = hex)) { "$hex is not a valid hex" } + override fun getOrCreateUser(pubkey: HexKey): User { + require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" } // Pass `this` as the UserContext — User now resolves each pinned // addressable note (kind:10002 / 10050 / 10019) lazily on first // read, instead of all-or-nothing at construction time. - return users.getOrCreate(hex) { User(it, userContext) } + return users.getOrCreate(pubkey) { User(it, userContext) } } /** [UserContext] bridge to this cache's addressable lookup. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index f27b3215c9..490f853b80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2106,7 +2106,7 @@ class AccountViewModel( fun checkGetOrCreateUser(key: HexKey): User? = LocalCache.checkGetOrCreateUser(key) - override fun getOrCreateUser(hex: HexKey): User = LocalCache.getOrCreateUser(hex) + override fun getOrCreateUser(pubkey: HexKey): User = LocalCache.getOrCreateUser(pubkey) fun getUserIfExists(hex: HexKey): User? = LocalCache.getUserIfExists(hex) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt index dba6e87238..b73dbfd3fc 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/NewMessageTaggerKeyParseTest.kt @@ -39,7 +39,7 @@ import org.junit.Test class NewMessageTaggerKeyParseTest { val dao: Dao = object : Dao { - override fun getOrCreateUser(hex: String): User = User(hex) { addr -> getOrCreateAddressableNoteInternal(addr) } + override fun getOrCreateUser(pubkey: String): User = User(pubkey) { addr -> getOrCreateAddressableNoteInternal(addr) } override fun getOrCreateNote(hex: String) = com.vitorpamplona.amethyst.model diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt index e4b3ccd9ae..6bdb38532e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt @@ -93,7 +93,7 @@ interface ICacheProvider { * @param address The note's ID in address format * @return The AddressableNote (existing or newly created) */ - fun getOrCreateAddressableNote(key: Address): AddressableNote + fun getOrCreateAddressableNote(address: Address): AddressableNote /** * Gets the event stream for cache updates. diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt index e676dd6dc6..e2a25246a9 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt @@ -181,7 +181,7 @@ class ThreadAssemblerTest { override fun checkGetOrCreateNote(hexKey: HexKey): Note? = notesById[hexKey] - override fun getOrCreateAddressableNote(key: Address): AddressableNote = error("not used by ThreadAssembler in this test") + override fun getOrCreateAddressableNote(address: Address): AddressableNote = error("not used by ThreadAssembler in this test") override fun getEventStream(): ICacheEventStream = error("not used by ThreadAssembler in this test") diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt index 2b4682326f..600a56a38b 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt @@ -90,7 +90,7 @@ class ConcordChannelListLeaveTest { override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null - override fun getOrCreateAddressableNote(key: Address): AddressableNote = AddressableNote(key) + override fun getOrCreateAddressableNote(address: Address): AddressableNote = AddressableNote(address) override fun getEventStream(): ICacheEventStream = error("not used") diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt index 366693b062..e4325e9280 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt @@ -88,7 +88,7 @@ class ConcordListLateArrivalTest { override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null - override fun getOrCreateAddressableNote(key: Address): AddressableNote = notes.getOrPut(key.toValue()) { AddressableNote(key) } + override fun getOrCreateAddressableNote(address: Address): AddressableNote = notes.getOrPut(address.toValue()) { AddressableNote(address) } override fun getEventStream(): ICacheEventStream = error("not used") diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt index e0d5791888..0a23769767 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt @@ -121,7 +121,7 @@ class ReplyContextTest { override fun checkGetOrCreateNote(hexKey: HexKey): Note? = notesById[hexKey] - override fun getOrCreateAddressableNote(key: Address): AddressableNote = error("not used by ReplyContext.from") + override fun getOrCreateAddressableNote(address: Address): AddressableNote = error("not used by ReplyContext.from") override fun getEventStream(): ICacheEventStream = error("not used by ReplyContext.from") diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index af026c5746..b83d490a28 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -904,9 +904,9 @@ class DesktopLocalCache : ICacheProvider { Note(hexKey) } - override fun getOrCreateAddressableNote(key: Address): AddressableNote = - addressableNotes.getOrCreate(key.toValue()) { - AddressableNote(key) + override fun getOrCreateAddressableNote(address: Address): AddressableNote = + addressableNotes.getOrCreate(address.toValue()) { + AddressableNote(address) } // ----- Channel operations ----- diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt index 3aaa3e3f32..e543fa5a41 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt @@ -128,8 +128,8 @@ class LimitsPolicy( */ private fun capLimits(filters: List): List { val max = limits.maxLimit ?: return filters - if (filters.none { it.limit != null && it.limit!! > max }) return filters - return filters.map { if (it.limit != null && it.limit!! > max) it.copy(limit = max) else it } + if (filters.none { it.limit != null && it.limit > max }) return filters + return filters.map { if (it.limit != null && it.limit > max) it.copy(limit = max) else it } } private fun targetLimit(current: Int?): Int? = diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt index 1cfdfcea00..f0a6be04ca 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt @@ -327,9 +327,9 @@ class RelayProberFlowTest { } check.join() - val verdict = result!![fast]!! - assertEquals(true, verdict.writeAccepted, "the listed relay's OK must still be awaited and recorded") - assertNull(result!![foreign], "the foreign relay must not appear in the result") + val verdicts = result!! + assertEquals(true, verdicts[fast]!!.writeAccepted, "the listed relay's OK must still be awaited and recorded") + assertNull(verdicts[foreign], "the foreign relay must not appear in the result") } @Test diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyStallRepro.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyStallRepro.kt index c85e4fb80a..16ef708751 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyStallRepro.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/NegentropyStallRepro.kt @@ -103,10 +103,10 @@ class NegentropyStallRepro { object : WebSocketListener { override fun onOpen( pingMillis: Int, - usingCompression: Boolean, + compression: Boolean, ) { - log(" [<-open] ${url.url} ping=${pingMillis}ms deflate=$usingCompression") - out.onOpen(pingMillis, usingCompression) + log(" [<-open] ${url.url} ping=${pingMillis}ms deflate=$compression") + out.onOpen(pingMillis, compression) } override suspend fun onMessage(text: String) { @@ -130,10 +130,10 @@ class NegentropyStallRepro { override fun onFailure( t: Throwable, code: Int?, - errorMessage: String?, + response: String?, ) { - log(" [<-failure] ${url.url} code=$code msg=$errorMessage err=${t.message}") - out.onFailure(t, code, errorMessage) + log(" [<-failure] ${url.url} code=$code msg=$response err=${t.message}") + out.onFailure(t, code, response) } } From c8e357381247f8f502fca679400d5e6d301d238e Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 08:23:50 +0200 Subject: [PATCH 155/227] feat(resourceusage): relay churn and traffic attribution counters The ledger could say how much relay data the app moved, but not why. It counted completed connections and a single undifferentiated byte total, so "1.65 GB/day across 6,600 connects" could not be broken down further, and relay.connfails was being read as a dial-failure count when it also fires for mid-session drops of successful connections. Adds, all as counters with no behaviour change: relay.dials / relay.disc real dial and disconnect counts relay.life. connection-lifetime histogram, bucketed to straddle STABLE_CONNECTION_IN_SECS relay.verb.up/down. the byte totals split by protocol verb relay.purpose.

.* REQ bytes, inbound bytes and frames by the SubPurpose that asked, read off the ExplainedFilter that already travels on the filter relay.subs.* REQs sent, closed, replayed after connect, and re-sent for an already-open subscription relay.events.* inbound EVENT frames and how many carried an event already delivered relay.notice. NOTICE frames by an allowlisted reason relay.hs / relay.gap the transport's own handshake timing, and everything before the request went out relay.trigger. which decision asked for a reconnect --- .../com/vitorpamplona/amethyst/AppModules.kt | 1 + .../relayClient/RelayProxyClientConnector.kt | 29 + .../resourceusage/RelayUsageListener.kt | 265 +++++- .../resourceusage/ResourceUsageAccountant.kt | 16 +- .../ResourceUsageReportAssembler.kt | 7 +- .../resourceusage/ResourceUsageStore.kt | 6 +- .../service/resourceusage/UsageKeys.kt | 610 +++++++++++++- .../loggedIn/buzz/AgentConsoleViewModel.kt | 4 +- .../loggedIn/buzz/BuzzDmListViewModel.kt | 4 +- .../screen/loggedIn/buzz/BuzzJoinReconnect.kt | 53 ++ .../loggedIn/buzz/BuzzRelayImportViewModel.kt | 10 +- .../resourceusage/ResourceUsageLedgerTest.kt | 774 ++++++++++++++++++ .../client/single/basic/BasicRelayClient.kt | 6 +- 13 files changed, 1744 insertions(+), 41 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzJoinReconnect.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 0c59012d38..0c3936c413 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -723,6 +723,7 @@ class AppModules( torManager.status, client, applicationIOScope, + onTrigger = { cause -> resourceUsage.add(UsageKeys.relayTrigger(cause), 1) }, ) // Verifies and inserts in the cache from all relays, all subscriptions diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt index 995870ce21..b7283b37d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -49,6 +50,14 @@ class RelayProxyClientConnector( val torStatus: StateFlow, val client: INostrClient, val scope: CoroutineScope, + /** + * Called with the cause every time this connector *decides* to reconnect, so the + * usage ledger can attribute relay churn without this class knowing where the + * counters go. Causes are the `UsageKeys.TRIGGER_*` constants — see + * [UsageKeys.relayTrigger] for why these are an upper bound rather than a count + * of reconnects actually performed. + */ + val onTrigger: (String) -> Unit = {}, ) { data class RelayServiceInfra( val evaluator: TorRelayEvaluation, @@ -138,6 +147,9 @@ class RelayProxyClientConnector( infra.connectivity is ConnectivityStatus.Off -> { Log.d("ManageRelayServices") { "Connectivity Off: Pausing Relay Services ${infra.connectivity}" } if (client.isActive()) { + // Counted inside the guard: the upstream combine() re-emits Off + // repeatedly and only this branch does any work. + onTrigger(UsageKeys.TRIGGER_OFF) client.disconnect() } if (infra.torStatus is TorServiceStatus.Active) { @@ -156,6 +168,7 @@ class RelayProxyClientConnector( } // only calls this if the client is not active. Otherwise goes to the else below + onTrigger(UsageKeys.TRIGGER_COLD_START) client.connect() lastNetworkId = networkId lastTorSettings = torSettings @@ -211,10 +224,26 @@ class RelayProxyClientConnector( Log.d("ManageRelayServices") { "Network identity changed ($previousNetworkId -> $networkId), rebuilding every relay connection" } + // The expensive branch, and the one the churn investigation is + // aimed at: a full teardown re-dials the whole pool and replays + // every REQ. + // + // Only this cause is counted here, so a wifi<->cellular handoff — + // which mints a new network handle AND rebuilds the OkHttp clients + // off the metered bit — is booked as netid alone. relay.trigger.transport + // therefore undercounts exactly the case one would most want it for; + // read it as "transport changed WITHOUT the handle changing". + onTrigger(UsageKeys.TRIGGER_NETID) // Full teardown: disconnect() drops the dead sockets AND clears each // relay's backoff, so the new network starts from a clean slate. client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) } else { + // Non-exclusive: count each independently so the report shows + // which combination fired. + if (transportChanged) onTrigger(UsageKeys.TRIGGER_TRANSPORT) + if (torPolicyChanged) onTrigger(UsageKeys.TRIGGER_TOR_POLICY) + if (classificationChanged) onTrigger(UsageKeys.TRIGGER_CLASSIFICATION) + val freshStart = transportChanged || torPolicyChanged if (freshStart) { // The failures behind the current backoffs were measured against a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt index 5d97ef56cb..79e4e9e9c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt @@ -20,10 +20,22 @@ */ package com.vitorpamplona.amethyst.service.resourceusage +import android.os.SystemClock +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import java.util.concurrent.ConcurrentHashMap /** * Counts relay websocket traffic into the usage ledger. Frame sizes are @@ -31,12 +43,89 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command * (relay JSON is ASCII-dominant), consistent with how RelayStats counts. * Excludes WS framing/compression; good enough for "which subsystem is * eating my data plan" comparisons. + * + * Beyond the byte totals this also carries the relay-churn diagnostics: a + * per-verb split of those same bytes, a connection-lifetime histogram, and + * per-relay failure/short-session counts. See + * plans/2026-07-29-relay-churn-diagnostics.md for what each answers and how to + * read them together. + * + * The verb split takes its name straight from the wire label the command already + * knows (`Command.label()` / `Message.label()`), so `Σ verb == Σ msg` holds by + * construction and a new subtype needs no change here. */ class RelayUsageListener( private val accountant: ResourceUsageAccountant, private val isMobile: () -> Boolean, private val isForeground: () -> Boolean, + private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, ) : RelayConnectionListener { + /** + * Relay -> when its current session became ready. Touched from the per-relay + * OkHttp dispatcher threads, hence concurrent. Keyed by [NormalizedRelayUrl] to + * match the other per-relay caches (`RelayStats`, `RelayLimitsTracker`). + * + * Entries are consumed on disconnect. Three ways a session escapes the map + * unrecorded, all counted rather than prevented — see [UsageKeys.RELAY_LIFE_OVERWRITE], + * [UsageKeys.RELAY_LIFE_ORPHAN], and [UsageKeys.relayConnects] for process death. + */ + private val connectedSince = ConcurrentHashMap() + + /** + * Relay -> subscription ids currently open on this connection, so a REQ that + * replaces an in-flight subscription can be told apart from one that opens a new + * one. Cleared on disconnect, because the relay forgets them too — every REQ + * after a reconnect is legitimately new. + * + * Bounded by the live subscription count per relay (tens), not by session length. + */ + private val openSubs = ConcurrentHashMap>() + + /** + * Subscription id -> the purpose that opened it, for attributing inbound frames: + * an EVENT names only its subscription, never why the client asked for it. + * + * Deliberately **not** [openSubs]. Sharing one map conflated two different + * lifetimes and lost 41 % of the download to `unattributed` in the 2026-08-02 + * reading: a CLOSE removed the id, and a disconnect dropped the whole relay's + * map, while frames already in flight were still arriving. "Is this subscription + * open" and "what did this subscription belong to" answer different questions and + * expire at different times — the second stays true after the first turns false. + * + * Keyed by subscription id alone, without the relay. The same id is used across + * relays for one logical subscription, so the purpose is a property of the id; + * this also means a frame arriving after a reconnect still attributes. + * + * Bounded by [MAX_TRACKED_SUBS] with wholesale eviction rather than an LRU: this + * is a diagnostic on a hot path, ids are recycled steadily, and a rare reset that + * sends a few frames to `unattributed` is cheaper than per-frame bookkeeping. + * `unattributed` staying small is what says the bound is generous enough. + */ + private val subPurpose = ConcurrentHashMap() + + /** + * Event ids delivered recently, as the first 64 bits of the id. + * + * Held as a Long rather than the 64-char hex: at the window size below that is + * the difference between ~200 KB and several MB on a 512 MB-class device, for a + * counter that only has to spot repetition. 64 bits makes a collision between + * distinct ids negligible where a 32-bit hash would not be. + * + * The window only needs to span the fan-out, not the session: the same event + * arrives from every relay carrying it within seconds, so near-term memory + * catches the duplication this measures. Cleared wholesale at [MAX_TRACKED_EVENTS] + * for the same reason [subPurpose] is — the alternative is per-frame LRU + * bookkeeping on the hottest path in the app. A clear undercounts duplicates that + * straddle it, so the ratio is a floor. + */ + private val recentEventIds = ConcurrentHashMap.newKeySet() + + /** Relay -> when this dial was decided, so the pre-request cost can be separated from the handshake. */ + private val dialStartedAt = ConcurrentHashMap() + + /** Notice texts already logged, so one wording costs one line however often it arrives. */ + private val loggedNotices = ConcurrentHashMap.newKeySet() + override fun onSent( relay: IRelayClient, cmdStr: String, @@ -44,7 +133,44 @@ class RelayUsageListener( success: Boolean, ) { if (success) { - accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = false), cmdStr.length.toLong()) + val bytes = cmdStr.length.toLong() + val mobile = isMobile() + val fg = isForeground() + accountant.add(UsageKeys.relayMsg(mobile, fg, received = false), bytes) + accountant.add(UsageKeys.relayVerb(cmd.label(), received = false, mobile, fg), bytes) + + when (cmd.label()) { + ReqCmd.LABEL -> { + accountant.add(UsageKeys.relaySubsSent(mobile, fg), 1) + + val purpose = purposeOf(cmd) + accountant.add(UsageKeys.relayPurposeSent(purpose), 1) + accountant.add(UsageKeys.relayPurposeBytes(purpose), bytes) + + // Already open on this connection, so this REQ replaces a live + // subscription rather than starting one. + val subId = (cmd as ReqCmd).subId + if (subPurpose.size >= MAX_TRACKED_SUBS) subPurpose.clear() + subPurpose[subId] = purpose + + val known = openSubs.getOrPut(relay.url) { ConcurrentHashMap.newKeySet() } + if (!known.add(subId)) { + accountant.add(UsageKeys.relaySubsResent(mobile, fg), 1) + } + // Within the window after this relay's connect, so almost certainly + // part of syncState's replay rather than a user action. A time + // window because nothing on this side marks a frame as belonging to + // it; see UsageKeys.relaySubsReplay. + val since = connectedSince[relay.url] + if (since != null && nowMs() - since <= UsageKeys.REPLAY_WINDOW_MS) { + accountant.add(UsageKeys.relaySubsReplay(mobile, fg), 1) + } + } + CloseCmd.LABEL -> { + accountant.add(UsageKeys.relaySubsClosed(mobile, fg), 1) + openSubs[relay.url]?.remove((cmd as CloseCmd).subId) + } + } } } @@ -53,7 +179,63 @@ class RelayUsageListener( msgStr: String, msg: Message, ) { - accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = true), msgStr.length.toLong()) + val bytes = msgStr.length.toLong() + val mobile = isMobile() + val fg = isForeground() + accountant.add(UsageKeys.relayMsg(mobile, fg, received = true), bytes) + accountant.add(UsageKeys.relayVerb(msg.label(), received = true, mobile, fg), bytes) + + // Attribute the inbound side to whoever asked for it. Only frames that name a + // subscription can be attributed; NOTICE and OK are relay-wide and are left out + // rather than guessed at, which is why this does not reconcile to msg.rx. + // Resolved once: the duplicate check below needs the same answer, and an + // EVENT names only its subscription, never why the client asked for it. + val purpose = subIdOf(msg)?.let { subPurpose[it] ?: UsageKeys.PURPOSE_UNATTRIBUTED } + if (purpose != null) { + accountant.add(UsageKeys.relayPurposeDown(purpose), bytes) + accountant.add(UsageKeys.relayPurposeDownCount(purpose), 1) + } + + if (msg is EventMessage) { + accountant.add(UsageKeys.relayEventsSeen(mobile, fg), 1) + idPrefix(msg.event.id)?.let { key -> + if (recentEventIds.size >= MAX_TRACKED_EVENTS) recentEventIds.clear() + if (!recentEventIds.add(key)) { + accountant.add(UsageKeys.relayEventsDup(mobile, fg), 1) + accountant.add(UsageKeys.relayEventsDupBytes(mobile, fg), bytes) + if (purpose != null) accountant.add(UsageKeys.relayPurposeDupBytes(purpose), bytes) + } + } + } + + // A refused subscription arrives here and nowhere else: the NOTICE carries no + // subscription id, so RelayReqRefusals (wired to CLOSED) never sees it. + if (msg is NoticeMessage) { + val reason = UsageKeys.noticeReason(msg.message) + accountant.add(UsageKeys.relayNotice(reason), 1) + if (reason == UsageKeys.NOTICE_UNCLASSIFIED) { + // The counter alone cannot say whether an absent `toomanysubs` means no + // refusals or an allowlist that misses how this relay words them. + // + // INFO, not DEBUG: a debug build defaults to LogLevel.INFO + // (Amethyst.DEFAULT_LOG_LEVEL, with VERBOSE_LOGS off), so a DEBUG line + // here is dropped before it reaches the sink and this said nothing at + // all. Demote it once the allowlist stops needing evidence. + // + // One line per distinct wording rather than per frame: the ledger + // already has the count, what is missing is the variety. Truncated and + // capped because the text is server-controlled. + if (loggedNotices.size < MAX_DISTINCT_NOTICES && loggedNotices.add(msg.message)) { + Log.i(TAG) { "Unclassified NOTICE from ${relay.url.url}: ${msg.message.take(MAX_NOTICE_LOG)}" } + } + } + } + } + + /** Dial attempts. Unlike [onCannotConnect] this really is one per dial. */ + override fun onConnecting(relay: IRelayClient) { + accountant.add(UsageKeys.relayDials(isMobile(), isForeground()), 1) + dialStartedAt[relay.url] = nowMs() } // Every completed (re)connection paid a TCP+TLS handshake; high daily @@ -64,13 +246,90 @@ class RelayUsageListener( pingMillis: Int, compressed: Boolean, ) { - accountant.add(UsageKeys.relayConnects(isMobile(), isForeground()), 1) + val mobile = isMobile() + val fg = isForeground() + // Doubles as the lifetime histogram's denominator — one session begins here. + accountant.add(UsageKeys.relayConnects(mobile, fg), 1) + // pingMillis is the transport's own handshake timing; <= 0 means it could + // not measure it, and a fabricated 0 would be worse than no record. + if (pingMillis > 0) { + accountant.add(UsageKeys.relayHandshake(pingMillis.toLong(), mobile, fg), 1) + dialStartedAt.remove(relay.url)?.let { startedAt -> + val gap = nowMs() - startedAt - pingMillis + if (gap >= 0) accountant.add(UsageKeys.relayDialGap(gap, mobile, fg), 1) + } + } + + if (connectedSince.put(relay.url, nowMs()) != null) { + accountant.add(UsageKeys.RELAY_LIFE_OVERWRITE, 1) + } } + override fun onDisconnected(relay: IRelayClient) { + val mobile = isMobile() + val fg = isForeground() + accountant.add(UsageKeys.relayDisconnects(mobile, fg), 1) + + openSubs.remove(relay.url) + val startedAt = connectedSince.remove(relay.url) + if (startedAt == null) { + // A dial that never became ready, or a second disconnect for one session. + accountant.add(UsageKeys.RELAY_LIFE_ORPHAN, 1) + return + } + + val elapsed = (nowMs() - startedAt).coerceAtLeast(0) + accountant.add(UsageKeys.relayLife(elapsed, mobile, fg), 1) + } + + /** + * The [SubPurpose] behind a REQ, from the first filter that declares one. + * + * One subscription id carries one purpose in practice, so the first is the + * subscription's. A REQ whose filters are plain [com.vitorpamplona.quartz.nip01Core.relay.filters.Filter]s + * predates #3832's tagging and is counted separately rather than guessed at. + */ + private fun purposeOf(cmd: Command): String { + val filters = (cmd as? ReqCmd)?.filters ?: return UsageKeys.PURPOSE_UNEXPLAINED + val explained = + filters.firstOrNull { it is ExplainedFilter } as? ExplainedFilter + ?: return UsageKeys.PURPOSE_UNEXPLAINED + return UsageKeys.purposeKeyPart(explained.purpose) + } + + /** The first 64 bits of an event id, or null if it is not a well-formed id. */ + private fun idPrefix(id: String): Long? = if (id.length < 16) null else runCatching { id.substring(0, 16).toULong(16).toLong() }.getOrNull() + + /** The subscription a frame belongs to, when it names one. */ + private fun subIdOf(msg: Message): String? = + when (msg) { + is EventMessage -> msg.subId + is EoseMessage -> msg.subId + is ClosedMessage -> msg.subId + is CountMessage -> msg.queryId + else -> null + } + override fun onCannotConnect( relay: IRelayClient, errorMessage: String, ) { accountant.add(UsageKeys.relayConnectFails(isMobile(), isForeground()), 1) } + + companion object { + private const val TAG = "RelayUsage" + + /** NOTICE text is server-controlled; cap what reaches the log. */ + private const val MAX_NOTICE_LOG = 200 + + /** Ceiling on distinct wordings held in memory; relay prose is unbounded. */ + private const val MAX_DISTINCT_NOTICES = 200 + + /** Ceiling on remembered subscription-id purposes. See [subPurpose]. */ + private const val MAX_TRACKED_SUBS = 4_000 + + /** Recent-event-id window. See [recentEventIds]. */ + private const val MAX_TRACKED_EVENTS = 50_000 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt index eaa90e8d10..e7807eb02b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt @@ -37,8 +37,15 @@ import java.util.concurrent.atomic.AtomicLong * AtomicLong (not LongAdder) because draining must be loss-free: getAndSet(0) * hands off the accumulated value atomically, whereas remove+sum on a * LongAdder can strand a racing increment on an orphaned cell. Entries stay - * in the map after a drain — the key space is small and fixed (dims x areas), - * so this costs a few hundred boxed zeros at most. + * in the map after a drain — the key space is small and *mostly* fixed + * (dims x areas), so this costs a few hundred boxed zeros at most. + * + * The exception is the per-relay churn counters (`relay.host..*`), whose + * cardinality follows the user's relay list rather than a compile-time set: + * two keys per relay, so a few hundred more entries for a large list. Still + * negligible in memory, but it does mean neither this map nor the persisted + * store has a fixed upper bound any more. Keep that in mind before adding + * another counter keyed on runtime data. * * Counters added from inside a pre-flush hook (the CPU sampler, the segment * integrators closing an open segment) never re-arm the debounce: they are @@ -74,7 +81,10 @@ class ResourceUsageAccountant( amount: Long, ) { if (amount <= 0) return - live.computeIfAbsent(key) { AtomicLong() }.addAndGet(amount) + // Plain get first: computeIfAbsent locks the bin head when the key is present + // but not the head node, and the churn counters roughly tripled the key count + // (so collisions) on a path that runs per relay frame. + (live[key] ?: live.computeIfAbsent(key) { AtomicLong() }).addAndGet(amount) if (inHookRun.get() == true) return if (flushScheduled.compareAndSet(false, true)) { scope.launch { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index 73c4f510f3..9b289e5ee7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -29,8 +29,9 @@ import java.util.Locale * Assembles the Markdown resource-usage report the user can DM to the * developers via NIP-17 — same shape as the crash ReportAssembler: a device * header table, a human-readable summary, then the full per-day counter dump - * as the technical payload. Counters are sizes/durations/counts only; no - * URLs, relay names, or content. + * as the technical payload. Counters are sizes/durations/counts only, and never + * content. + * */ class ResourceUsageReportAssembler { fun buildReport( @@ -132,6 +133,8 @@ class ResourceUsageReportAssembler { /** Markdown table header/body separator row. */ private const val TABLE_SEPARATOR = "| --- | --- |\n" + /** Caps how many relay hosts a shared report can name. See the class doc. */ + fun formatBytes(bytes: Long): String = when { bytes >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt index 7e7824631b..c25bd7e3f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt @@ -34,7 +34,11 @@ import java.io.File * Jackson + Mutex + write-to-tmp-then-rename + version envelope. * * Day keys are UTC epoch-days (stringified for JSON). Buckets older than - * [keepDays] are pruned on every merge, so the file stays small (a few KB). + * [keepDays] are pruned on every merge, so the file stays small — a few KB, plus + * two keys per relay per day now that the churn counters are keyed on runtime + * data (see [ResourceUsageAccountant], which owns that caveat). The whole file is + * re-serialized on every flush (debounced to ~30s while traffic flows), so that + * growth is paid on each write, not just at rest. * Also carries the high-consumption alert state (last prompt time, opt-out) * so the whole feature has exactly one file. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index e43cde73a9..75271e3b7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -20,6 +20,11 @@ */ package com.vitorpamplona.amethyst.service.resourceusage +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient +import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayObserver +import java.util.concurrent.ConcurrentHashMap + /** * Counter-key grammar for the resource-usage ledger. Keys are flat strings so * the on-disk store is schema-free — adding a counter never needs a migration. @@ -29,8 +34,28 @@ package com.vitorpamplona.amethyst.service.resourceusage * - visibility: `fg` (an activity is started) vs `bg` * - direction: `rx` (downloaded) vs `tx` (uploaded) * - * Counters are sizes, durations, and counts only — never URLs, relay names, or - * content. See plans/2026-07-12-resource-usage-ledger.md. + * Counters are sizes, durations, and counts only — never content, and never a + * full URL, and never a relay name. + * See plans/2026-07-12-resource-usage-ledger.md. + * + * ## Reserved segments — read before adding a counter + * + * [sumMatching] matches by dot-segment *membership*, not by prefix, and every + * headline figure in [UsageSummary] is built from it. A new key that happens to + * contain one of these segments silently joins that sum: + * + * rx tx msg connms connects connfails reqs bursts activems + * worker runs + every value in [HTTP_ROLES] + * + * Concretely: a key named `relay.rx.event.mobile.bg` would be counted by + * `traffic(MOBILE, BG)` *in addition to* `relay.msg.mobile.bg.rx`, doubling the + * reported data usage and halving the effective threshold of the + * background-mobile-data alert. That is why the relay verb split below uses + * `up`/`down` rather than `tx`/`rx`. + * + * `ResourceUsageLedgerTest.newKeysDoNotDisturbSummary` is the regression guard: + * it asserts [UsageSummary.from] is value-identical with and without every key + * this object can produce. */ object UsageKeys { const val MOBILE = "mobile" @@ -54,6 +79,39 @@ object UsageKeys { val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH, ROLE_OTHER) + /** + * `mobile.bg` — the network x visibility pair every counter is split by. + * + * Table-backed rather than interpolated: this is evaluated on every relay frame + * and every HTTP response, and there are only four possible answers. Declared + * first because the key tables below are built from it at class-init. + */ + fun dim( + mobile: Boolean, + foreground: Boolean, + ): String = DIMS[dimIndex(mobile, foreground)] + + private val DIMS = arrayOf("$WIFI.$BG", "$WIFI.$FG", "$MOBILE.$BG", "$MOBILE.$FG") + + private fun dimIndex( + mobile: Boolean, + foreground: Boolean, + ): Int = (if (mobile) 2 else 0) or (if (foreground) 1 else 0) + + /** + * The four `.[.]` keys, indexed by [dimIndex]. + * + * Used for every `relay.*` key, because all of them are built from a relay + * callback — per frame for [relayMsg]/[relayVerb], per dial/connect/disconnect + * for the rest. The `net.*` builders below still interpolate: their key space is + * role x dim x metric and they are called once per HTTP response, so the table + * would be larger and buy less. If you add a `relay.*` counter, table it. + */ + private fun dimKeys( + prefix: String, + suffix: String? = null, + ): Array = Array(DIMS.size) { if (suffix == null) "$prefix.${DIMS[it]}" else "$prefix.${DIMS[it]}.$suffix" } + /** `net.image.mobile.bg.rx` — HTTP bytes for a subsystem. */ fun net( role: String, @@ -87,25 +145,526 @@ object UsageKeys { mobile: Boolean, foreground: Boolean, received: Boolean, - ): String = "relay.msg.${dim(mobile, foreground)}.${if (received) RX else TX}" + ): String = (if (received) RELAY_MSG_RX else RELAY_MSG_TX)[dimIndex(mobile, foreground)] + + private val RELAY_MSG_RX = dimKeys("relay.msg", RX) + private val RELAY_MSG_TX = dimKeys("relay.msg", TX) /** `relay.connms.mobile.bg` — Σ(open relay connections × elapsed ms). */ fun relayConnMs( mobile: Boolean, foreground: Boolean, - ): String = "relay.connms.${dim(mobile, foreground)}" + ): String = RELAY_CONNMS[dimIndex(mobile, foreground)] - /** `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a TCP+TLS handshake. */ + private val RELAY_CONNMS = dimKeys("relay.connms") + + /** + * `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a + * TCP+TLS handshake. + * + * Also the [relayLife] histogram's denominator — one session begins per + * `onConnected` — which is what makes the histogram's deficit measurable rather + * than assumed: + * + * connects − Σ life buckets − orphan = still open at report time + lost to process death + * + * Without that subtraction a leak, a still-open session and a session lost to a + * background kill are indistinguishable. + */ fun relayConnects( mobile: Boolean, foreground: Boolean, - ): String = "relay.connects.${dim(mobile, foreground)}" + ): String = RELAY_CONNECTS[dimIndex(mobile, foreground)] - /** `relay.connfails.mobile.bg` — dials that failed before the websocket opened. */ + private val RELAY_CONNECTS = dimKeys("relay.connects") + + /** + * `relay.connfails.mobile.bg` — every `onCannotConnect`. + * + * NOT a failed-dial count, despite the name. `BasicRelayClient.onFailure` + * raises `onCannotConnect` with no `isReady` test, so a connection that lived + * for ten minutes and then dropped increments both this and [relayConnects] + * from a single dial. Use [relayDials] for the actual number of dials. + */ fun relayConnectFails( mobile: Boolean, foreground: Boolean, - ): String = "relay.connfails.${dim(mobile, foreground)}" + ): String = RELAY_CONNFAILS[dimIndex(mobile, foreground)] + + private val RELAY_CONNFAILS = dimKeys("relay.connfails") + + /** + * `relay.dials.mobile.bg` — dial attempts, from `onConnecting`. + * + * Fires exactly once per dial, after the transport gate and the connect mutex + * and before the socket is built, so this is the honest denominator that + * [relayConnectFails] is not. + */ + fun relayDials( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_DIALS[dimIndex(mobile, foreground)] + + private val RELAY_DIALS = dimKeys("relay.dials") + + /** `relay.disc.mobile.bg` — every `onDisconnected`, whatever the cause. */ + fun relayDisconnects( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_DISC[dimIndex(mobile, foreground)] + + private val RELAY_DISC = dimKeys("relay.disc") + + /** + * `relay.subs.sent.mobile.bg` — REQ commands sent. + * + * A count to sit beside the `relay.verb.up.req` byte total: PR #3832 raised the + * number of live subscriptions per relay (background accounts now subscribe too) + * and measured refusals against nos.lol's cap of 20. Divided by [relayConnects] + * this is REQs per connection, which is what separates "we reconnect too often" + * from "each reconnect asks for too much" — different fixes. + */ + fun relaySubsSent( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_SUBS_SENT[dimIndex(mobile, foreground)] + + private val RELAY_SUBS_SENT = dimKeys("relay.subs.sent") + + /** `relay.subs.closed.mobile.bg` — CLOSE commands sent; sent minus closed is net subscription growth. */ + fun relaySubsClosed( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_SUBS_CLOSED[dimIndex(mobile, foreground)] + + private val RELAY_SUBS_CLOSED = dimKeys("relay.subs.closed") + + /** + * `relay.subs.replay.mobile.bg` — REQs sent within [REPLAY_WINDOW_MS] of that + * relay's connect, i.e. the post-connect resubscribe burst. + * + * An estimate, not an exact split. `PoolRequests.syncState` replays every desired + * filter from a coroutine launched at `onConnected`, but nothing on the listener + * side marks a frame as belonging to it, so this is a time window. It is the + * measurement behind the source report's inference that ~24 KB per connection + * "is exactly the size of a full REQ subscription replay" — which was arithmetic + * on a daily total, not an observation. + */ + fun relaySubsReplay( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_SUBS_REPLAY[dimIndex(mobile, foreground)] + + private val RELAY_SUBS_REPLAY = dimKeys("relay.subs.replay") + + /** How long after a connect a REQ still counts as part of the resubscribe burst. */ + const val REPLAY_WINDOW_MS = 2_000L + + /** + * `relay.notice.toomanysubs` — NOTICE frames by reason. + * + * Exists because a refused subscription is otherwise invisible. Per PR #3832's + * own known issue, `ERROR: too many concurrent REQs` arrives as a NOTICE, which + * carries no subscription id and so never reaches `RelayReqRefusals.onRefused` + * (wired to CLOSED only). The relay drops the REQ while the client still believes + * it is live — it never EOSEs, its `since` never advances, and `syncState` then + * re-requests its full backlog on every reconnect, forever. A non-trivial count + * here means subscription pressure is a *cause* of the download volume rather + * than a symptom of the reconnect count. + * + * The reason comes from a fixed allowlist, never from the relay's text. Relay + * prose is server-controlled and this key is persisted for 30 days; `RelayObserver` + * had to fix exactly this bug, where free-form CLOSED prose became its own tally + * key and cardinality grew with the number of distinct sentences relays wrote. + */ + fun relayNotice(reason: String): String = RELAY_NOTICE[reason] ?: RELAY_NOTICE.getValue(NOTICE_UNCLASSIFIED) + + const val NOTICE_TOO_MANY_SUBS = "toomanysubs" + const val NOTICE_RATE_LIMITED = "ratelimited" + const val NOTICE_AUTH_REQUIRED = "authrequired" + const val NOTICE_RESTRICTED = "restricted" + const val NOTICE_INVALID = "invalid" + const val NOTICE_BLOCKED = "blocked" + const val NOTICE_ERROR = "error" + const val NOTICE_UNSUPPORTED = "unsupported" + + /** The query itself was too expensive — too many kinds/steps/filters. Observed on nostr.land, relay.layer.systems. */ + const val NOTICE_QUERY_COST = "querycost" + + /** The relay refuses REQs outright. Observed on sendit.nosflare.com. */ + const val NOTICE_REQ_REFUSED = "reqrefused" + + /** Relay chatter that costs bytes but means nothing — keepalives, per-query PERF telemetry. */ + const val NOTICE_BENIGN = "benign" + + /** Deliberately not `other`: that is an [HTTP_ROLES] value and a reserved segment. */ + const val NOTICE_UNCLASSIFIED = "unclassified" + + val NOTICE_REASONS = + listOf( + NOTICE_TOO_MANY_SUBS, + NOTICE_RATE_LIMITED, + NOTICE_AUTH_REQUIRED, + NOTICE_RESTRICTED, + NOTICE_INVALID, + NOTICE_BLOCKED, + NOTICE_ERROR, + NOTICE_UNSUPPORTED, + NOTICE_QUERY_COST, + NOTICE_REQ_REFUSED, + NOTICE_BENIGN, + NOTICE_UNCLASSIFIED, + ) + + private val RELAY_NOTICE = NOTICE_REASONS.associateWith { "relay.notice.$it" } + + /** + * Classifies a NOTICE into one of [NOTICE_REASONS]. + * + * Matches on content markers rather than the NIP-01 machine-readable prefix + * alone, because the case this exists for does not have a useful one: strfry + * sends `ERROR: too many concurrent REQs`, whose prefix is just `error`. + * [RelayObserver.prefixOf] is consulted for the standard prefixes it does + * handle correctly. + */ + fun noticeReason(message: String): String { + val text = message.lowercase() + // Several relays prefix a NOTICE with the subscription id it concerns + // ("Kgo0HH: closed: too many steps"), which makes the *subscription id* the + // machine-readable prefix and hides the real one. Try the remainder too. + val prefix = RelayObserver.prefixOf(message) + val inner = RelayObserver.prefixOf(message.substringAfter(':', "")) + val prefixes = setOf(prefix, inner) + return when { + "too many" in text && ("req" in text || "subscription" in text || "concurrent" in text) -> NOTICE_TOO_MANY_SUBS + // A cost refusal is still a refusal: the REQ is dropped, so it never + // EOSEs and its `since` never advances. + "too many" in text || "too costly" in text || "too expensive" in text -> NOTICE_QUERY_COST + "does not accept" in text || "denied" in text || "not accepting" in text -> NOTICE_REQ_REFUSED + "keepalive" in text || "perf:" in text -> NOTICE_BENIGN + "rate-limited" in prefixes || ("rate" in text && "limit" in text) -> NOTICE_RATE_LIMITED + "auth-required" in prefixes || ("auth" in text && "required" in text) -> NOTICE_AUTH_REQUIRED + "restricted" in prefixes -> NOTICE_RESTRICTED + "invalid" in prefixes -> NOTICE_INVALID + "blocked" in prefixes -> NOTICE_BLOCKED + "unsupported" in prefixes -> NOTICE_UNSUPPORTED + "error" in prefixes -> NOTICE_ERROR + else -> NOTICE_UNCLASSIFIED + } + } + + /** + * The bar that decides reconnect behaviour, and so also what counts as a short + * session: below it a disconnect keeps the growing backoff, + * at or above it the backoff resets to 1s. (`NostrClient.KEEP_ALIVE_INTERVAL_MS` + * is the same 60s, but it is private, so this reads the one that is public.) + * + * Derived rather than copied: the plan retunes `STABLE_CONNECTION_IN_SECS` once + * the histogram is read, and a hand-written 60_000 here would silently stop + * meaning "session that kept the backoff growing" at that point. + * `UsageKeyHelpersTest.lifeBucketsAreHalfOpen` asserts the resulting bucket + * labels, so a retune surfaces as a test failure rather than as a histogram that + * quietly answers the wrong question. + */ + const val SHORT_SESSION_MS = BasicRelayClient.STABLE_CONNECTION_IN_SECS * 1_000L + + /** + * Half-open upper bounds, in ms, for the [relayLife] histogram. Deliberately + * straddles [SHORT_SESSION_MS]: a mean cannot tell a tight cluster sitting on + * that bar from a bimodal mix; this can. + */ + private val LIFE_BUCKET_BOUNDS_MS = + longArrayOf(5_000, 30_000, SHORT_SESSION_MS, 120_000, 300_000).also { + // [lifeBucketIndex] linear-scans for the first bound greater than the + // elapsed time, so the bounds must ascend. One of them is derived from + // BasicRelayClient.STABLE_CONNECTION_IN_SECS, and raising that to five + // minutes — exactly the retune commit 2 of the churn plan contemplates — + // would push it past the two bounds after it. The buckets between would + // become unreachable and the labels would start lying. Fail at class-init + // with the reason rather than as a puzzling boundary-test failure. + require(it.asList() == it.sorted()) { + "relay.life bounds must ascend, got ${it.toList()}. " + + "SHORT_SESSION_MS is ${SHORT_SESSION_MS}ms — reorder the bounds to match." + } + } + + /** Derived from the bounds so a bound change can never leave a label lying about it. */ + private val LIFE_BUCKET_NAMES = + Array(LIFE_BUCKET_BOUNDS_MS.size + 1) { i -> + if (i < LIFE_BUCKET_BOUNDS_MS.size) { + "lt${LIFE_BUCKET_BOUNDS_MS[i] / 1000}s" + } else { + "gte${LIFE_BUCKET_BOUNDS_MS.last() / 1000}s" + } + } + + private fun lifeBucketIndex(elapsedMs: Long): Int { + for (i in LIFE_BUCKET_BOUNDS_MS.indices) { + if (elapsedMs < LIFE_BUCKET_BOUNDS_MS[i]) return i + } + return LIFE_BUCKET_BOUNDS_MS.size + } + + fun lifeBucket(elapsedMs: Long): String = LIFE_BUCKET_NAMES[lifeBucketIndex(elapsedMs)] + + /** + * `relay.life.lt60s.mobile.bg` — connections that closed after living this long. + * [relayConnects] is the denominator; see its doc for the deficit equation. + */ + fun relayLife( + elapsedMs: Long, + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_LIFE[lifeBucketIndex(elapsedMs)][dimIndex(mobile, foreground)] + + private val RELAY_LIFE = Array(LIFE_BUCKET_NAMES.size) { dimKeys("relay.life.${LIFE_BUCKET_NAMES[it]}") } + + /** + * `relay.life.overwrite` — a connect arrived for a relay that already had an + * unconsumed start stamp. + * + * Expected during a pool teardown: `NostrClient` runs `disconnect()` then + * `connect()` synchronously, so a stale failure callback for the old socket + * can land after the new socket is already open. The new stamp overwrites the + * old, and the stale disconnect then consumes the new one — booking a + * near-zero lifetime for a session that never ended. Bias runs toward `lt5s`, + * so read this before reading the histogram's short buckets. + */ + const val RELAY_LIFE_OVERWRITE = "relay.life.overwrite" + + /** `relay.life.orphan` — a disconnect with no matching start stamp. */ + const val RELAY_LIFE_ORPHAN = "relay.life.orphan" + + /** + * `relay.verb.up.req.mobile.bg` / `relay.verb.down.eose.mobile.bg` — the + * [relayMsg] bytes, split by wire verb. Parameterised on direction for the same + * reason [relayMsg] is: one memo strategy, not two. + * + * `verb` is the command's own `label()` (`REQ`, `EVENT`, ...) lowercased, so a + * new `Command`/`Message` subtype maps itself and nothing can land in a + * catch-all bucket unnoticed. Deliberately `up`/`down` rather than `tx`/`rx` — + * see the reserved-segment note above. + * + * Keys are memoized because this is on the per-frame path: the verb x dim space + * is a handful of entries, so steady state is a map lookup and an array index + * with no string building at all. + */ + fun relayVerb( + verb: String, + received: Boolean, + mobile: Boolean, + foreground: Boolean, + ): String = + (if (received) VERB_DOWN_KEYS else VERB_UP_KEYS) + .getOrPut(verb) { dimKeys("relay.verb.${if (received) DOWN else UP}.${verb.lowercase()}") }[dimIndex(mobile, foreground)] + + private const val UP = "up" + private const val DOWN = "down" + + private val VERB_UP_KEYS = ConcurrentHashMap>() + private val VERB_DOWN_KEYS = ConcurrentHashMap>() + + /** + * `relay.purpose.home_feed.sent` / `.bytes` — REQ frames and REQ bytes by the + * [SubPurpose] that asked for them. + * + * The counter that turns "REQ traffic is 64 % of upload" into an actionable + * name. Purpose travels on the filter itself (PR #3832's `ExplainedFilter`, + * which survives the `copy(since = …)` assemblers do after every EOSE), so this + * is a read, not a new registry. + * + * Cardinality is the enum, so it is bounded and stable. [PURPOSE_UNEXPLAINED] is + * its own bucket rather than folded into the enum's OTHER: a filter carrying no + * purpose at all means an assembler #3832 did not reach, which is a different + * fact from one that declared itself uncategorised — and if that bucket is large, + * the attribution below cannot be trusted. + */ + fun relayPurposeSent(purpose: String): String = "relay.purpose.$purpose.sent" + + fun relayPurposeBytes(purpose: String): String = "relay.purpose.$purpose.bytes" + + /** + * `relay.purpose.moderation.down` — bytes received on subscriptions opened for + * that purpose, resolved through the subscription id the frame carries. + * + * The upload counters answer "who is asking"; this answers "who is being + * answered", which is the larger number: inbound EVENT payload is ~74 % of relay + * traffic against ~26 % outbound. Without it, a fix to the REQ churn can only be + * credited with the upload it removes, when the interesting question is how much + * of the download it was causing — every re-subscription can make the relay + * re-send everything that matches. + */ + fun relayPurposeDown(purpose: String): String = "relay.purpose.$purpose.down" + + /** + * `relay.purpose.home_feed.downn` — inbound frames, alongside the bytes. + * + * Bytes alone cannot separate "many small events delivered repeatedly" from "few + * large ones", and those want opposite fixes. With a count, `down / downn` is the + * average frame size per purpose, and the total frame count set against + * `crypto.verify.count` — which the cache pays once per event it accepts — bounds + * how much of the download is the same events arriving from different relays + * under the outbox fan-out. + */ + fun relayPurposeDownCount(purpose: String): String = "relay.purpose.$purpose.downn" + + /** + * `relay.purpose.user_profile.dupbytes` — of that purpose's inbound bytes, how + * many carried an event already delivered. + * + * [relayEventsDupBytes] measures duplication across the whole client, which says + * how much is wasted but not where. The seventh reading needs exactly this split: + * `user_profile` was 57 % of download at ~17 KB per event, and whether that is + * mostly the same events arriving from many relays or mostly distinct large ones + * points at completely different fixes — suppress redundant delivery, or stop + * fetching the large thing per relay. + */ + fun relayPurposeDupBytes(purpose: String): String = "relay.purpose.$purpose.dupbytes" + + /** A frame whose subscription id we never saw opened — counters wiped mid-session, or a sub from before this connection. */ + const val PURPOSE_UNATTRIBUTED = "unattributed" + + /** A REQ whose filters carry no [ExplainedFilter] purpose. */ + const val PURPOSE_UNEXPLAINED = "unexplained" + + /** The enum's own OTHER, renamed: bare `other` is an [HTTP_ROLES] value and a reserved segment. */ + const val PURPOSE_OTHER = "otherpurpose" + + /** + * The key segment for a [SubPurpose]. The one place the enum is turned into a + * key, so the reserved-segment rename cannot drift between the producer and the + * test that guards it — which is exactly how it drifted the first time. + */ + fun purposeKeyPart(purpose: SubPurpose): String = if (purpose == SubPurpose.OTHER) PURPOSE_OTHER else purpose.name.lowercase() + + /** + * `relay.subs.resent.mobile.bg` — a REQ for a subscription id this relay already + * has open on the current connection. + * + * The distinction the churn question turns on. A REQ that opens a new + * subscription is work; a REQ that replaces one already in flight is the client + * changing its mind, and at ~1 KB each that is pure cost. Measured against + * [relaySubsSent] it says what fraction of the upload is re-subscription rather + * than subscription. + */ + fun relaySubsResent( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_SUBS_RESENT[dimIndex(mobile, foreground)] + + private val RELAY_SUBS_RESENT = dimKeys("relay.subs.resent") + + /** + * Half-open bounds, in ms, for the two connect-timing histograms below. + */ + private val CONNECT_BUCKET_BOUNDS_MS = longArrayOf(100, 500, 2_000, 10_000, 30_000) + private val CONNECT_BUCKET_NAMES = + Array(CONNECT_BUCKET_BOUNDS_MS.size + 1) { i -> + if (i < CONNECT_BUCKET_BOUNDS_MS.size) "lt${CONNECT_BUCKET_BOUNDS_MS[i]}ms" else "gte${CONNECT_BUCKET_BOUNDS_MS.last()}ms" + } + + private fun connectBucketIndex(ms: Long): Int { + for (i in CONNECT_BUCKET_BOUNDS_MS.indices) { + if (ms < CONNECT_BUCKET_BOUNDS_MS[i]) return i + } + return CONNECT_BUCKET_BOUNDS_MS.size + } + + fun connectBucket(ms: Long): String = CONNECT_BUCKET_NAMES[connectBucketIndex(ms)] + + /** + * `relay.hs.lt500ms.wifi.fg` — the websocket upgrade round-trip, as the + * transport measured it. + * + * This is `onConnected`'s `pingMillis`, which `BasicOkHttpWebSocket` computes as + * `receivedResponseAtMillis - sentRequestAtMillis` and which this listener + * previously discarded. PR #3843 is the cautionary tale: `RelayObserver` derived + * the same quantity from `onConnecting -> onConnected` instead, and on a large + * fan-out published a 33.5 s median that was the client's own backlog rather than + * relay latency. Those timestamps bracket the request itself, so everything + * before it — queueing, DNS, TCP, TLS — is excluded. Zero or negative means the + * transport could not time it, and nothing is recorded rather than a fabricated 0. + */ + fun relayHandshake( + ms: Long, + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_HS[connectBucketIndex(ms)][dimIndex(mobile, foreground)] + + private val RELAY_HS = Array(CONNECT_BUCKET_NAMES.size) { dimKeys("relay.hs.${CONNECT_BUCKET_NAMES[it]}") } + + /** + * `relay.gap.lt2000ms.wifi.fg` — everything between deciding to dial and the + * upgrade request going out: dispatcher queueing, DNS, TCP, TLS. + * + * `(onConnected wall clock - onConnecting wall clock) - handshake`. This is the + * share of connect latency the app is responsible for rather than the relay, and + * it is what decides whether a high never-became-ready rate is relays being + * unreachable or ~500 simultaneous dials saturating name resolution and sockets. + * The dispatcher's own cap is not the constraint on a phone + * (`maxRequests = 1024`, `maxRequestsPerHost = 10`), so a large value here points + * at resolution and socket setup, not at a queue. + */ + fun relayDialGap( + ms: Long, + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_GAP[connectBucketIndex(ms)][dimIndex(mobile, foreground)] + + private val RELAY_GAP = Array(CONNECT_BUCKET_NAMES.size) { dimKeys("relay.gap.${CONNECT_BUCKET_NAMES[it]}") } + + /** + * `relay.events.seen.wifi.fg` — inbound EVENT frames, and of those, how many + * carried an event id already delivered recently. + * + * The outbox model asks many relays for the same authors, so one event is + * delivered once per relay that carries it. Relay download is ~65 % of all data + * on the release build, so the duplication factor decides whether the largest + * number in the ledger is content or repetition — a question no other counter + * here can answer, and one [VERIFY_COUNT] only proxies (it counts what the cache + * accepted, not what arrived, and only while dedup-before-verify holds). + * + * [relayEventsDupBytes] is the number that matters: bytes that arrived and were + * already held. + */ + fun relayEventsSeen( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_EV_SEEN[dimIndex(mobile, foreground)] + + fun relayEventsDup( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_EV_DUP[dimIndex(mobile, foreground)] + + fun relayEventsDupBytes( + mobile: Boolean, + foreground: Boolean, + ): String = RELAY_EV_DUPB[dimIndex(mobile, foreground)] + + private val RELAY_EV_SEEN = dimKeys("relay.events.seen") + private val RELAY_EV_DUP = dimKeys("relay.events.dup") + private val RELAY_EV_DUPB = dimKeys("relay.events.dupbytes") + + /** + * `relay.trigger.netid` — reconnect *decisions*, by cause, not reconnects + * performed. + * + * Two reasons this is an upper bound, both of which matter when reading it: + * `NostrClient.reconnect` emits into a debounced flow that `subscribe` / + * `count` / `publish` / `onDisconnected` also feed very frequently, so a + * teardown can be coalesced away before it runs; and the flow's initial value + * fires one teardown per client construction with no trigger attributed. + */ + fun relayTrigger(cause: String): String = "relay.trigger.$cause" + + const val TRIGGER_NETID = "netid" + const val TRIGGER_TRANSPORT = "transport" + const val TRIGGER_TOR_POLICY = "torpolicy" + const val TRIGGER_CLASSIFICATION = "class" + const val TRIGGER_COLD_START = "coldstart" + const val TRIGGER_OFF = "off" + const val TRIGGER_BUZZ = "buzz" /** `worker.scheduledPost.runs` */ fun workerRuns(worker: String): String = "worker.$worker.runs" @@ -187,18 +746,35 @@ object UsageKeys { const val BATTERY_DRAIN_FG = "battery.drain.fg" const val BATTERY_DRAIN_BG = "battery.drain.bg" - fun dim( - mobile: Boolean, - foreground: Boolean, - ): String = "${if (mobile) MOBILE else WIFI}.${if (foreground) FG else BG}" - - /** Sums every counter whose key matches all the given dot-delimited parts. */ + /** + * Sums every counter whose key matches all the given dot-delimited parts. + * + * Scans segments in place rather than `key.split('.')`: [UsageSummary.from] makes + * ~54 of these passes over the whole day bucket, the usage screen builds 16 + * summaries per entry on the main thread, and the churn counters roughly tripled + * the key count — none of which can ever match (that is what + * `noNewKeyContainsAReservedSegment` guarantees), so every split was pure waste. + */ fun Map.sumMatching(vararg parts: String): Long { var total = 0L - for ((key, value) in this) { - val segments = key.split('.') - if (parts.all { it in segments }) total += value + outer@ for ((key, value) in this) { + for (part in parts) { + if (!key.hasSegment(part)) continue@outer + } + total += value } return total } + + /** True when `segment` is one of this key's whole dot-delimited segments. */ + private fun String.hasSegment(segment: String): Boolean { + var from = 0 + while (from <= length) { + var end = indexOf('.', from) + if (end < 0) end = length + if (end - from == segment.length && regionMatches(from, segment, 0, segment.length)) return true + from = end + 1 + } + return false + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt index 98d19c7937..2433459063 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt @@ -109,9 +109,7 @@ class AgentConsoleViewModel : ViewModel() { relay?.let { val newlyJoined = BuzzWorkspaces.join(it) viewModelScope.launch { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) } - // A join makes the relay first-party; if the socket was already open its one-shot AUTH - // challenge was spent unauthenticated, so reconnect to re-challenge and authenticate. - if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) + if (newlyJoined) reconnectPoolAfterJoin(account.client) } refresh() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index a619471e3f..dccf0cb851 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -130,9 +130,7 @@ class BuzzDmListViewModel : ViewModel() { val newlyJoined = BuzzWorkspaces.join(relay) viewModelScope.launch { account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) } - // A join makes the relay first-party; if the socket was already open its one-shot AUTH - // challenge was spent unauthenticated, so reconnect to re-challenge and authenticate. - if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) + if (newlyJoined) reconnectPoolAfterJoin(account.client) // Paint from cache BEFORE any network work. [discoverMemberChannels] learns the channel ids // from a relay round-trip, so waiting on it left the Direct Messages section visibly empty diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzJoinReconnect.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzJoinReconnect.kt new file mode 100644 index 0000000000..9d419d535b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzJoinReconnect.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +/** + * Re-dials the whole relay pool after a Buzz workspace join. + * + * NIP-42 sends its AUTH challenge once, on connect. If the socket was already open + * before the join (the common case — the relay is in the user's lists and connected + * at startup), that challenge was spent while the relay was still NOT first-party, + * so the connection is unauthenticated and every `#p=me`-gated read on it is + * refused. Joining makes the relay first-party (see `AuthCoordinator.isFirstParty`); + * this forces the relay to re-challenge so the connection authenticates. + * + * Shared by the three join sites that need the re-challenge, both to keep the + * reconnect flags identical and to give the churn ledger one place to attribute from: + * without the counter, `Σ(relay.trigger.*)` would only account for the + * connectivity-driven teardowns `RelayProxyClientConnector` reports, and a full pool + * teardown is the most expensive thing either can do. + * + * `BuzzInviteScreen` is a fourth join+pre-approve site that deliberately does NOT + * reconnect — it hands off to the in-app browser rather than reading a `#p=me`-gated + * subscription — so `relay.trigger.buzz` undercounts joins, not re-challenges. + */ +internal fun reconnectPoolAfterJoin(client: INostrClient) { + // Guarded like every other ledger write that reaches the application singleton + // (MediaPlayTimeTracker, the workers): a diagnostics counter must never break a + // user-visible join, and `Amethyst.instance` is lateinit. + runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.relayTrigger(UsageKeys.TRIGGER_BUZZ), 1) } + client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt index 9cded7263f..a3976d203f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt @@ -107,14 +107,8 @@ class BuzzRelayImportViewModel : ViewModel() { val newlyJoined = BuzzWorkspaces.join(normalized) viewModelScope.launch { account.relayAuthLedger.setDecision(normalized.url, RelayAuthDecision.ALLOW) } - // NIP-42 sends its AUTH challenge once, on connect. If the socket was already open before this - // join (the common case — the relay is in the user's lists and connected at startup), that - // challenge was spent while the relay was still NOT first-party, so the connection is - // unauthenticated and the persistent group-roster (39002) subscription is refused. Joining - // makes the relay first-party (see AuthCoordinator.isFirstParty); force a reconnect so the - // relay re-challenges and the connection authenticates — unlocking the roster (Join gate) and - // every other `#p=me`-gated read on the shared socket. - if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) + // Unlocks the persistent group-roster (39002) subscription — see [reconnectPoolAfterJoin]. + if (newlyJoined) reconnectPoolAfterJoin(account.client) // Track "already added" against the live kind-10009 list, scoped to this relay, so the rows // follow every add/remove — from here, from the channel's top bar, or from another device. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index ac4c066788..c42b3bdada 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -20,12 +20,33 @@ */ package com.vitorpamplona.amethyst.service.resourceusage +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.service.playback.playerPool.MediaPlayTimeTracker import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.LimitsMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import io.mockk.every +import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -778,3 +799,756 @@ class ResourceUsageAlertsTest { assertFalse(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = 0, optOut = true, nowSec = now)) } } + +/** + * The guard that keeps the counter-key grammar honest — see the reserved-segment + * note on [UsageKeys] for the mechanism and what it would cost to get wrong. + */ +class UsageKeyGrammarTest { + /** Every diagnostic key shape, with values that would be obvious if they leaked into a sum. */ + private fun churnKeys(): Map { + val out = mutableMapOf() + var n = 1_000_000L + for (mobile in listOf(true, false)) { + for (fg in listOf(true, false)) { + out[UsageKeys.relayDials(mobile, fg)] = n++ + out[UsageKeys.relayDisconnects(mobile, fg)] = n++ + for (ms in listOf(1L, 10_000L, 45_000L, 90_000L, 200_000L, 900_000L)) { + out[UsageKeys.relayLife(ms, mobile, fg)] = n++ + } + for (verb in CMD_LABELS) { + out[UsageKeys.relayVerb(verb, received = false, mobile, fg)] = n++ + } + for (verb in MSG_LABELS) { + out[UsageKeys.relayVerb(verb, received = true, mobile, fg)] = n++ + } + } + } + out[UsageKeys.RELAY_LIFE_OVERWRITE] = n++ + out[UsageKeys.RELAY_LIFE_ORPHAN] = n++ + for (cause in listOf( + UsageKeys.TRIGGER_NETID, + UsageKeys.TRIGGER_TRANSPORT, + UsageKeys.TRIGGER_TOR_POLICY, + UsageKeys.TRIGGER_CLASSIFICATION, + UsageKeys.TRIGGER_COLD_START, + UsageKeys.TRIGGER_OFF, + UsageKeys.TRIGGER_BUZZ, + )) { + out[UsageKeys.relayTrigger(cause)] = n++ + } + for (mobile in listOf(true, false)) { + for (fg in listOf(true, false)) { + out[UsageKeys.relaySubsSent(mobile, fg)] = n++ + out[UsageKeys.relaySubsClosed(mobile, fg)] = n++ + out[UsageKeys.relaySubsReplay(mobile, fg)] = n++ + } + } + UsageKeys.NOTICE_REASONS.forEach { out[UsageKeys.relayNotice(it)] = n++ } + for (mobile in listOf(true, false)) { + for (fg in listOf(true, false)) { + out[UsageKeys.relaySubsResent(mobile, fg)] = n++ + out[UsageKeys.relayEventsSeen(mobile, fg)] = n++ + out[UsageKeys.relayEventsDup(mobile, fg)] = n++ + out[UsageKeys.relayEventsDupBytes(mobile, fg)] = n++ + for (ms in listOf(0L, 200L, 1_000L, 5_000L, 20_000L, 60_000L)) { + out[UsageKeys.relayHandshake(ms, mobile, fg)] = n++ + out[UsageKeys.relayDialGap(ms, mobile, fg)] = n++ + } + } + } + (SubPurpose.entries.map { UsageKeys.purposeKeyPart(it) } + UsageKeys.PURPOSE_UNEXPLAINED + UsageKeys.PURPOSE_UNATTRIBUTED).forEach { + out[UsageKeys.relayPurposeSent(it)] = n++ + out[UsageKeys.relayPurposeBytes(it)] = n++ + out[UsageKeys.relayPurposeDown(it)] = n++ + out[UsageKeys.relayPurposeDownCount(it)] = n++ + out[UsageKeys.relayPurposeDupBytes(it)] = n++ + } + return out + } + + /** A baseline of the pre-existing counters the summary is actually built from. */ + private fun baseline(): Map = + mapOf( + UsageKeys.relayMsg(mobile = true, foreground = false, received = true) to 500L, + UsageKeys.relayMsg(mobile = true, foreground = false, received = false) to 60L, + UsageKeys.relayMsg(mobile = false, foreground = true, received = true) to 900L, + UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true, received = true) to 70L, + UsageKeys.netReqs(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true) to 3L, + UsageKeys.netActiveMs(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true) to 40L, + UsageKeys.radioBursts(mobile = true, foreground = true) to 2L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 1_234L, + UsageKeys.relayConnects(mobile = true, foreground = false) to 11L, + UsageKeys.relayConnectFails(mobile = true, foreground = false) to 22L, + UsageKeys.workerRuns("calendarReminder") to 1L, + ) + + @Test + fun newKeysDoNotDisturbSummary() { + val before = UsageSummary.from(baseline()) + val after = UsageSummary.from(baseline() + churnKeys()) + assertEquals(before, after) + } + + @Test + fun noNewKeyContainsAReservedSegment() { + val reserved = + setOf( + UsageKeys.RX, + UsageKeys.TX, + "msg", + "connms", + "connects", + "connfails", + "reqs", + "bursts", + "activems", + "worker", + "runs", + ) + UsageKeys.HTTP_ROLES + + churnKeys().keys.forEach { key -> + val clash = key.split('.').filter { it in reserved } + assertTrue("Key '$key' uses reserved segment(s) $clash", clash.isEmpty()) + } + } + + companion object { + /** + * The verb segments taken straight from quartz's own wire labels — the same + * source [RelayUsageListener] reads, so a new subtype cannot be tested against + * a stale hand-written list. + */ + val CMD_LABELS = listOf(ReqCmd.LABEL, EventCmd.LABEL, AuthCmd.LABEL, CloseCmd.LABEL, CountCmd.LABEL) + val MSG_LABELS = + listOf( + EventMessage.LABEL, + EoseMessage.LABEL, + OkMessage.LABEL, + NoticeMessage.LABEL, + AuthMessage.LABEL, + ClosedMessage.LABEL, + CountMessage.LABEL, + NotifyMessage.LABEL, + LimitsMessage.LABEL, + ) + } +} + +class UsageKeyHelpersTest { + @Test + fun lifeBucketsAreHalfOpen() { + assertEquals("lt5s", UsageKeys.lifeBucket(0)) + assertEquals("lt5s", UsageKeys.lifeBucket(4_999)) + assertEquals("lt30s", UsageKeys.lifeBucket(5_000)) + assertEquals("lt60s", UsageKeys.lifeBucket(59_999)) + // The one that matters: exactly the stability bar is NOT "under a minute". + assertEquals("lt120s", UsageKeys.lifeBucket(60_000)) + assertEquals("lt300s", UsageKeys.lifeBucket(299_999)) + assertEquals("gte300s", UsageKeys.lifeBucket(300_000)) + assertEquals("gte300s", UsageKeys.lifeBucket(Long.MAX_VALUE)) + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class RelayUsageListenerTest { + @get:Rule val tmp = TemporaryFolder() + + private var now = 0L + + private fun relay(url: String): IRelayClient { + val r = mockk(relaxed = true) + every { r.url } returns NormalizedRelayUrl(url) + return r + } + + private fun runLedger(block: suspend (ResourceUsageAccountant, RelayUsageListener) -> Unit) = + runTest { + val store = ResourceUsageStore(File(tmp.newFolder(), "usage.json")) + // backgroundScope, as everywhere else in this file: the accountant's + // debounced flush is auto-cancelled with the test instead of leaking a + // pending 30s delay into the test body. + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + val l = + RelayUsageListener( + accountant = accountant, + isMobile = { false }, + isForeground = { true }, + nowMs = { now }, + ) + block(accountant, l) + } + + private suspend fun counters(accountant: ResourceUsageAccountant): Map = accountant.allDaysIncludingLive()[1L].orEmpty() + + @Test + fun bucketsASessionByHowLongItLived() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + now = 1_000 + l.onConnected(r, 10, false) + now = 1_000 + 45_000 + l.onDisconnected(r) + + val c = counters(accountant) + assertEquals(1L, c[UsageKeys.relayLife(45_000, mobile = false, foreground = true)]) + assertEquals(1L, c[UsageKeys.relayConnects(mobile = false, foreground = true)]) + assertEquals(1L, c[UsageKeys.relayDisconnects(mobile = false, foreground = true)]) + assertNull(c[UsageKeys.RELAY_LIFE_ORPHAN]) + assertNull(c[UsageKeys.RELAY_LIFE_OVERWRITE]) + } + + @Test + fun aDialThatNeverConnectedProducesNoLifetime() = + runLedger { accountant, l -> + val r = relay("wss://nos.lol/") + l.onConnecting(r) + l.onCannotConnect(r, "WebSocket Failure: timeout (SocketTimeoutException)") + l.onDisconnected(r) + + val c = counters(accountant) + assertEquals(1L, c[UsageKeys.relayDials(mobile = false, foreground = true)]) + assertEquals(1L, c[UsageKeys.relayConnectFails(mobile = false, foreground = true)]) + // No start stamp to consume: counted as an orphan rather than a 0ms session, + // which would otherwise pile into lt5s and fake "instant failures". + assertEquals(1L, c[UsageKeys.RELAY_LIFE_ORPHAN]) + assertNull(c[UsageKeys.relayLife(0, mobile = false, foreground = true)]) + assertNull(c[UsageKeys.relayConnects(mobile = false, foreground = true)]) + } + + @Test + fun aSecondConnectBeforeDisconnectIsCountedAsAnOverwrite() = + runLedger { accountant, l -> + // The teardown race: disconnect(); connect() runs synchronously, so a stale + // failure callback for the old socket can land after the new one is open. + val r = relay("wss://relay.damus.io/") + now = 0 + l.onConnected(r, 10, false) + now = 500_000 + l.onConnected(r, 10, false) + now = 500_100 + l.onDisconnected(r) + + val c = counters(accountant) + assertEquals(1L, c[UsageKeys.RELAY_LIFE_OVERWRITE]) + assertEquals(2L, c[UsageKeys.relayConnects(mobile = false, foreground = true)]) + // The long session was lost; only the short one is recorded. `connects` is the + // denominator that makes that deficit visible instead of silent. + assertEquals(1L, c[UsageKeys.relayLife(100, mobile = false, foreground = true)]) + assertNull(c[UsageKeys.relayLife(500_000, mobile = false, foreground = true)]) + } + + @Test + fun twoRelaysDoNotShareASlot() = + runLedger { accountant, l -> + val a = relay("wss://relay.damus.io/") + val b = relay("wss://nos.lol/") + now = 0 + l.onConnected(a, 10, false) + l.onConnected(b, 10, false) + now = 90_000 + l.onDisconnected(a) + now = 200_000 + l.onDisconnected(b) + + val c = counters(accountant) + assertEquals(1L, c[UsageKeys.relayLife(90_000, mobile = false, foreground = true)]) + assertEquals(1L, c[UsageKeys.relayLife(200_000, mobile = false, foreground = true)]) + assertNull(c[UsageKeys.RELAY_LIFE_OVERWRITE]) + } + + @Test + fun sentVerbSplitSumsBackToTheByteTotal() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + val req = ReqCmd("sub1", listOf()) + val event = EventCmd(mockk(relaxed = true)) + l.onSent(r, "0123456789", req, success = true) + l.onSent(r, "01234", event, success = true) + // A failed send is not counted at all, matching relay.msg.*.tx. + l.onSent(r, "0123456789012345", req, success = false) + + val c = counters(accountant) + val total = c[UsageKeys.relayMsg(mobile = false, foreground = true, received = false)] + val split = + c.filterKeys { it.startsWith("relay.verb.up.") }.values.sum() + assertEquals(15L, total) + assertEquals(total, split) + assertEquals(10L, c[UsageKeys.relayVerb(ReqCmd.LABEL, received = false, mobile = false, foreground = true)]) + assertEquals(5L, c[UsageKeys.relayVerb(EventCmd.LABEL, received = false, mobile = false, foreground = true)]) + // The label is uppercase on the wire; the key segment is not. + assertEquals("relay.verb.up.req.wifi.fg", UsageKeys.relayVerb(ReqCmd.LABEL, received = false, mobile = false, foreground = true)) + } + + @Test + fun receivedVerbSplitSumsBackToTheByteTotal() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + l.onIncomingMessage(r, "0123456789", EoseMessage("sub1")) + l.onIncomingMessage(r, "012", NoticeMessage("hi")) + + val c = counters(accountant) + val total = c[UsageKeys.relayMsg(mobile = false, foreground = true, received = true)] + val split = c.filterKeys { it.startsWith("relay.verb.down.") }.values.sum() + assertEquals(13L, total) + assertEquals(total, split) + } + + @Test + fun reqsInsideTheConnectWindowCountAsReplay() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + now = 10_000 + l.onConnected(r, 10, false) + // syncState's burst: sent immediately after the socket is ready. + l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf()), success = true) + l.onSent(r, "[\"REQ\"]", ReqCmd("sub2", listOf()), success = true) + // Well past the window — a user opening a screen, not a replay. + now = 10_000 + UsageKeys.REPLAY_WINDOW_MS + 1 + l.onSent(r, "[\"REQ\"]", ReqCmd("sub3", listOf()), success = true) + l.onSent(r, "[\"CLOSE\"]", CloseCmd("sub1"), success = true) + + val c = counters(accountant) + assertEquals(3L, c[UsageKeys.relaySubsSent(mobile = false, foreground = true)]) + assertEquals(2L, c[UsageKeys.relaySubsReplay(mobile = false, foreground = true)]) + assertEquals(1L, c[UsageKeys.relaySubsClosed(mobile = false, foreground = true)]) + } + + @Test + fun aReqOnANeverConnectedRelayIsNotReplay() = + runLedger { accountant, l -> + // No onConnected, so no start stamp: must not be attributed to a burst. + val r = relay("wss://nos.lol/") + l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf()), success = true) + + val c = counters(accountant) + assertEquals(1L, c[UsageKeys.relaySubsSent(mobile = false, foreground = true)]) + assertNull(c[UsageKeys.relaySubsReplay(mobile = false, foreground = true)]) + } + + @Test + fun aFailedSendCountsNowhere() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf()), success = false) + + val c = counters(accountant) + assertNull(c[UsageKeys.relaySubsSent(mobile = false, foreground = true)]) + } + + @Test + fun aRefusalNoticeIsCountedByReason() = + runLedger { accountant, l -> + val r = relay("wss://nos.lol/") + l.onIncomingMessage(r, "[\"NOTICE\",\"x\"]", NoticeMessage("ERROR: too many concurrent REQs")) + l.onIncomingMessage(r, "[\"NOTICE\",\"y\"]", NoticeMessage("hello")) + + val c = counters(accountant) + assertEquals(1L, c[UsageKeys.relayNotice(UsageKeys.NOTICE_TOO_MANY_SUBS)]) + assertEquals(1L, c[UsageKeys.relayNotice(UsageKeys.NOTICE_UNCLASSIFIED)]) + // Still part of the byte total, so the verb invariant is unaffected. + assertEquals( + c[UsageKeys.relayMsg(mobile = false, foreground = true, received = true)], + c.filterKeys { it.startsWith("relay.verb.down.") }.values.sum(), + ) + } + + @Test + fun aReqForAnAlreadyOpenSubscriptionCountsAsAResend() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + l.onConnected(r, 10, false) + l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf(Filter())), success = true) + // Same subId, still open: the assembler changed its mind. + l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf(Filter())), success = true) + l.onSent(r, "[\"REQ\"]", ReqCmd("sub2", listOf(Filter())), success = true) + + val c = counters(accountant) + assertEquals(3L, c[UsageKeys.relaySubsSent(mobile = false, foreground = true)]) + assertEquals(1L, c[UsageKeys.relaySubsResent(mobile = false, foreground = true)]) + } + + @Test + fun aClosedSubscriptionCanBeReopenedWithoutCountingAsAResend() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + l.onConnected(r, 10, false) + l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf(Filter())), success = true) + l.onSent(r, "[\"CLOSE\"]", CloseCmd("sub1"), success = true) + l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf(Filter())), success = true) + + assertNull(counters(accountant)[UsageKeys.relaySubsResent(mobile = false, foreground = true)]) + } + + @Test + fun aReconnectForgetsOpenSubscriptions() = + runLedger { accountant, l -> + // The relay forgets them too, so the post-reconnect replay is legitimately + // new work and must not be booked as the client changing its mind. + val r = relay("wss://relay.damus.io/") + l.onConnected(r, 10, false) + l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf(Filter())), success = true) + l.onDisconnected(r) + l.onConnected(r, 10, false) + l.onSent(r, "[\"REQ\"]", ReqCmd("sub1", listOf(Filter())), success = true) + + assertNull(counters(accountant)[UsageKeys.relaySubsResent(mobile = false, foreground = true)]) + } + + @Test + fun reqsAreAttributedToTheirSubscriptionPurpose() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + l.onConnected(r, 10, false) + l.onSent(r, "0123456789", ReqCmd("a", listOf(ExplainedFilter(purpose = SubPurpose.HOME_FEED))), success = true) + l.onSent(r, "01234", ReqCmd("b", listOf(ExplainedFilter(purpose = SubPurpose.OTHER))), success = true) + // An assembler #3832 never tagged: its own bucket, not a guess. + l.onSent(r, "012", ReqCmd("c", listOf(Filter())), success = true) + + val c = counters(accountant) + assertEquals(1L, c[UsageKeys.relayPurposeSent("home_feed")]) + assertEquals(10L, c[UsageKeys.relayPurposeBytes("home_feed")]) + assertEquals(1L, c[UsageKeys.relayPurposeSent(UsageKeys.PURPOSE_OTHER)]) + assertEquals(1L, c[UsageKeys.relayPurposeSent(UsageKeys.PURPOSE_UNEXPLAINED)]) + // Purpose bytes reconcile with the REQ verb total. + assertEquals( + c[UsageKeys.relayVerb("REQ", received = false, mobile = false, foreground = true)], + c.filterKeys { it.startsWith("relay.purpose.") && it.endsWith(".bytes") }.values.sum(), + ) + } + + @Test + fun handshakeAndDialGapSeparateTheRelayFromOurselves() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + now = 0 + l.onConnecting(r) + // 3s of wall clock to get connected, of which the transport says the + // upgrade round trip was 150ms — the other 2850ms is DNS/TCP/TLS/queueing. + now = 3_000 + l.onConnected(r, pingMillis = 150, compressed = false) + + val c = counters(accountant) + assertEquals(1L, c[UsageKeys.relayHandshake(150, mobile = false, foreground = true)]) + assertEquals(1L, c[UsageKeys.relayDialGap(2_850, mobile = false, foreground = true)]) + } + + @Test + fun anUntimeableHandshakeRecordsNothingRatherThanZero() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + l.onConnecting(r) + l.onConnected(r, pingMillis = 0, compressed = false) + + val c = counters(accountant) + assertNull(c[UsageKeys.relayHandshake(0, mobile = false, foreground = true)]) + assertNull(c[UsageKeys.relayDialGap(0, mobile = false, foreground = true)]) + // The connection itself is still counted. + assertEquals(1L, c[UsageKeys.relayConnects(mobile = false, foreground = true)]) + } + + @Test + fun inboundBytesAreAttributedToTheSubscriptionThatAskedForThem() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + l.onConnected(r, 10, false) + l.onSent(r, "[\"REQ\"]", ReqCmd("mod1", listOf(ExplainedFilter(purpose = SubPurpose.MODERATION))), success = true) + l.onSent(r, "[\"REQ\"]", ReqCmd("feed1", listOf(ExplainedFilter(purpose = SubPurpose.HOME_FEED))), success = true) + + l.onIncomingMessage(r, "0123456789", EventMessage("mod1", mockk(relaxed = true))) + l.onIncomingMessage(r, "01234", EventMessage("feed1", mockk(relaxed = true))) + l.onIncomingMessage(r, "012", EoseMessage("mod1")) + + val c = counters(accountant) + assertEquals(13L, c[UsageKeys.relayPurposeDown("moderation")]) + assertEquals(5L, c[UsageKeys.relayPurposeDown("home_feed")]) + // Frames, not just bytes: 13 bytes of moderation arrived as two frames, so + // the average frame size is recoverable per purpose. + assertEquals(2L, c[UsageKeys.relayPurposeDownCount("moderation")]) + assertEquals(1L, c[UsageKeys.relayPurposeDownCount("home_feed")]) + } + + @Test + fun framesStillInFlightAfterACloseAreStillAttributed() = + runLedger { accountant, l -> + // The bug this replaced: CLOSE removed the id, so events the relay had + // already queued landed in `unattributed`. 8,159 CLOSEs in one session + // sent 41% of the download there. + val r = relay("wss://relay.damus.io/") + l.onConnected(r, 10, false) + l.onSent(r, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.HOME_FEED))), success = true) + l.onSent(r, "[\"CLOSE\"]", CloseCmd("s1"), success = true) + l.onIncomingMessage(r, "0123456789", EventMessage("s1", mockk(relaxed = true))) + + assertEquals(10L, counters(accountant)[UsageKeys.relayPurposeDown("home_feed")]) + } + + @Test + fun aFrameArrivingAfterAReconnectIsStillAttributed() = + runLedger { accountant, l -> + // Purpose is a property of the subscription id, not of the socket, so a + // disconnect must not forget it. + val r = relay("wss://relay.damus.io/") + l.onConnected(r, 10, false) + l.onSent(r, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.ENGAGEMENT))), success = true) + l.onDisconnected(r) + l.onConnected(r, 10, false) + l.onIncomingMessage(r, "01234", EventMessage("s1", mockk(relaxed = true))) + + assertEquals(5L, counters(accountant)[UsageKeys.relayPurposeDown("engagement")]) + } + + @Test + fun aReconnectStillForgetsWhichSubscriptionsAreOpen() = + runLedger { accountant, l -> + // The other half of the split: the relay forgot them, so the replay is + // new work and must not read as the client changing its mind. + val r = relay("wss://relay.damus.io/") + l.onConnected(r, 10, false) + l.onSent(r, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.HOME_FEED))), success = true) + l.onDisconnected(r) + l.onConnected(r, 10, false) + l.onSent(r, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.HOME_FEED))), success = true) + + assertNull(counters(accountant)[UsageKeys.relaySubsResent(mobile = false, foreground = true)]) + } + + @Test + fun anInboundFrameForAnUnknownSubscriptionIsNotGuessedAt() = + runLedger { accountant, l -> + // Counters wiped mid-session, or a subscription opened before this + // connection: attributing it to a purpose would be an invention. + val r = relay("wss://relay.damus.io/") + l.onConnected(r, 10, false) + l.onIncomingMessage(r, "0123456789", EventMessage("ghost", mockk(relaxed = true))) + + val c = counters(accountant) + assertEquals(10L, c[UsageKeys.relayPurposeDown(UsageKeys.PURPOSE_UNATTRIBUTED)]) + assertEquals(1L, c[UsageKeys.relayPurposeDownCount(UsageKeys.PURPOSE_UNATTRIBUTED)]) + } + + @Test + fun relayWideFramesAreLeftOutRatherThanMisattributed() = + runLedger { accountant, l -> + // NOTICE and OK name no subscription, so nothing may be booked for them. + // This is why purpose.down deliberately does not reconcile to msg.rx. + val r = relay("wss://relay.damus.io/") + l.onConnected(r, 10, false) + l.onIncomingMessage(r, "0123456789", NoticeMessage("hello")) + l.onIncomingMessage(r, "01234", OkMessage("id", true, "")) + + val c = counters(accountant) + assertTrue(c.keys.none { it.startsWith("relay.purpose.") && it.endsWith(".down") }) + // Still counted in the byte totals and the verb split. + assertEquals(15L, c[UsageKeys.relayMsg(mobile = false, foreground = true, received = true)]) + } + + private fun eventWithId(id: String): EventMessage { + val ev = mockk(relaxed = true) + every { ev.id } returns id + val msg = mockk(relaxed = true) + every { msg.subId } returns "s1" + every { msg.event } returns ev + every { msg.label() } returns EventMessage.LABEL + return msg + } + + @Test + fun theSameEventFromTwoRelaysIsCountedOnceAsNewAndOnceAsDuplicate() = + runLedger { accountant, l -> + // The outbox fan-out asks many relays for the same authors, so one event + // arrives once per relay carrying it. That repetition is the measurement. + val a = relay("wss://relay.damus.io/") + val b = relay("wss://nos.lol/") + l.onConnected(a, 10, false) + l.onConnected(b, 10, false) + val id = "a".repeat(64) + l.onIncomingMessage(a, "0123456789", eventWithId(id)) + l.onIncomingMessage(b, "0123456789", eventWithId(id)) + + val c = counters(accountant) + assertEquals(2L, c[UsageKeys.relayEventsSeen(mobile = false, foreground = true)]) + assertEquals(1L, c[UsageKeys.relayEventsDup(mobile = false, foreground = true)]) + assertEquals(10L, c[UsageKeys.relayEventsDupBytes(mobile = false, foreground = true)]) + } + + @Test + fun duplicateBytesAreAttributedToThePurposeThatReceivedThem() = + runLedger { accountant, l -> + // Global duplication says how much is wasted; this says where, which is + // what separates "suppress redundant delivery" from "stop fetching it". + val a = relay("wss://relay.damus.io/") + val b = relay("wss://nos.lol/") + l.onConnected(a, 10, false) + l.onConnected(b, 10, false) + l.onSent(a, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.USER_PROFILE))), success = true) + l.onSent(b, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.USER_PROFILE))), success = true) + + val id = "b".repeat(64) + l.onIncomingMessage(a, "0123456789", eventWithId(id)) + l.onIncomingMessage(b, "0123456789", eventWithId(id)) + + val c = counters(accountant) + assertEquals(20L, c[UsageKeys.relayPurposeDown("user_profile")]) + // Only the second copy is waste. + assertEquals(10L, c[UsageKeys.relayPurposeDupBytes("user_profile")]) + assertEquals(10L, c[UsageKeys.relayEventsDupBytes(mobile = false, foreground = true)]) + } + + @Test + fun aFirstDeliveryIsNeverBookedAsDuplicate() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + l.onConnected(r, 10, false) + l.onSent(r, "[\"REQ\"]", ReqCmd("s1", listOf(ExplainedFilter(purpose = SubPurpose.HOME_FEED))), success = true) + l.onIncomingMessage(r, "0123456789", eventWithId("c".repeat(64))) + + val c = counters(accountant) + assertEquals(10L, c[UsageKeys.relayPurposeDown("home_feed")]) + assertNull(c[UsageKeys.relayPurposeDupBytes("home_feed")]) + } + + @Test + fun distinctEventsAreNotDuplicates() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + l.onConnected(r, 10, false) + // Differ only past the 64-bit prefix would be a collision; differ within it. + l.onIncomingMessage(r, "01234", eventWithId("1".repeat(64))) + l.onIncomingMessage(r, "01234", eventWithId("2".repeat(64))) + + val c = counters(accountant) + assertEquals(2L, c[UsageKeys.relayEventsSeen(mobile = false, foreground = true)]) + assertNull(c[UsageKeys.relayEventsDup(mobile = false, foreground = true)]) + } + + @Test + fun aMalformedEventIdIsCountedButNeverDeduplicated() = + runLedger { accountant, l -> + val r = relay("wss://relay.damus.io/") + l.onConnected(r, 10, false) + l.onIncomingMessage(r, "01234", eventWithId("short")) + l.onIncomingMessage(r, "01234", eventWithId("short")) + l.onIncomingMessage(r, "01234", eventWithId("zzzz".repeat(16))) + + val c = counters(accountant) + assertEquals(3L, c[UsageKeys.relayEventsSeen(mobile = false, foreground = true)]) + assertNull(c[UsageKeys.relayEventsDup(mobile = false, foreground = true)]) + } + + @Test + fun everyCommandAndMessageSubtypeHasItsOwnVerb() { + // Labels are read off *instances*, because `cmd.label()` is what + // RelayUsageListener calls — a subtype whose label() didn't return its own + // LABEL would be invisible to a constant-only check. Asserting these against + // UsageKeyGrammarTest's lists then keeps the two inventories from drifting, + // which they had already started to do. + val cmdVerbs = + listOf( + ReqCmd("s", listOf()), + CloseCmd("s"), + EventCmd(mockk(relaxed = true)), + AuthCmd(mockk(relaxed = true)), + CountCmd("s", listOf()), + ).map { it.label() } + val msgVerbs = + listOf( + EventMessage("s", mockk(relaxed = true)), + EoseMessage("s"), + OkMessage("id", true, ""), + NoticeMessage("m"), + AuthMessage("challenge"), + ClosedMessage("s", "m"), + CountMessage("s", mockk(relaxed = true)), + NotifyMessage("m"), + LimitsMessage(), + ).map { it.label() } + + assertEquals(UsageKeyGrammarTest.CMD_LABELS.toSet(), cmdVerbs.toSet()) + assertEquals(UsageKeyGrammarTest.MSG_LABELS.toSet(), msgVerbs.toSet()) + + // No two labels may collide within a direction, and none may be key-hostile + // (a dot would inject uncontrolled segments — see UsageKeys.sumMatching). + assertEquals(cmdVerbs.size, cmdVerbs.distinct().size) + assertEquals(msgVerbs.size, msgVerbs.distinct().size) + (cmdVerbs + msgVerbs).forEach { + assertFalse("Label '$it' is not usable as a counter key segment", it.isEmpty() || it.contains('.')) + } + } +} + +/** + * NOTICE classification. The reason must come from a fixed set: this key is + * persisted for 30 days and the text behind it is written by the relay. + */ +class NoticeReasonTest { + @Test + fun refusalsAreRecognisedWhateverTheRelayCallsThem() { + // strfry's, the one Hypothesis N is about. Its NIP-01 prefix is just + // "error", so prefix matching alone would not have caught it. + assertEquals(UsageKeys.NOTICE_TOO_MANY_SUBS, UsageKeys.noticeReason("ERROR: too many concurrent REQs")) + assertEquals(UsageKeys.NOTICE_TOO_MANY_SUBS, UsageKeys.noticeReason("too many concurrent NEG requests")) + assertEquals(UsageKeys.NOTICE_TOO_MANY_SUBS, UsageKeys.noticeReason("blocked: too many subscriptions")) + } + + @Test + fun standardPrefixesAreCategorised() { + assertEquals(UsageKeys.NOTICE_AUTH_REQUIRED, UsageKeys.noticeReason("auth-required: we need to know you")) + assertEquals(UsageKeys.NOTICE_RATE_LIMITED, UsageKeys.noticeReason("rate-limited: slow down")) + assertEquals(UsageKeys.NOTICE_RESTRICTED, UsageKeys.noticeReason("restricted: not on the allowlist")) + assertEquals(UsageKeys.NOTICE_INVALID, UsageKeys.noticeReason("invalid: bad filter")) + assertEquals(UsageKeys.NOTICE_BLOCKED, UsageKeys.noticeReason("blocked: you are banned")) + assertEquals(UsageKeys.NOTICE_ERROR, UsageKeys.noticeReason("error: something broke")) + } + + /** Verbatim from a device on 2026-08-02 — the wordings the allowlist was missing. */ + @Test + fun realWorldNoticesAreClassified() { + // Refusals. Each one drops a REQ that then never EOSEs, so its `since` never + // advances and syncState re-sends it on every reconnect. + assertEquals(UsageKeys.NOTICE_QUERY_COST, UsageKeys.noticeReason("Kgo0HH: closed: too many steps")) + assertEquals(UsageKeys.NOTICE_QUERY_COST, UsageKeys.noticeReason("too many kinds")) + assertEquals(UsageKeys.NOTICE_REQ_REFUSED, UsageKeys.noticeReason("Denied! This relay does not accept REQs.")) + + // Chatter that costs bytes and means nothing. + assertEquals(UsageKeys.NOTICE_BENIGN, UsageKeys.noticeReason("keepalive")) + assertEquals(UsageKeys.NOTICE_BENIGN, UsageKeys.noticeReason("as7rp4: PERF: [/!\\ LS] 1087 scan, 0 dedup, 500 match")) + + // A bare subscription id carries no meaning and must stay unclassified rather + // than being read as a machine-readable prefix. + assertEquals(UsageKeys.NOTICE_UNCLASSIFIED, UsageKeys.noticeReason("AccountFollowsLoaderSubAssemblerxE1r8A")) + assertEquals(UsageKeys.NOTICE_UNCLASSIFIED, UsageKeys.noticeReason("Kgo0HH")) + } + + @Test + fun aSubscriptionIdPrefixDoesNotHideTheRealOne() { + // These relays send ": : ", which makes the subId the + // prefix and buries the standard one behind it. + assertEquals(UsageKeys.NOTICE_AUTH_REQUIRED, UsageKeys.noticeReason("sub123: auth-required: need auth")) + assertEquals(UsageKeys.NOTICE_RATE_LIMITED, UsageKeys.noticeReason("sub123: rate-limited: slow down")) + // Still works without the prefix. + assertEquals(UsageKeys.NOTICE_AUTH_REQUIRED, UsageKeys.noticeReason("auth-required: need auth")) + } + + @Test + fun freeFormProseNeverBecomesAKey() { + // The bug RelayObserver had to fix: without a fixed output set, cardinality + // grows with the number of distinct sentences relays happen to write. + listOf( + "hello there", + "Please contact admin@example.com for access", + "", + "::::", + "a".repeat(5000), + ).forEach { + val reason = UsageKeys.noticeReason(it) + assertTrue("'$it' produced unlisted reason '$reason'", reason in UsageKeys.NOTICE_REASONS) + } + assertEquals(UsageKeys.NOTICE_UNCLASSIFIED, UsageKeys.noticeReason("hello there")) + } + + @Test + fun anUnlistedReasonCannotMintAKey() { + assertEquals(UsageKeys.relayNotice(UsageKeys.NOTICE_UNCLASSIFIED), UsageKeys.relayNotice("something-invented")) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index d0f1b7bc16..eec65748e9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -206,7 +206,11 @@ open class BasicRelayClient( !msg.startsWith("failed to connect to /127.0.0.1") && msg != "Socket closed" && msg != "Socket is closed" && - msg != "Cancelled" + // OkHttp spells it with one L (RealCall throws + // IOException("Canceled")). "Cancelled" never matched, so + // client-initiated cancels were being reported as connection + // failures and inflating the relay.connfails counter. + msg != "Canceled" ) ) { if (code != null || response != null) { From 55c873858eefc3197424692b6973a0745a2b1cac Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 08:23:52 +0200 Subject: [PATCH 156/227] feat(resourceusage): copy and share the usage report The only way out of the Resource Usage screen was "Send report via DM", which builds the text into a draft message to a fixed pubkey. Reading your own report meant opening a composer and copying out of it. Adds Copy and Share beside it, handing over the same string for a bug report or a file. Reuses Clipboard.setText from ClipboardExt and the ACTION_SEND chooser pattern from ShareActions. --- .../loggedIn/settings/ResourceUsageScreen.kt | 69 +++++++++++++++---- amethyst/src/main/res/values/strings.xml | 4 +- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index a916dea583..08bcca8f80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import android.content.Intent import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -46,11 +47,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight @@ -67,6 +70,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssem import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatConnHours import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatDurationMs import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage @@ -77,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.warningColor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.Locale @@ -579,6 +584,10 @@ private fun SendReportSection( today: Long, memory: MemorySnapshot?, ) { + val context = LocalContext.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + SettingsSection(R.string.resource_usage_send_section) { Column( modifier = Modifier.padding(16.dp), @@ -589,21 +598,55 @@ private fun SendReportSection( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Button( - onClick = { - val report = ResourceUsageReportAssembler().buildReport(days, today, memory) - nav.nav { - routeToMessage( - user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), - draftMessage = report, - accountViewModel = accountViewModel, - expiresDays = 30, - ) - } - }, + // The DM route needs a composer to exist before the text does, which makes + // it a poor fit for reading the report yourself — copying out of a draft + // message is the only way to get at it. Copy and Share hand over the same + // string directly, for pasting into an issue or saving to a file. + Row( modifier = Modifier.align(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text(stringRes(R.string.resource_usage_send_button)) + TextButton( + onClick = { + val report = ResourceUsageReportAssembler().buildReport(days, today, memory) + scope.launch { clipboard.setText(report) } + }, + ) { + Text(stringRes(R.string.resource_usage_copy_button)) + } + TextButton( + onClick = { + val report = ResourceUsageReportAssembler().buildReport(days, today, memory) + val send = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, report) + putExtra(Intent.EXTRA_TITLE, stringRes(context, R.string.resource_usage_send_section)) + } + context.startActivity( + Intent.createChooser(send, stringRes(context, R.string.resource_usage_share_button)), + ) + }, + ) { + Text(stringRes(R.string.resource_usage_share_button)) + } + Button( + onClick = { + val report = ResourceUsageReportAssembler().buildReport(days, today, memory) + nav.nav { + routeToMessage( + user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), + draftMessage = report, + accountViewModel = accountViewModel, + expiresDays = 30, + ) + } + }, + ) { + Text(stringRes(R.string.resource_usage_send_button)) + } } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index da24418325..d8716bb5dd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -4188,8 +4188,10 @@ Chatroom lists in memory Device memory class Share with the developers - If Amethyst seems to drain battery or data, you can send this report to the developers in an encrypted DM. It contains only the numbers on this screen and the technical counters behind them \u2014 no posts, contacts, or browsing details. Nothing is sent until you tap Send in the message screen. + If Amethyst seems to drain battery or data, you can send this report to the developers in an encrypted DM, or copy it and share it yourself. It contains only the numbers on this screen, the technical counters behind them, and the host names of the relays that reconnected most \u2014 no posts, contacts, or browsing details. The report leaves this screen only when you send, copy, or share it. Send report via DM + Copy + Share High resource usage detected Amethyst consumed more than expected recently: %1$s. Would you like to send a usage report to the developers in an encrypted DM? You will see the full report before anything is sent. %1$s of cellular data in the background in one day From 7f94cf5cd542918d937721b5276a8e52106a246d Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 09:04:50 +0200 Subject: [PATCH 157/227] Code review: - fix(resourceusage): atomic subscription map, and build the report off Main - fix(resourceusage): bound the technical dump so the report stays sendable - an orphan KDoc in ResourceUsageReportAssembler with no declaration under it, which Kotlin silently bound to formatBytes - paragraphs in ResourceUsageAccountant and ResourceUsageStore claiming the key space has no fixed upper bound; every remaining counter is compile-time bounded - the user-facing privacy string, which claimed the report contains the host names of the relays that reconnected most. It does not, and that file is Crowdin-bound, so the false claim would have reached translators. --- .../resourceusage/RelayUsageListener.kt | 128 +++++++++----- .../resourceusage/ResourceUsageAccountant.kt | 18 +- .../ResourceUsageReportAssembler.kt | 59 ++++++- .../resourceusage/ResourceUsageStore.kt | 9 +- .../service/resourceusage/UsageKeys.kt | 167 +++++++++++------- .../loggedIn/settings/ResourceUsageScreen.kt | 52 +++--- amethyst/src/main/res/values/strings.xml | 2 +- .../resourceusage/ResourceUsageLedgerTest.kt | 48 +++++ 8 files changed, 328 insertions(+), 155 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt index 79e4e9e9c6..5abc1998e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.resourceusage import android.os.SystemClock -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposeOrNull import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage @@ -106,10 +106,13 @@ class RelayUsageListener( /** * Event ids delivered recently, as the first 64 bits of the id. * - * Held as a Long rather than the 64-char hex: at the window size below that is - * the difference between ~200 KB and several MB on a 512 MB-class device, for a - * counter that only has to spot repetition. 64 bits makes a collision between - * distinct ids negligible where a 32-bit hash would not be. + * Held as a Long rather than the 64-char hex, which saves the id strings + * themselves — but a boxed `Long` plus its map node still costs ~56 bytes an + * entry, so a full window is ~3 MB, not the few hundred KB the raw payload + * suggests. Worth knowing before raising [MAX_TRACKED_EVENTS] on a 512 MB-class + * device; an unboxed open-addressed `LongArray` would be ~400 KB if it ever needs + * to grow. 64 bits makes a collision between distinct ids negligible where a + * 32-bit hash would not be. * * The window only needs to span the fan-out, not the session: the same event * arrives from every relay carrying it within seconds, so near-term memory @@ -120,7 +123,11 @@ class RelayUsageListener( */ private val recentEventIds = ConcurrentHashMap.newKeySet() - /** Relay -> when this dial was decided, so the pre-request cost can be separated from the handshake. */ + /** + * Relay -> when this dial was decided, so the pre-request cost can be separated + * from the handshake. Consumed by whichever of `onConnected`/`onCannotConnect` + * ends the dial, so an entry never outlives the attempt that made it. + */ private val dialStartedAt = ConcurrentHashMap() /** Notice texts already logged, so one wording costs one line however often it arrives. */ @@ -132,45 +139,49 @@ class RelayUsageListener( cmd: Command, success: Boolean, ) { - if (success) { - val bytes = cmdStr.length.toLong() - val mobile = isMobile() - val fg = isForeground() - accountant.add(UsageKeys.relayMsg(mobile, fg, received = false), bytes) - accountant.add(UsageKeys.relayVerb(cmd.label(), received = false, mobile, fg), bytes) + if (!success) return - when (cmd.label()) { - ReqCmd.LABEL -> { - accountant.add(UsageKeys.relaySubsSent(mobile, fg), 1) + val bytes = cmdStr.length.toLong() + val mobile = isMobile() + val fg = isForeground() + accountant.add(UsageKeys.relayMsg(mobile, fg, received = false), bytes) + accountant.add(UsageKeys.relayVerb(cmd.label(), received = false, mobile, fg), bytes) - val purpose = purposeOf(cmd) - accountant.add(UsageKeys.relayPurposeSent(purpose), 1) - accountant.add(UsageKeys.relayPurposeBytes(purpose), bytes) + when (cmd) { + is ReqCmd -> { + accountant.add(UsageKeys.relaySubsSent(mobile, fg), 1) - // Already open on this connection, so this REQ replaces a live - // subscription rather than starting one. - val subId = (cmd as ReqCmd).subId - if (subPurpose.size >= MAX_TRACKED_SUBS) subPurpose.clear() - subPurpose[subId] = purpose + val purpose = purposeOf(cmd) + accountant.add(UsageKeys.relayPurposeSent(purpose), 1) + accountant.add(UsageKeys.relayPurposeBytes(purpose), bytes) - val known = openSubs.getOrPut(relay.url) { ConcurrentHashMap.newKeySet() } - if (!known.add(subId)) { - accountant.add(UsageKeys.relaySubsResent(mobile, fg), 1) - } - // Within the window after this relay's connect, so almost certainly - // part of syncState's replay rather than a user action. A time - // window because nothing on this side marks a frame as belonging to - // it; see UsageKeys.relaySubsReplay. - val since = connectedSince[relay.url] - if (since != null && nowMs() - since <= UsageKeys.REPLAY_WINDOW_MS) { - accountant.add(UsageKeys.relaySubsReplay(mobile, fg), 1) - } + if (subPurpose.size >= MAX_TRACKED_SUBS) subPurpose.clear() + subPurpose[cmd.subId] = purpose + + // Already open on this connection, so this REQ replaces a live + // subscription rather than starting one. + // computeIfAbsent, not getOrPut: the latter is get-then-put and two + // threads racing the first REQ after a (re)connect would each build a + // set, the losing put's subId vanishing with its orphaned set. + // `sendIfConnected` deliberately calls listeners outside PoolRequests' + // stripe lock, so same-relay concurrency here is by design. + val known = openSubs.computeIfAbsent(relay.url) { ConcurrentHashMap.newKeySet() } + if (!known.add(cmd.subId)) { + accountant.add(UsageKeys.relaySubsResent(mobile, fg), 1) } - CloseCmd.LABEL -> { - accountant.add(UsageKeys.relaySubsClosed(mobile, fg), 1) - openSubs[relay.url]?.remove((cmd as CloseCmd).subId) + // Within the window after this relay's connect, so almost certainly + // part of syncState's replay rather than a user action. A time + // window because nothing on this side marks a frame as belonging to + // it; see UsageKeys.relaySubsReplay. + val since = connectedSince[relay.url] + if (since != null && nowMs() - since <= UsageKeys.REPLAY_WINDOW_MS) { + accountant.add(UsageKeys.relaySubsReplay(mobile, fg), 1) } } + is CloseCmd -> { + accountant.add(UsageKeys.relaySubsClosed(mobile, fg), 1) + openSubs[relay.url]?.remove(cmd.subId) + } } } @@ -250,12 +261,15 @@ class RelayUsageListener( val fg = isForeground() // Doubles as the lifetime histogram's denominator — one session begins here. accountant.add(UsageKeys.relayConnects(mobile, fg), 1) + // Consumed unconditionally: the gap needs a handshake to subtract, but the + // stamp has to go either way or a dial that cannot be timed leaks its entry. + val dialedAt = dialStartedAt.remove(relay.url) // pingMillis is the transport's own handshake timing; <= 0 means it could // not measure it, and a fabricated 0 would be worse than no record. if (pingMillis > 0) { accountant.add(UsageKeys.relayHandshake(pingMillis.toLong(), mobile, fg), 1) - dialStartedAt.remove(relay.url)?.let { startedAt -> - val gap = nowMs() - startedAt - pingMillis + if (dialedAt != null) { + val gap = nowMs() - dialedAt - pingMillis if (gap >= 0) accountant.add(UsageKeys.relayDialGap(gap, mobile, fg), 1) } } @@ -289,16 +303,29 @@ class RelayUsageListener( * subscription's. A REQ whose filters are plain [com.vitorpamplona.quartz.nip01Core.relay.filters.Filter]s * predates #3832's tagging and is counted separately rather than guessed at. */ - private fun purposeOf(cmd: Command): String { - val filters = (cmd as? ReqCmd)?.filters ?: return UsageKeys.PURPOSE_UNEXPLAINED - val explained = - filters.firstOrNull { it is ExplainedFilter } as? ExplainedFilter - ?: return UsageKeys.PURPOSE_UNEXPLAINED - return UsageKeys.purposeKeyPart(explained.purpose) - } + private fun purposeOf(cmd: ReqCmd): String = + cmd.filters + .firstNotNullOfOrNull { it.purposeOrNull() } + ?.let { UsageKeys.purposeKeyPart(it) } + ?: UsageKeys.PURPOSE_UNEXPLAINED - /** The first 64 bits of an event id, or null if it is not a well-formed id. */ - private fun idPrefix(id: String): Long? = if (id.length < 16) null else runCatching { id.substring(0, 16).toULong(16).toLong() }.getOrNull() + /** + * The first 64 bits of an event id, or null if it is not a well-formed id. + * + * Parsed in place rather than `substring(0, 16).toULongOrNull(16)`: this runs on + * every inbound EVENT frame, and the substring would be a String plus its backing + * array per event, thrown away immediately. + */ + private fun idPrefix(id: String): Long? { + if (id.length < 16) return null + var acc = 0L + for (i in 0 until 16) { + val digit = Character.digit(id[i], 16) + if (digit < 0) return null + acc = (acc shl 4) or digit.toLong() + } + return acc + } /** The subscription a frame belongs to, when it names one. */ private fun subIdOf(msg: Message): String? = @@ -315,6 +342,9 @@ class RelayUsageListener( errorMessage: String, ) { accountant.add(UsageKeys.relayConnectFails(isMobile(), isForeground()), 1) + // A dial that ends here never reaches onConnected, so nothing else would + // ever consume its start stamp. + dialStartedAt.remove(relay.url) } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt index e7807eb02b..3c4cde0ccf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt @@ -37,15 +37,10 @@ import java.util.concurrent.atomic.AtomicLong * AtomicLong (not LongAdder) because draining must be loss-free: getAndSet(0) * hands off the accumulated value atomically, whereas remove+sum on a * LongAdder can strand a racing increment on an orphaned cell. Entries stay - * in the map after a drain — the key space is small and *mostly* fixed - * (dims x areas), so this costs a few hundred boxed zeros at most. - * - * The exception is the per-relay churn counters (`relay.host..*`), whose - * cardinality follows the user's relay list rather than a compile-time set: - * two keys per relay, so a few hundred more entries for a large list. Still - * negligible in memory, but it does mean neither this map nor the persisted - * store has a fixed upper bound any more. Keep that in mind before adding - * another counter keyed on runtime data. + * in the map after a drain — the key space is small and fixed (dims x areas), + * so this costs a few hundred boxed zeros at most. The churn counters roughly + * tripled it, but every one of them is still compile-time bounded: no counter + * is keyed on a relay url, a host, or any other runtime string. * * Counters added from inside a pre-flush hook (the CPU sampler, the segment * integrators closing an open segment) never re-arm the debounce: they are @@ -86,6 +81,11 @@ class ResourceUsageAccountant( // (so collisions) on a path that runs per relay frame. (live[key] ?: live.computeIfAbsent(key) { AtomicLong() }).addAndGet(amount) if (inHookRun.get() == true) return + // Plain read before the CAS: in steady state a flush is always already armed, + // and the churn counters made this 5-8 calls per relay frame — every one of + // which would otherwise be a read-modify-write on the same shared cache line + // from every relay's socket thread, only to fail. + if (flushScheduled.get()) return if (flushScheduled.compareAndSet(false, true)) { scope.launch { delay(flushDebounceMs) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index 9b289e5ee7..4387cb2b40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -29,9 +29,8 @@ import java.util.Locale * Assembles the Markdown resource-usage report the user can DM to the * developers via NIP-17 — same shape as the crash ReportAssembler: a device * header table, a human-readable summary, then the full per-day counter dump - * as the technical payload. Counters are sizes/durations/counts only, and never - * content. - * + * as the technical payload. Counters are sizes/durations/counts only; no + * URLs, relay names, or content. */ class ResourceUsageReportAssembler { fun buildReport( @@ -76,16 +75,49 @@ class ResourceUsageReportAssembler { sb.append("\nTechnical details (per epoch-day):\n") sb.append("```\n") - days.toSortedMap().forEach { (day, counters) -> + val sorted = days.toSortedMap() + val included = newestDaysWithin(sorted, MAX_DUMP_CHARS) + sorted.forEach { (day, counters) -> + if (day !in included) return@forEach sb.append("day $day (today=$today)\n") counters.toSortedMap().forEach { (key, value) -> sb.append(" $key = $value\n") } } + val omitted = sorted.size - included.size + if (omitted > 0) { + sb.append("($omitted earlier day(s) omitted to keep this report sendable; ") + sb.append("the summary tables above still cover them)\n") + } sb.append("```\n") return sb.toString() } + /** + * The most recent days whose dumps fit in [budget], newest first, always + * including at least the newest even if it alone exceeds it. + * + * Bounded by size rather than by a day count because the per-day size is not a + * constant: it tracks how many distinct counters the build emits, and that has + * grown by more than an order of magnitude. A fixed day count would have to be + * re-tuned every time a counter family is added, and would be wrong in the + * meantime. + */ + private fun newestDaysWithin( + days: Map>, + budget: Int, + ): Set { + val included = mutableSetOf() + var left = budget + for (day in days.keys.sortedDescending()) { + val size = days.getValue(day).entries.sumOf { it.key.length + DUMP_LINE_OVERHEAD } + if (included.isNotEmpty() && size > left) break + included.add(day) + left -= size + } + return included + } + private fun summaryTable(s: UsageSummary): String = buildString { append("| Metric | Value |\n") @@ -133,7 +165,24 @@ class ResourceUsageReportAssembler { /** Markdown table header/body separator row. */ private const val TABLE_SEPARATOR = "| --- | --- |\n" - /** Caps how many relay hosts a shared report can name. See the class doc. */ + /** + * Character budget for the raw per-day dump. + * + * This report exists to be sent to the developers as a NIP-17 DM, and relays + * commonly cap events between 64 and 256 KB — so an unbounded dump does not + * merely inconvenience, it makes the report unsendable by exactly the users + * whose ledgers are most worth seeing. It also travels through a ~1 MB Binder + * transaction when shared. + * + * The ledger keeps 30 days and a busy day now emits ~1,200 counters, which is + * ~52 KB of dump per day — so "every retained day" would be ~1.5 MB. This + * keeps the newest days and says how many it dropped; the summary tables + * above are unaffected and still cover the whole window. + */ + private const val MAX_DUMP_CHARS = 64 * 1024 + + /** ` ` + ` = ` + the value, per dumped line. */ + private const val DUMP_LINE_OVERHEAD = 24 fun formatBytes(bytes: Long): String = when { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt index c25bd7e3f5..9d56c6b881 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt @@ -34,11 +34,10 @@ import java.io.File * Jackson + Mutex + write-to-tmp-then-rename + version envelope. * * Day keys are UTC epoch-days (stringified for JSON). Buckets older than - * [keepDays] are pruned on every merge, so the file stays small — a few KB, plus - * two keys per relay per day now that the churn counters are keyed on runtime - * data (see [ResourceUsageAccountant], which owns that caveat). The whole file is - * re-serialized on every flush (debounced to ~30s while traffic flows), so that - * growth is paid on each write, not just at rest. + * [keepDays] are pruned on every merge, so the file stays small (a few KB, on a + * key space the churn counters tripled but left compile-time bounded). The whole + * file is re-serialized on every flush (debounced to ~30s while traffic flows), + * so that size is paid on each write, not just at rest. * Also carries the high-consumption alert state (last prompt time, opt-out) * so the whole feature has exactly one file. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index 75271e3b7f..fd222cf993 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -22,6 +22,13 @@ package com.vitorpamplona.amethyst.service.resourceusage import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.AUTH_REQUIRED +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.BLOCKED +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.ERROR +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.INVALID +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.RATE_LIMITED +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.RESTRICTED +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.UNSUPPORTED import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayObserver import java.util.concurrent.ConcurrentHashMap @@ -110,7 +117,10 @@ object UsageKeys { private fun dimKeys( prefix: String, suffix: String? = null, - ): Array = Array(DIMS.size) { if (suffix == null) "$prefix.${DIMS[it]}" else "$prefix.${DIMS[it]}.$suffix" } + ): Array { + val tail = if (suffix == null) "" else ".$suffix" + return Array(DIMS.size) { "$prefix.${DIMS[it]}$tail" } + } /** `net.image.mobile.bg.rx` — HTTP bytes for a subsystem. */ fun net( @@ -342,13 +352,15 @@ object UsageKeys { "too many" in text || "too costly" in text || "too expensive" in text -> NOTICE_QUERY_COST "does not accept" in text || "denied" in text || "not accepting" in text -> NOTICE_REQ_REFUSED "keepalive" in text || "perf:" in text -> NOTICE_BENIGN - "rate-limited" in prefixes || ("rate" in text && "limit" in text) -> NOTICE_RATE_LIMITED - "auth-required" in prefixes || ("auth" in text && "required" in text) -> NOTICE_AUTH_REQUIRED - "restricted" in prefixes -> NOTICE_RESTRICTED - "invalid" in prefixes -> NOTICE_INVALID - "blocked" in prefixes -> NOTICE_BLOCKED - "unsupported" in prefixes -> NOTICE_UNSUPPORTED - "error" in prefixes -> NOTICE_ERROR + // Wire codes come from the enum rather than being hand-written a third + // time (after the enum itself and the NOTICE_* key segments). + RATE_LIMITED.code in prefixes || ("rate" in text && "limit" in text) -> NOTICE_RATE_LIMITED + AUTH_REQUIRED.code in prefixes || ("auth" in text && "required" in text) -> NOTICE_AUTH_REQUIRED + RESTRICTED.code in prefixes -> NOTICE_RESTRICTED + INVALID.code in prefixes -> NOTICE_INVALID + BLOCKED.code in prefixes -> NOTICE_BLOCKED + UNSUPPORTED.code in prefixes -> NOTICE_UNSUPPORTED + ERROR.code in prefixes -> NOTICE_ERROR else -> NOTICE_UNCLASSIFIED } } @@ -369,43 +381,61 @@ object UsageKeys { const val SHORT_SESSION_MS = BasicRelayClient.STABLE_CONNECTION_IN_SECS * 1_000L /** - * Half-open upper bounds, in ms, for the [relayLife] histogram. Deliberately - * straddles [SHORT_SESSION_MS]: a mean cannot tell a tight cluster sitting on - * that bar from a bimodal mix; this can. + * A half-open millisecond histogram: ascending upper [bounds], the derived + * bucket labels, and the `..` key table they index. + * + * Shared by all three histograms below (`relay.life`, `relay.hs`, `relay.gap`), + * which differ only in their bounds and in whether the label reads in seconds or + * milliseconds. Deriving the names from the bounds is what keeps a bound change + * from leaving a label lying about it. */ - private val LIFE_BUCKET_BOUNDS_MS = - longArrayOf(5_000, 30_000, SHORT_SESSION_MS, 120_000, 300_000).also { - // [lifeBucketIndex] linear-scans for the first bound greater than the - // elapsed time, so the bounds must ascend. One of them is derived from + private class MsHistogram( + prefix: String, + private val bounds: LongArray, + label: (Long) -> String, + ) { + init { + // [indexOf] linear-scans for the first bound greater than the elapsed + // time, so the bounds must ascend. One of relay.life's is derived from // BasicRelayClient.STABLE_CONNECTION_IN_SECS, and raising that to five // minutes — exactly the retune commit 2 of the churn plan contemplates — // would push it past the two bounds after it. The buckets between would // become unreachable and the labels would start lying. Fail at class-init // with the reason rather than as a puzzling boundary-test failure. - require(it.asList() == it.sorted()) { - "relay.life bounds must ascend, got ${it.toList()}. " + + require(bounds.asList() == bounds.sorted()) { + "$prefix bounds must ascend, got ${bounds.toList()}. " + "SHORT_SESSION_MS is ${SHORT_SESSION_MS}ms — reorder the bounds to match." } } - /** Derived from the bounds so a bound change can never leave a label lying about it. */ - private val LIFE_BUCKET_NAMES = - Array(LIFE_BUCKET_BOUNDS_MS.size + 1) { i -> - if (i < LIFE_BUCKET_BOUNDS_MS.size) { - "lt${LIFE_BUCKET_BOUNDS_MS[i] / 1000}s" - } else { - "gte${LIFE_BUCKET_BOUNDS_MS.last() / 1000}s" + val names = Array(bounds.size + 1) { i -> if (i < bounds.size) "lt${label(bounds[i])}" else "gte${label(bounds.last())}" } + + private val keys = Array(names.size) { dimKeys("$prefix.${names[it]}") } + + fun indexOf(ms: Long): Int { + for (i in bounds.indices) { + if (ms < bounds[i]) return i } + return bounds.size } - private fun lifeBucketIndex(elapsedMs: Long): Int { - for (i in LIFE_BUCKET_BOUNDS_MS.indices) { - if (elapsedMs < LIFE_BUCKET_BOUNDS_MS[i]) return i - } - return LIFE_BUCKET_BOUNDS_MS.size + fun nameOf(ms: Long): String = names[indexOf(ms)] + + fun key( + ms: Long, + mobile: Boolean, + foreground: Boolean, + ): String = keys[indexOf(ms)][dimIndex(mobile, foreground)] } - fun lifeBucket(elapsedMs: Long): String = LIFE_BUCKET_NAMES[lifeBucketIndex(elapsedMs)] + /** + * Bounds for the [relayLife] histogram deliberately straddle [SHORT_SESSION_MS]: + * a mean cannot tell a tight cluster sitting on that bar from a bimodal mix; + * this can. + */ + private val LIFE = MsHistogram("relay.life", longArrayOf(5_000, 30_000, SHORT_SESSION_MS, 120_000, 300_000)) { "${it / 1000}s" } + + fun lifeBucket(elapsedMs: Long): String = LIFE.nameOf(elapsedMs) /** * `relay.life.lt60s.mobile.bg` — connections that closed after living this long. @@ -415,9 +445,7 @@ object UsageKeys { elapsedMs: Long, mobile: Boolean, foreground: Boolean, - ): String = RELAY_LIFE[lifeBucketIndex(elapsedMs)][dimIndex(mobile, foreground)] - - private val RELAY_LIFE = Array(LIFE_BUCKET_NAMES.size) { dimKeys("relay.life.${LIFE_BUCKET_NAMES[it]}") } + ): String = LIFE.key(elapsedMs, mobile, foreground) /** * `relay.life.overwrite` — a connect arrived for a relay that already had an @@ -478,10 +506,15 @@ object UsageKeys { * purpose at all means an assembler #3832 did not reach, which is a different * fact from one that declared itself uncategorised — and if that bucket is large, * the attribution below cannot be trusted. + * + * Memoized for the same reason [relayVerb] is, and more urgently: [relayPurposeDown] + * and [relayPurposeDownCount] both run on every inbound frame that names a + * subscription, so interpolating would build two strings per frame on the hottest + * path in the app. The purpose space is the enum plus the three fallbacks below. */ - fun relayPurposeSent(purpose: String): String = "relay.purpose.$purpose.sent" + fun relayPurposeSent(purpose: String): String = purposeKeys(purpose)[P_SENT] - fun relayPurposeBytes(purpose: String): String = "relay.purpose.$purpose.bytes" + fun relayPurposeBytes(purpose: String): String = purposeKeys(purpose)[P_BYTES] /** * `relay.purpose.moderation.down` — bytes received on subscriptions opened for @@ -494,7 +527,7 @@ object UsageKeys { * of the download it was causing — every re-subscription can make the relay * re-send everything that matches. */ - fun relayPurposeDown(purpose: String): String = "relay.purpose.$purpose.down" + fun relayPurposeDown(purpose: String): String = purposeKeys(purpose)[P_DOWN] /** * `relay.purpose.home_feed.downn` — inbound frames, alongside the bytes. @@ -506,7 +539,7 @@ object UsageKeys { * how much of the download is the same events arriving from different relays * under the outbox fan-out. */ - fun relayPurposeDownCount(purpose: String): String = "relay.purpose.$purpose.downn" + fun relayPurposeDownCount(purpose: String): String = purposeKeys(purpose)[P_DOWNN] /** * `relay.purpose.user_profile.dupbytes` — of that purpose's inbound bytes, how @@ -519,7 +552,20 @@ object UsageKeys { * points at completely different fixes — suppress redundant delivery, or stop * fetching the large thing per relay. */ - fun relayPurposeDupBytes(purpose: String): String = "relay.purpose.$purpose.dupbytes" + fun relayPurposeDupBytes(purpose: String): String = purposeKeys(purpose)[P_DUPBYTES] + + private const val P_SENT = 0 + private const val P_BYTES = 1 + private const val P_DOWN = 2 + private const val P_DOWNN = 3 + private const val P_DUPBYTES = 4 + + private val PURPOSE_SUFFIXES = arrayOf("sent", "bytes", "down", "downn", "dupbytes") + + private val PURPOSE_KEYS = ConcurrentHashMap>() + + /** The five `relay.purpose..*` keys, indexed by the `P_` constants above. */ + private fun purposeKeys(purpose: String): Array = PURPOSE_KEYS.getOrPut(purpose) { Array(PURPOSE_SUFFIXES.size) { "relay.purpose.$purpose.${PURPOSE_SUFFIXES[it]}" } } /** A frame whose subscription id we never saw opened — counters wiped mid-session, or a sub from before this connection. */ const val PURPOSE_UNATTRIBUTED = "unattributed" @@ -534,8 +580,17 @@ object UsageKeys { * The key segment for a [SubPurpose]. The one place the enum is turned into a * key, so the reserved-segment rename cannot drift between the producer and the * test that guards it — which is exactly how it drifted the first time. + * + * Tabled by ordinal: this runs per REQ, and `name.lowercase()` would allocate a + * fresh String each time for one of a dozen fixed answers. */ - fun purposeKeyPart(purpose: SubPurpose): String = if (purpose == SubPurpose.OTHER) PURPOSE_OTHER else purpose.name.lowercase() + fun purposeKeyPart(purpose: SubPurpose): String = PURPOSE_PARTS[purpose.ordinal] + + private val PURPOSE_PARTS = + Array(SubPurpose.entries.size) { + val purpose = SubPurpose.entries[it] + if (purpose == SubPurpose.OTHER) PURPOSE_OTHER else purpose.name.lowercase() + } /** * `relay.subs.resent.mobile.bg` — a REQ for a subscription id this relay already @@ -554,23 +609,8 @@ object UsageKeys { private val RELAY_SUBS_RESENT = dimKeys("relay.subs.resent") - /** - * Half-open bounds, in ms, for the two connect-timing histograms below. - */ - private val CONNECT_BUCKET_BOUNDS_MS = longArrayOf(100, 500, 2_000, 10_000, 30_000) - private val CONNECT_BUCKET_NAMES = - Array(CONNECT_BUCKET_BOUNDS_MS.size + 1) { i -> - if (i < CONNECT_BUCKET_BOUNDS_MS.size) "lt${CONNECT_BUCKET_BOUNDS_MS[i]}ms" else "gte${CONNECT_BUCKET_BOUNDS_MS.last()}ms" - } - - private fun connectBucketIndex(ms: Long): Int { - for (i in CONNECT_BUCKET_BOUNDS_MS.indices) { - if (ms < CONNECT_BUCKET_BOUNDS_MS[i]) return i - } - return CONNECT_BUCKET_BOUNDS_MS.size - } - - fun connectBucket(ms: Long): String = CONNECT_BUCKET_NAMES[connectBucketIndex(ms)] + /** Half-open bounds, in ms, shared by the two connect-timing histograms below. */ + private val CONNECT_BOUNDS_MS = longArrayOf(100, 500, 2_000, 10_000, 30_000) /** * `relay.hs.lt500ms.wifi.fg` — the websocket upgrade round-trip, as the @@ -589,9 +629,9 @@ object UsageKeys { ms: Long, mobile: Boolean, foreground: Boolean, - ): String = RELAY_HS[connectBucketIndex(ms)][dimIndex(mobile, foreground)] + ): String = HANDSHAKE.key(ms, mobile, foreground) - private val RELAY_HS = Array(CONNECT_BUCKET_NAMES.size) { dimKeys("relay.hs.${CONNECT_BUCKET_NAMES[it]}") } + private val HANDSHAKE = MsHistogram("relay.hs", CONNECT_BOUNDS_MS) { "${it}ms" } /** * `relay.gap.lt2000ms.wifi.fg` — everything between deciding to dial and the @@ -609,9 +649,9 @@ object UsageKeys { ms: Long, mobile: Boolean, foreground: Boolean, - ): String = RELAY_GAP[connectBucketIndex(ms)][dimIndex(mobile, foreground)] + ): String = DIAL_GAP.key(ms, mobile, foreground) - private val RELAY_GAP = Array(CONNECT_BUCKET_NAMES.size) { dimKeys("relay.gap.${CONNECT_BUCKET_NAMES[it]}") } + private val DIAL_GAP = MsHistogram("relay.gap", CONNECT_BOUNDS_MS) { "${it}ms" } /** * `relay.events.seen.wifi.fg` — inbound EVENT frames, and of those, how many @@ -757,11 +797,8 @@ object UsageKeys { */ fun Map.sumMatching(vararg parts: String): Long { var total = 0L - outer@ for ((key, value) in this) { - for (part in parts) { - if (!key.hasSegment(part)) continue@outer - } - total += value + for ((key, value) in this) { + if (parts.all { key.hasSegment(it) }) total += value } return total } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index 08bcca8f80..beeb879295 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -588,6 +588,14 @@ private fun SendReportSection( val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() + // Suspending, and off Main: assembling the report walks every counter of every + // retained day (UsageSummary makes ~54 passes per summary) over a key space the + // churn counters grew by an order of magnitude. `scope` is Main.immediate, so a + // bare `scope.launch { }` would still build it on the UI thread — the + // withContext is what actually moves it. Only the handover (clipboard, chooser, + // navigation) stays on Main. + suspend fun buildReport(): String = withContext(Dispatchers.Default) { ResourceUsageReportAssembler().buildReport(days, today, memory) } + SettingsSection(R.string.resource_usage_send_section) { Column( modifier = Modifier.padding(16.dp), @@ -609,39 +617,41 @@ private fun SendReportSection( ) { TextButton( onClick = { - val report = ResourceUsageReportAssembler().buildReport(days, today, memory) - scope.launch { clipboard.setText(report) } + scope.launch { clipboard.setText(buildReport()) } }, ) { Text(stringRes(R.string.resource_usage_copy_button)) } TextButton( onClick = { - val report = ResourceUsageReportAssembler().buildReport(days, today, memory) - val send = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, report) - putExtra(Intent.EXTRA_TITLE, stringRes(context, R.string.resource_usage_send_section)) - } - context.startActivity( - Intent.createChooser(send, stringRes(context, R.string.resource_usage_share_button)), - ) + scope.launch { + val send = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, buildReport()) + putExtra(Intent.EXTRA_TITLE, stringRes(context, R.string.resource_usage_send_section)) + } + context.startActivity( + Intent.createChooser(send, stringRes(context, R.string.resource_usage_share_button)), + ) + } }, ) { Text(stringRes(R.string.resource_usage_share_button)) } Button( onClick = { - val report = ResourceUsageReportAssembler().buildReport(days, today, memory) - nav.nav { - routeToMessage( - user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), - draftMessage = report, - accountViewModel = accountViewModel, - expiresDays = 30, - ) + scope.launch { + val report = buildReport() + nav.nav { + routeToMessage( + user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), + draftMessage = report, + accountViewModel = accountViewModel, + expiresDays = 30, + ) + } } }, ) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d8716bb5dd..f3e96dffdf 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -4188,7 +4188,7 @@ Chatroom lists in memory Device memory class Share with the developers - If Amethyst seems to drain battery or data, you can send this report to the developers in an encrypted DM, or copy it and share it yourself. It contains only the numbers on this screen, the technical counters behind them, and the host names of the relays that reconnected most \u2014 no posts, contacts, or browsing details. The report leaves this screen only when you send, copy, or share it. + If Amethyst seems to drain battery or data, you can send this report to the developers in an encrypted DM, or copy it and share it yourself. It contains only the numbers on this screen and the technical counters behind them \u2014 no posts, contacts, relay names, or browsing details. The report leaves this screen only when you send, copy, or share it. Send report via DM Copy Share diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index c42b3bdada..21dd106f48 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -1552,3 +1552,51 @@ class NoticeReasonTest { assertEquals(UsageKeys.relayNotice(UsageKeys.NOTICE_UNCLASSIFIED), UsageKeys.relayNotice("something-invented")) } } + +/** + * The report is sent as a NIP-17 DM, so its size is a correctness property, not a + * cosmetic one: relays cap events and an oversized report is simply never delivered. + */ +class ReportSizeTest { + private fun day(keys: Int) = (0 until keys).associate { "relay.purpose.p$it.bytes" to 123_456L } + + @Test + fun aFullLedgerStaysSendable() { + // 30 retained days at the density a busy day now produces. + val days = (1L..30L).associateWith { day(1_200) } + val report = ResourceUsageReportAssembler().buildReport(days, today = 30L) + + assertTrue("report was ${report.length} chars", report.length < 100_000) + assertTrue("nothing was said about the omission", report.contains("omitted to keep this report sendable")) + } + + @Test + fun aSmallLedgerIsNotTruncatedAndSaysNothingAboutOmission() { + val days = (1L..3L).associateWith { day(20) } + val report = ResourceUsageReportAssembler().buildReport(days, today = 3L) + + assertTrue(report.contains("day 1 ")) + assertTrue(report.contains("day 3 ")) + assertFalse(report.contains("omitted")) + } + + @Test + fun theNewestDayIsKeptEvenIfItAloneExceedsTheBudget() { + // Dropping everything would leave a report that says nothing at all. + val days = mapOf(1L to day(50), 2L to day(20_000)) + val report = ResourceUsageReportAssembler().buildReport(days, today = 2L) + + assertTrue("newest day missing", report.contains("day 2 ")) + assertTrue(report.contains("1 earlier day(s) omitted")) + } + + @Test + fun omittedDaysStillCountTowardTheSummaryTables() { + // The tables are built from every day; only the raw dump is bounded. + val days = (1L..30L).associateWith { mapOf(UsageKeys.relayConnects(mobile = false, foreground = true) to 10L) } + val report = ResourceUsageReportAssembler().buildReport(days, today = 30L) + + // 7 days x 10 connections in the week table. + assertTrue(report.contains("| Relay reconnections | 70 ")) + } +} From a251a69b9315a349c92acb05b01ccd6c2829732a Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 12:47:12 +0200 Subject: [PATCH 158/227] perf(resourceusage): keep 7 days of ledger, not 30 ResourceUsageStore.persist() rewrites the whole file on every merge, and merges fire on the accountant's 30s flush debounce while traffic flows. Only today's bucket ever changes, so retention is a write-amplification setting as much as a history setting. Nothing reads past 7 days. --- .../service/resourceusage/ResourceUsageStore.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt index 9d56c6b881..fc63089343 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt @@ -43,7 +43,22 @@ import java.io.File */ class ResourceUsageStore( private val storageFile: File, - private val keepDays: Long = 30, + /** + * Retention, in days. + * + * Seven because that is the widest window anything reads: the summary tables and + * the trend chart both span `today - 6 .. today`, the alert evaluator looks at + * two days, and the report's raw dump is byte-bounded well below a week. At 30 — + * the previous value — twenty-three days were rewritten on every flush and read + * by nothing. + * + * That is not free: [persist] rewrites the whole file on every merge, and the + * relay-churn counters took a day's bucket from ~57 keys to ~241. Retention is + * therefore a write-amplification setting as much as a history setting — cutting + * it to a week is what keeps those counters at ~18% over the previous file size + * rather than ~5x. + */ + private val keepDays: Long = 7, ) { data class UsageFile( val version: Int = 1, From ac06d4c4358dbcfb88cb54394d131305fcc1499c Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:12:34 +0000 Subject: [PATCH 159/227] chore: sync Crowdin translations and seed translator npub placeholders --- docs/changelog/translators.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 78f466826a..0697190820 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -93,12 +93,15 @@ { "user": "vitorpamplona", "languages": [ + "Chinese Simplified", "Czech", + "Dutch", "German", "Hindi", "Hungarian", "Polish", "Portuguese, Brazilian", + "Slovenian", "Swedish" ] }, From c84de87361e11f1deefbdb2316cfe2a8dcc66093 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 8 Aug 2026 12:01:55 -0400 Subject: [PATCH 160/227] fix(concord): adopt a mid-session control_root without rebuilding the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A staff-making Grant delivers the `control_root` inside the fold itself (CORD-04 §3), so it lands on an entry whose session was built as a read-only member long before. `ConcordSessionRegistry.sync` only rebuilt a session when `root`/`rootEpoch` changed, and `ConcordCommunitySession` derived `controlKeys` once at construction — adoption changes neither, so the live session kept `signer = null` and `canWrite == false` for the rest of the process. The promoted staffer saw their new role badge appear (that half reads the folded `state` flow) while every write affordance stayed hidden and `controlKeysForWrite` refused, until the app was restarted. Rebuilding the session on the change is not the fix: the new session starts with no buffered Control Plane wraps, so the community folds to "No channels yet" until every wrap happens to be re-delivered. Instead refresh the key material in place. Nothing about the plane moves — adoption is gated on the secret deriving to exactly the `control_pk` already held (CORD-02 §5) — so the address, read key, buffered wraps and subscription set are all invariant, and only the signer appears. `adoptControlMaterial` fails closed on a different community/root/epoch or an address change, leaving those to a rebuild. Verified on device (SM-T220, Android 14) against a loopback geode relay: an account promoted to staff while sitting on the community screen gains the edit/create affordances with no restart, keeps its folded channel list, and its next Control edition lands on the wire signed by `control_pk`. Co-Authored-By: Claude Opus 5 (1M context) --- .../model/concord/ConcordCommunitySession.kt | 52 ++++++++++++++- .../model/concord/ConcordSessionRegistry.kt | 7 +++ .../concord/ConcordSessionRegistryTest.kt | 63 ++++++++++++++++++- 3 files changed, 117 insertions(+), 5 deletions(-) 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 32869407e1..7670ff69c6 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 @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlin.concurrent.Volatile /** * A validated inner chat rumor emitted by a session: its parent [communityId] and @@ -97,10 +98,23 @@ enum class ConcordIngestOutcome { * [ConcordActions]/[ConcordPlaneRegistry] helpers. */ class ConcordCommunitySession( - val entry: ConcordCommunityListEntry, + entry: ConcordCommunityListEntry, val myPubKey: HexKey, private val onRumor: ConcordRumorSink = { _, _, _, _ -> }, ) { + /** + * The joined-list entry this session projects. Replaced in place — only ever by + * [adoptControlMaterial], and only within one epoch — because the Control Plane + * write key can arrive long after the session was built (CORD-04 §3). A change + * that moves the *planes* (a Refounding) rebuilds the session instead. + * + * Volatile because the ingest path, the UI and the adopting drain are different + * threads: the write happens under [lock], but readers take it unsynchronized. + */ + @Volatile + var entry: ConcordCommunityListEntry = entry + private set + private val root = entry.root.hexToByteArray() private val communityIdBytes = entry.id.hexToByteArray() @@ -109,7 +123,8 @@ class ConcordCommunitySession( * carries a `control_pk` (plus the write key when this account is staff and * holds the `control_root`), legacy single-key otherwise. */ - private val controlKeys: ControlPlaneKeys = ConcordActions.controlPlaneKeysFor(entry) + @Volatile + private var controlKeys: ControlPlaneKeys = ConcordActions.controlPlaneKeysFor(entry) /** The Guestbook Plane at this epoch — where member join/leave motions ride (CORD-02 §5). */ private val guestbookKey: GroupKey = ConcordActions.guestbookPlane(root, communityIdBytes, entry.rootEpoch) @@ -329,7 +344,38 @@ class ConcordCommunitySession( * editions. [ControlPlaneKeys.canWrite] is false for a regular member on a split * epoch (CORD-02 §2) — the caller must not attempt to publish an edition then. */ - fun controlPlaneKeys(): ControlPlaneKeys = controlKeys + fun controlPlaneKeys(): ControlPlaneKeys = lock.withLock { controlKeys } + + /** + * Adopt Control Plane key material that arrived *after* this session was built, at + * the same epoch: the `control_root` a staff-making Grant delivers (CORD-04 §3), or + * a `control_pk` filled in by a same-epoch Community List merge (CORD-02 §8). + * + * Done in place rather than by rebuilding the session, because a rebuild would drop + * the buffered Control Plane wraps and leave the community folded empty until every + * wrap happened to be re-delivered. Nothing about the *plane* moves here: adoption is + * gated on the secret deriving to exactly the `control_pk` already held (CORD-02 §5), + * so the address, the read key, the buffered wraps and the subscription set are all + * invariant — only [ControlPlaneKeys.signer] appears, flipping + * [ControlPlaneKeys.canWrite] and adding the stream key to [streamKeys]. + * + * Fails closed and returns false when [newEntry] is not the same community at the + * same root and epoch, or when the material it carries would move the plane's + * address — a caller must rebuild the session for that, never mutate it. Returns + * false too when nothing changed, so the caller can skip a needless revision bump. + */ + fun adoptControlMaterial(newEntry: ConcordCommunityListEntry): Boolean = + lock.withLock { + if (newEntry.id != entry.id || newEntry.root != entry.root || newEntry.rootEpoch != entry.rootEpoch) return@withLock false + if (newEntry.controlPk == entry.controlPk && newEntry.controlRoot == entry.controlRoot) return@withLock false + val newKeys = ConcordActions.controlPlaneKeysFor(newEntry) + // The plane is where the buffered wraps already are. If the new material points + // somewhere else, this is not an adoption — refuse and let the caller rebuild. + if (newKeys.address != controlKeys.address) return@withLock false + entry = newEntry + controlKeys = newKeys + true + } /** This account's standing, from the current fold. */ fun membership(): ConcordMembership { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index cb366ba6fa..09be414805 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -78,6 +78,13 @@ class ConcordSessionRegistry( if (existing == null || existing.entry.root != entry.root || existing.entry.rootEpoch != entry.rootEpoch) { sessions[id] = ConcordCommunitySession(entry, myPubKey, onRumor) created += id + } else { + // Same epoch, but the Control Plane write key may have just arrived — a + // staff-making Grant delivers it inside the fold itself (CORD-04 §3), long + // after this session was built. Adopt it in place: rebuilding would drop the + // buffered wraps and fold the community empty, and the plane's address is + // invariant under adoption anyway (CORD-02 §5). + existing.adoptControlMaterial(entry) } } created 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 a8bedadc73..81519fd100 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 @@ -30,8 +30,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue class ConcordSessionRegistryTest { @@ -40,14 +42,16 @@ class ConcordSessionRegistryTest { private fun entryFor( community: NewConcordCommunity, name: String, + controlRoot: String? = community.controlRoot.toHexKey(), + rootEpoch: Long = community.rootEpoch, ) = ConcordCommunityListEntry( id = community.communityIdHex, owner = community.ownerPubKey, ownerSalt = community.ownerSalt.toHexKey(), root = community.communityRoot.toHexKey(), - rootEpoch = community.rootEpoch, + rootEpoch = rootEpoch, controlPk = community.controlPkHex, - controlRoot = community.controlRoot.toHexKey(), + controlRoot = controlRoot, relays = listOf("wss://r.example"), name = name, ) @@ -104,4 +108,59 @@ class ConcordSessionRegistryTest { val gamma = ConcordCommunityFactory.create(owner, "Gamma", createdAt = 1L, relays = listOf("wss://r.example")) assertEquals(ConcordIngestOutcome.NOT_MINE, registry.ingest(gamma.genesisWraps.first())) } + + /** + * A promotion to staff delivers the `control_root` inside the fold itself (CORD-04 §3), + * so it lands on an entry whose session was built as a read-only member long before. The + * session must pick the key up **without** being rebuilt: a rebuild would drop the + * buffered Control Plane wraps and fold the community empty, which is exactly what the + * user would see instead of their new moderation powers. + */ + @Test + fun adoptsAControlRootDeliveredMidSessionWithoutLosingTheFold() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example")) + val registry = ConcordSessionRegistry() + + // Joined as a plain member: the address is held, the write secret is not. + val asMember = entryFor(community, "Alpha", controlRoot = null) + registry.sync(listOf(asMember), owner.pubKey) + val session = registry.sessionFor(community.communityIdHex)!! + community.genesisWraps.forEach { registry.ingest(it) } + + assertEquals( + "Alpha", + session.state.value + ?.metadata + ?.name, + ) + assertFalse(session.controlPlaneKeys().canWrite, "a member holds no control_root") + + // The staff-making Grant lands and the drain writes the secret onto the entry. + val asStaff = entryFor(community, "Alpha") + val createdOnAdopt = registry.sync(listOf(asStaff), owner.pubKey) + + // Adopted in place: same session object, no rebuild. + assertTrue(createdOnAdopt.isEmpty(), "adopting a control_root must not rebuild the session") + assertSame(session, registry.sessionFor(community.communityIdHex)) + + // The write key is live... + assertTrue(session.controlPlaneKeys().canWrite, "the delivered control_root must flip canWrite") + assertEquals(community.controlRoot.toHexKey(), session.entry.controlRoot, "the entry carries it onward for the next Grant") + assertTrue(session.streamKeys().any { it.publicKeyHex == community.controlPkHex }, "staff now AUTHs as the plane") + + // ...and the address and the fold are untouched — the bug this guards. + assertEquals(community.controlPlane.address, session.controlPlaneAddress) + assertEquals( + "Alpha", + session.state.value + ?.metadata + ?.name, + "adoption must not discard the buffered wraps", + ) + + // A real rotation still rebuilds rather than adopting in place. + val nextEpoch = entryFor(community, "Alpha", rootEpoch = community.rootEpoch + 1) + assertEquals(setOf(community.communityIdHex), registry.sync(listOf(nextEpoch), owner.pubKey)) + } } From 9ce0fb9559339295ac0e421b1e99ad85f7be3a8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 17:43:11 +0000 Subject: [PATCH 161/227] fix(quartz): update NIP-66 relay records instead of rebuilding them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A kind:30166 is addressable, so RelayReachabilityStore keeps exactly one record per (monitor, relay) — but it is not necessarily the only thing writing per-relay knowledge under that identity. Both write paths built the record from their own tags and inserted it, so every update deleted whatever else was in that slot. Observed while adding a "this url is an alias of that one" tag alongside the monitor: `[d, n, rtt-open]` became `[d, redirect]` on our write, and the monitor's next observation turned it back into `[d, n, rtt-open]`. Nothing looks wrong at any point — the event still signs, still parses, still reads as a valid NIP-66 record. It just says less than it did, and the reader downstream cannot tell. Writing is now an edit: read this monitor's current record, carry across every tag the writer does not own — including tags this version of quartz has never heard of — and replace only what it measured. `n` and the three `rtt-*` types are owned by both paths, so a dead update still clears a stale rtt and liveness keeps meaning what it meant. `R` is owned only by the observation path, which is the one that learns whether a relay challenged us; writeOne leaves it alone rather than deleting what it cannot re-measure. Only OUR records are merged. Folding another monitor's tags into a document signed with this key would republish their claims as ours. The timestamp is now `max(now, current + 1)` rather than `now`. A store enforcing replaceable semantics REJECTS a record that is not strictly newer than the one it replaces, and two writers inside the same second — or a peer whose clock runs ahead — are ordinary. That is not theoretical: it silently swallowed a repair pass in the caller that found this bug, which reported success having written nothing. The reads are batched per call rather than per relay, so a flush over N relays costs one extra query, not N. Test plan: ./gradlew :quartz:jvmTest — 4,071 tests, all passing, including four new cases in RelayReachabilityStoreTest covering a foreign tag surviving an update, an update against a record stamped an hour ahead, a dead update clearing its rtt, and another monitor's record not being merged. ./gradlew :quartz:spotlessApply clean. --- .../reachability/RelayReachabilityStore.kt | 104 +++++++++++++++-- .../RelayReachabilityStoreTest.kt | 106 ++++++++++++++++++ 2 files changed, 201 insertions(+), 9 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt index fe28f94954..772fd3524a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip66RelayMonitor.reachability +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -30,6 +31,8 @@ import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.networkType import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.requirement import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.rtt import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkType +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkTypeTag +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RequirementTag import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType import com.vitorpamplona.quartz.utils.TimeUtils @@ -139,8 +142,9 @@ class RelayReachabilityStore( now: Long = TimeUtils.now(), rttOpenMs: Long = 0, ) { - for (relay in reachable) writeOne(relay, up = true, now, rttOpenMs) - for (relay in dead) if (relay !in reachable) writeOne(relay, up = false, now, rttOpenMs) + val current = currentRecords(reachable + dead) + for (relay in reachable) writeOne(relay, up = true, now, rttOpenMs, current[relay]) + for (relay in dead) if (relay !in reachable) writeOne(relay, up = false, now, rttOpenMs, current[relay]) } /** @@ -154,8 +158,9 @@ class RelayReachabilityStore( dead: Set, now: Long = TimeUtils.now(), ) { - for ((relay, rtt) in reachableRttMs) writeOne(relay, up = true, now, rtt.coerceAtLeast(0)) - for (relay in dead) if (relay !in reachableRttMs) writeOne(relay, up = false, now, 0) + val current = currentRecords(reachableRttMs.keys + dead) + for ((relay, rtt) in reachableRttMs) writeOne(relay, up = true, now, rtt.coerceAtLeast(0), current[relay]) + for (relay in dead) if (relay !in reachableRttMs) writeOne(relay, up = false, now, 0, current[relay]) } /** @@ -171,10 +176,11 @@ class RelayReachabilityStore( observations: Collection, now: Long = TimeUtils.now(), ): Int { + val reported = observations.filter { it.reachable || it.error != null } + val current = currentRecords(reported.map { it.url }) var written = 0 - for (o in observations) { - if (!o.reachable && o.error == null) continue - writeObserved(o, now) + for (o in reported) { + writeObserved(o, now, current[o.url]) written++ } return written @@ -183,9 +189,14 @@ class RelayReachabilityStore( private suspend fun writeObserved( o: RelayObserver.Observation, now: Long, + current: RelayDiscoveryEvent?, ) { + // `R` is included in the owned set here and NOT in [writeOne]: an + // observation knows whether this relay challenged us, so it may clear a + // requirement that no longer holds. writeOne never learns that, so it + // leaves the tag alone rather than deleting what it cannot re-measure. val template = - RelayDiscoveryEvent.build(o.url, createdAt = now) { + edit(o.url, now, current, OWNED_LIVENESS + RequirementTag.TAG_NAME) { networkType(networkTypeOf(o.url)) if (o.reachable) { // Liveness is the presence of rtt-open, per NIP-66. A relay we @@ -210,16 +221,91 @@ class RelayReachabilityStore( up: Boolean, now: Long, rttOpenMs: Long, + current: RelayDiscoveryEvent?, ) { val template = - RelayDiscoveryEvent.build(relay, createdAt = now) { + edit(relay, now, current, OWNED_LIVENESS) { networkType(networkTypeOf(relay)) if (up) rtt(RttType.OPEN, rttOpenMs) } store.insert(signer.sign(template)) } + /** + * Build this monitor's next record for [relay] as an EDIT of [current] + * rather than a fresh document. + * + * A 30166 is addressable, so a relay has exactly one record per monitor — + * and this class is not necessarily its only writer. Anything else keeping + * per-relay knowledge under the same identity (an operator marking a relay + * as a mirror of another, a crawler recording which kinds it served) writes + * into this same slot, and a build-from-scratch silently deletes it. The + * result still signs, still parses, and still reads as a valid NIP-66 + * record — it just says less than it did, and the reader downstream has no + * way to know something was lost. + * + * [owned] is what this writer measured and may therefore replace. + * Everything else is carried across untouched, including tags this version + * of quartz has never heard of. + * + * The timestamp is `max(now, current + 1)`, not `now`: a store enforcing + * replaceable semantics REJECTS a record that is not strictly newer than + * the one it replaces, and two writers inside the same second — or a peer + * whose clock runs ahead of ours — are ordinary. An update lost that way is + * indistinguishable from one that had nothing to say. + */ + private fun edit( + relay: NormalizedRelayUrl, + now: Long, + current: RelayDiscoveryEvent?, + owned: Set, + measured: TagArrayBuilder.() -> Unit, + ) = RelayDiscoveryEvent.build( + relay, + current?.content ?: "", + createdAt = maxOf(now, (current?.createdAt ?: 0L) + 1), + ) { + current?.tags?.forEach { tag -> + if (tag.firstOrNull() != "d" && tag.firstOrNull() !in owned) add(tag) + } + measured() + } + + /** + * This monitor's own current record for each relay, in one query. + * + * Only OUR records: merging another monitor's tags into a document signed + * with this key would republish their claims as ours. + */ + private suspend fun currentRecords(relays: Collection): Map { + if (relays.isEmpty()) return emptyMap() + val held = + store.query( + Filter( + kinds = listOf(RelayDiscoveryEvent.KIND), + authors = listOf(signer.pubKey), + tags = mapOf("d" to relays.map { it.url }.distinct()), + ), + ) + val out = HashMap(held.size) + for (ev in held) { + val relay = ev.relay() ?: continue + val seen = out[relay] + if (seen == null || ev.createdAt > seen.createdAt) out[relay] = ev + } + return out + } + companion object { + /** + * The tags this class measures on every write, and may therefore + * replace. A dead record must be able to CLEAR a stale rtt — liveness + * is the presence of `rtt-open` — so all three rtt types are owned even + * though only `rtt-open` is written by every path. + */ + private val OWNED_LIVENESS = + setOf(NetworkTypeTag.TAG_NAME, RttType.OPEN.tagName, RttType.READ.tagName, RttType.WRITE.tagName) + /** Default freshness window: a relay's status is trusted for a day, then re-probed. */ const val DEFAULT_TTL_SECONDS = 24L * 60 * 60 diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt index 2117163f78..8b338bd72e 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt @@ -21,11 +21,15 @@ package com.vitorpamplona.quartz.nip66RelayMonitor.reachability import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkType +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -110,4 +114,106 @@ class RelayReachabilityStoreTest { // A host that merely contains ".onion" as a substring is clearnet, not Tor. assertEquals(NetworkType.CLEARNET, RelayReachabilityStore.networkTypeOf(fakeOnion)) } + // ---- one address, more than one writer --------------------------------- + + private suspend fun tagsOf( + store: EventStore, + signer: NostrSignerInternal, + relay: NormalizedRelayUrl, + ): List> = + store + .query( + Filter(kinds = listOf(RelayDiscoveryEvent.KIND), authors = listOf(signer.pubKey), tags = mapOf("d" to listOf(relay.url))), + ).maxByOrNull { it.createdAt } + ?.tags + ?.toList() + .orEmpty() + + private fun names(tags: List>) = tags.mapNotNull { it.firstOrNull() }.toSet() + + /** + * A 30166 is addressable, so this monitor has one record per relay — and it + * is not necessarily the only thing writing per-relay knowledge under that + * identity. A record rebuilt from this writer's own tags deletes the rest, + * and the loss is invisible: the event still signs and still parses. + */ + @Test + fun `an update keeps tags this writer does not own`() = + runBlocking { + val store = store() + val signer = NostrSignerInternal(KeyPair()) + val cache = RelayReachabilityStore(store, signer, ttlSeconds = 3600) + + cache.recordProbed(mapOf(live1 to 120L), emptySet(), now = 1_700_000_000) + // Something else records what it knows about the same relay, + // keeping what the monitor already put there. + val existing = tagsOf(store, signer, live1) + val withExtra = + RelayDiscoveryEvent.build(live1, "", createdAt = 1_700_000_001) { + existing.forEach { if (it.firstOrNull() != "d") add(it) } + add(arrayOf("redirect", "wss://canonical.example.com/")) + } + store.insert(signer.sign(withExtra)) + + cache.recordProbed(mapOf(live1 to 131L), emptySet(), now = 1_700_000_002) + + val after = tagsOf(store, signer, live1) + assertTrue("redirect" in names(after), "the update erased another writer's tag: ${names(after)}") + // ...and still replaced what it does own. + assertEquals("131", after.first { it[0] == RttType.OPEN.tagName }[1]) + } + + /** + * A store enforcing replaceable semantics rejects a record that is not + * strictly newer than the one it replaces. Two writers inside one second, + * or a peer whose clock runs ahead, are ordinary — and an update lost that + * way looks exactly like one that had nothing to say. + */ + @Test + fun `an update lands even when the record it replaces is newer than the clock`() = + runBlocking { + val store = store() + val signer = NostrSignerInternal(KeyPair()) + val cache = RelayReachabilityStore(store, signer, ttlSeconds = 3600) + + cache.recordProbed(mapOf(live1 to 120L), emptySet(), now = 1_700_003_600) + cache.recordProbed(mapOf(live1 to 131L), emptySet(), now = 1_700_000_000) + + assertEquals("131", tagsOf(store, signer, live1).first { it[0] == RttType.OPEN.tagName }[1]) + } + + /** A relay that went down must lose its rtt, or it still reads as live. */ + @Test + fun `a dead update clears the rtt it replaces`() = + runBlocking { + val store = store() + val signer = NostrSignerInternal(KeyPair()) + val cache = RelayReachabilityStore(store, signer, ttlSeconds = 3600) + + cache.recordProbed(mapOf(live1 to 120L), emptySet(), now = 1_700_000_000) + cache.record(reachable = emptySet(), dead = setOf(live1), now = 1_700_000_100) + + assertTrue(RttType.OPEN.tagName !in names(tagsOf(store, signer, live1))) + assertTrue(cache.snapshot(now = 1_700_000_200).isKnownDead(live1)) + } + + /** Only OUR records merge: republishing another monitor's tags under this key would launder their claims. */ + @Test + fun `another monitor's record is not merged into ours`() = + runBlocking { + val store = store() + val signer = NostrSignerInternal(KeyPair()) + val other = NostrSignerInternal(KeyPair()) + val cache = RelayReachabilityStore(store, signer, ttlSeconds = 3600) + + val theirs = + RelayDiscoveryEvent.build(live1, "", createdAt = 1_700_000_000) { + add(arrayOf("redirect", "wss://not-ours.example.com/")) + } + store.insert(other.sign(theirs)) + + cache.recordProbed(mapOf(live1 to 120L), emptySet(), now = 1_700_000_100) + + assertTrue("redirect" !in names(tagsOf(store, signer, live1))) + } } From a81c43e1d4c32ae206077f3b14051ae6da49e54a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 18:04:15 +0000 Subject: [PATCH 162/227] fix(quartz): address code-review findings on the record merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five issues from a review pass on the previous commit, all in the new merge path. currentRecords() bound one SQL host parameter per relay with no chunking. Callers pass the whole relay universe — RelayProber's own measurement puts that at 16,507 — and a bundled SQLite refuses past 32,766 variables. The throw lands BEFORE anything is written, so an entire probe run's records are lost rather than one relay's. Chunked at 500, in the same range as the author chunking elsewhere. The created_at bump had no ceiling, so a stamp that once landed in the future was sticky: every later edit derived from the bad value and never re-anchored to now. Such a record never ages out of snapshot()'s TTL window (an isKnownDead verdict that can never expire) and relays enforcing future-timestamp limits reject every publish for it. Capped at 60s past now — a pathological record now costs the updates made while the clock catches up, and heals itself. writeOne owned all three rtt names but only ever measures rtt-open, so the reachable path deleted rtt-read/rtt-write taken by an observation — the exact silent loss this change exists to stop. It now owns rtt-open alone; only the dead path clears them all, which liveness semantics require. writeObserved owned the whole R tag name but can only prove `auth`, so it erased `R pow` and friends written by RelayProber. Ownership is now per VALUE, which is why edit() takes a predicate rather than a set of names. The read-modify-write spans a store round trip and IEventStore exposes no read inside a transaction, so a concurrent writer to the same address can still win the race and get our stale insert rejected. That cannot be closed at this layer; it is now isolated per relay so one loser does not end the loop and silently drop every relay after it. Test plan: ./gradlew :quartz:jvmTest — 4,075 tests, all passing. Four new cases, one per fixable finding: a flush wider than one chunk writing every relay, a far-future record not being pushed further ahead, a reachable update keeping latencies it never measured, and an observation clearing only `auth`. --- .../reachability/RelayReachabilityStore.kt | 138 +++++++++++++----- .../RelayReachabilityStoreTest.kt | 92 +++++++++++- 2 files changed, 189 insertions(+), 41 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt index 772fd3524a..0c993461b1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt @@ -143,8 +143,8 @@ class RelayReachabilityStore( rttOpenMs: Long = 0, ) { val current = currentRecords(reachable + dead) - for (relay in reachable) writeOne(relay, up = true, now, rttOpenMs, current[relay]) - for (relay in dead) if (relay !in reachable) writeOne(relay, up = false, now, rttOpenMs, current[relay]) + for (relay in reachable) writeSafely { writeOne(relay, up = true, now, rttOpenMs, current[relay]) } + for (relay in dead) if (relay !in reachable) writeSafely { writeOne(relay, up = false, now, rttOpenMs, current[relay]) } } /** @@ -159,8 +159,8 @@ class RelayReachabilityStore( now: Long = TimeUtils.now(), ) { val current = currentRecords(reachableRttMs.keys + dead) - for ((relay, rtt) in reachableRttMs) writeOne(relay, up = true, now, rtt.coerceAtLeast(0), current[relay]) - for (relay in dead) if (relay !in reachableRttMs) writeOne(relay, up = false, now, 0, current[relay]) + for ((relay, rtt) in reachableRttMs) writeSafely { writeOne(relay, up = true, now, rtt.coerceAtLeast(0), current[relay]) } + for (relay in dead) if (relay !in reachableRttMs) writeSafely { writeOne(relay, up = false, now, 0, current[relay]) } } /** @@ -180,23 +180,44 @@ class RelayReachabilityStore( val current = currentRecords(reported.map { it.url }) var written = 0 for (o in reported) { - writeObserved(o, now, current[o.url]) - written++ + if (writeSafely { writeObserved(o, now, current[o.url]) }) written++ } return written } + /** + * Run one relay's write, keeping its failure to that relay. + * + * The read-modify-write below spans a store round trip and [IEventStore] + * offers no read inside a transaction, so a concurrent writer to the same + * address can still win the race — and on a store enforcing replaceable + * semantics our now-stale insert is REJECTED. Unisolated, that one throw + * ends the loop and drops every relay after it; the run reports fewer + * records than it measured and nothing says why. + */ + private inline fun writeSafely(write: () -> Unit): Boolean = + try { + write() + true + } catch (e: Exception) { + false + } + private suspend fun writeObserved( o: RelayObserver.Observation, now: Long, current: RelayDiscoveryEvent?, ) { - // `R` is included in the owned set here and NOT in [writeOne]: an - // observation knows whether this relay challenged us, so it may clear a - // requirement that no longer holds. writeOne never learns that, so it - // leaves the tag alone rather than deleting what it cannot re-measure. + // Owns the `auth` REQUIREMENT VALUE, not the whole `R` tag name: an + // observation learns whether this relay challenged us and may clear + // that, but `R payment`, `R pow` and the negated forms — written by + // RelayProber among others — are somebody else's measurement. val template = - edit(o.url, now, current, OWNED_LIVENESS + RequirementTag.TAG_NAME) { + edit(o.url, now, current, { tag -> + tag.firstOrNull() == NetworkTypeTag.TAG_NAME || + tag.firstOrNull() in ALL_RTT || + (tag.firstOrNull() == RequirementTag.TAG_NAME && tag.getOrNull(1) == AUTH_REQUIREMENT) + }) { networkType(networkTypeOf(o.url)) if (o.reachable) { // Liveness is the presence of rtt-open, per NIP-66. A relay we @@ -211,7 +232,7 @@ class RelayReachabilityStore( // Observed, not read off NIP-11: this relay actually challenged // us. A relay advertising open reads and then demanding AUTH is // exactly what a monitor exists to catch. - if (o.authRequired) requirement("auth") + if (o.authRequired) requirement(AUTH_REQUIREMENT) } store.insert(signer.sign(template)) } @@ -223,8 +244,19 @@ class RelayReachabilityStore( rttOpenMs: Long, current: RelayDiscoveryEvent?, ) { + // Owns every rtt only when writing a DEAD record: liveness is the + // presence of rtt-open, so a relay that went down must lose all of + // them. On the reachable path it owns rtt-open alone — deleting a + // `rtt-read` this call never measured is the same silent loss this + // whole change exists to stop. + val owned = + if (up) { + { tag: Array -> tag.firstOrNull() == NetworkTypeTag.TAG_NAME || tag.firstOrNull() == RttType.OPEN.tagName } + } else { + { tag: Array -> tag.firstOrNull() == NetworkTypeTag.TAG_NAME || tag.firstOrNull() in ALL_RTT } + } val template = - edit(relay, now, current, OWNED_LIVENESS) { + edit(relay, now, current, owned) { networkType(networkTypeOf(relay)) if (up) rtt(RttType.OPEN, rttOpenMs) } @@ -244,29 +276,40 @@ class RelayReachabilityStore( * record — it just says less than it did, and the reader downstream has no * way to know something was lost. * - * [owned] is what this writer measured and may therefore replace. - * Everything else is carried across untouched, including tags this version - * of quartz has never heard of. + * [owns] decides what this writer measured and may therefore replace — a + * predicate rather than a set of names, because ownership is sometimes per + * VALUE: an observation may clear `R auth` without touching the `R pow` + * another writer measured. Everything it does not claim is carried across + * untouched, including tags this version of quartz has never heard of. * * The timestamp is `max(now, current + 1)`, not `now`: a store enforcing * replaceable semantics REJECTS a record that is not strictly newer than * the one it replaces, and two writers inside the same second — or a peer - * whose clock runs ahead of ours — are ordinary. An update lost that way is - * indistinguishable from one that had nothing to say. + * whose clock runs slightly ahead — are ordinary. An update lost that way + * is indistinguishable from one that had nothing to say. + * + * That bump is CAPPED at [MAX_FUTURE_SKEW_SECONDS] past `now`. Without a + * ceiling a `created_at` that once landed in the future is sticky: every + * later edit derives from the bad value and never re-anchors, so the record + * never ages out of [snapshot]'s TTL window (a stale `isKnownDead` that can + * never expire) and relays enforcing future-timestamp limits reject + * everything this monitor publishes for that relay. Capped, a pathological + * record costs the updates made while `now` catches up — bounded, and it + * heals itself — instead of poisoning the slot permanently. */ private fun edit( relay: NormalizedRelayUrl, now: Long, current: RelayDiscoveryEvent?, - owned: Set, + owns: (Array) -> Boolean, measured: TagArrayBuilder.() -> Unit, ) = RelayDiscoveryEvent.build( relay, current?.content ?: "", - createdAt = maxOf(now, (current?.createdAt ?: 0L) + 1), + createdAt = minOf(maxOf(now, (current?.createdAt ?: 0L) + 1), now + MAX_FUTURE_SKEW_SECONDS), ) { current?.tags?.forEach { tag -> - if (tag.firstOrNull() != "d" && tag.firstOrNull() !in owned) add(tag) + if (tag.firstOrNull() != "d" && !owns(tag)) add(tag) } measured() } @@ -279,32 +322,47 @@ class RelayReachabilityStore( */ private suspend fun currentRecords(relays: Collection): Map { if (relays.isEmpty()) return emptyMap() - val held = - store.query( - Filter( - kinds = listOf(RelayDiscoveryEvent.KIND), - authors = listOf(signer.pubKey), - tags = mapOf("d" to relays.map { it.url }.distinct()), - ), - ) - val out = HashMap(held.size) - for (ev in held) { - val relay = ev.relay() ?: continue - val seen = out[relay] - if (seen == null || ev.createdAt > seen.createdAt) out[relay] = ev + val out = HashMap() + // CHUNKED: a `d` filter binds one host parameter per url, and callers + // pass the whole relay universe — RelayProber's own measurement puts + // that at 16,507. A bundled SQLite refuses past 32,766 variables, and + // the throw would land BEFORE anything was written, losing an entire + // probe run's records rather than one relay's. + for (chunk in relays.map { it.url }.distinct().chunked(RELAYS_PER_QUERY)) { + val held = + store.query( + Filter(kinds = listOf(RelayDiscoveryEvent.KIND), authors = listOf(signer.pubKey), tags = mapOf("d" to chunk)), + ) + for (ev in held) { + val relay = ev.relay() ?: continue + val seen = out[relay] + if (seen == null || ev.createdAt > seen.createdAt) out[relay] = ev + } } return out } companion object { + /** Every rtt tag name. A DEAD record must clear all of them: liveness is the presence of `rtt-open`. */ + private val ALL_RTT = setOf(RttType.OPEN.tagName, RttType.READ.tagName, RttType.WRITE.tagName) + + /** The one NIP-66 requirement an observation can prove: the relay challenged us. */ + const val AUTH_REQUIREMENT = "auth" + /** - * The tags this class measures on every write, and may therefore - * replace. A dead record must be able to CLEAR a stale rtt — liveness - * is the presence of `rtt-open` — so all three rtt types are owned even - * though only `rtt-open` is written by every path. + * How far past `now` an edit may stamp itself to clear a record that + * is already ahead of the clock. Enough to cover ordinary skew between + * two writers; small enough that a pathological record heals in + * minutes rather than never. See [edit]. */ - private val OWNED_LIVENESS = - setOf(NetworkTypeTag.TAG_NAME, RttType.OPEN.tagName, RttType.READ.tagName, RttType.WRITE.tagName) + const val MAX_FUTURE_SKEW_SECONDS = 60L + + /** + * Urls per `d` lookup. Well under a bundled SQLite's 32,766-variable + * ceiling, and in the same range as the author chunking elsewhere in + * this codebase. + */ + const val RELAYS_PER_QUERY = 500 /** Default freshness window: a relay's status is trusted for a day, then re-probed. */ const val DEFAULT_TTL_SECONDS = 24L * 60 * 60 diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt index 8b338bd72e..3d5422230a 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkType +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RequirementTag import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType import kotlinx.coroutines.runBlocking import kotlin.test.Test @@ -176,12 +177,101 @@ class RelayReachabilityStoreTest { val signer = NostrSignerInternal(KeyPair()) val cache = RelayReachabilityStore(store, signer, ttlSeconds = 3600) - cache.recordProbed(mapOf(live1 to 120L), emptySet(), now = 1_700_003_600) + // Ordinary skew: the record ahead of our clock by seconds, which is + // what two writers or a slightly fast peer produce. + cache.recordProbed(mapOf(live1 to 120L), emptySet(), now = 1_700_000_010) cache.recordProbed(mapOf(live1 to 131L), emptySet(), now = 1_700_000_000) assertEquals("131", tagsOf(store, signer, live1).first { it[0] == RttType.OPEN.tagName }[1]) } + /** + * Without a ceiling the bump is sticky: a record that once landed in the + * future is derived from forever, so it never ages out of the TTL window + * and relays enforcing future-timestamp limits reject every publish. + */ + @Test + fun `a record already far in the future is never pushed further ahead`() = + runBlocking { + val store = store() + val signer = NostrSignerInternal(KeyPair()) + val cache = RelayReachabilityStore(store, signer, ttlSeconds = 3600) + val now = 1_700_000_000L + + cache.recordProbed(mapOf(live1 to 120L), emptySet(), now = now + 86_400) + cache.recordProbed(mapOf(live1 to 131L), emptySet(), now = now) + + val held = + store + .query( + Filter(kinds = listOf(RelayDiscoveryEvent.KIND), authors = listOf(signer.pubKey), tags = mapOf("d" to listOf(live1.url))), + ).maxByOrNull { it.createdAt } + assertEquals(now + 86_400, held?.createdAt, "the pathological stamp was carried forward instead of capped") + } + + /** writeOne measures rtt-open only; deleting a read/write latency it never took is the loss this guards. */ + @Test + fun `a reachable update keeps latencies it did not measure`() = + runBlocking { + val store = store() + val signer = NostrSignerInternal(KeyPair()) + val cache = RelayReachabilityStore(store, signer, ttlSeconds = 3600) + + val observed = RelayObserver() + observed.record(live1, true, 100L, null) + observed.observationOf(live1)?.rttReadMs = 55L + cache.record(observed.collectUnreported(), now = 1_700_000_000) + assertTrue(RttType.READ.tagName in names(tagsOf(store, signer, live1))) + + cache.recordProbed(mapOf(live1 to 120L), emptySet(), now = 1_700_000_100) + + val after = tagsOf(store, signer, live1) + assertEquals("55", after.first { it[0] == RttType.READ.tagName }[1], "rtt-read was deleted by a writer that never measured it") + assertEquals("120", after.first { it[0] == RttType.OPEN.tagName }[1]) + } + + /** An observation proves `R auth` and nothing else; other requirements belong to whoever measured them. */ + @Test + fun `an observation clears only the auth requirement`() = + runBlocking { + val store = store() + val signer = NostrSignerInternal(KeyPair()) + val cache = RelayReachabilityStore(store, signer, ttlSeconds = 3600) + + cache.recordProbed(mapOf(live1 to 120L), emptySet(), now = 1_700_000_000) + val existing = tagsOf(store, signer, live1) + val withReqs = + RelayDiscoveryEvent.build(live1, "", createdAt = 1_700_000_001) { + existing.forEach { if (it.firstOrNull() != "d") add(it) } + add(arrayOf(RequirementTag.TAG_NAME, "pow")) + add(arrayOf(RequirementTag.TAG_NAME, RelayReachabilityStore.AUTH_REQUIREMENT)) + } + store.insert(signer.sign(withReqs)) + + // Reached without a challenge: auth no longer holds, pow was never ours. + val observed = RelayObserver() + observed.record(live1, true, 100L, null) + cache.record(observed.collectUnreported(), now = 1_700_000_002) + + val after = tagsOf(store, signer, live1).filter { it[0] == RequirementTag.TAG_NAME }.map { it[1] } + assertTrue("pow" in after, "another writer's requirement was erased: " + after) + assertTrue(RelayReachabilityStore.AUTH_REQUIREMENT !in after, "auth should have been cleared: " + after) + } + + /** One `d` filter binds one host parameter per url, and callers pass the whole relay universe. */ + @Test + fun `a flush wider than one query chunk still writes every relay`() = + runBlocking { + val store = store() + val signer = NostrSignerInternal(KeyPair()) + val cache = RelayReachabilityStore(store, signer, ttlSeconds = 3600) + val many = (0 until RelayReachabilityStore.RELAYS_PER_QUERY * 2 + 7).map { RelayUrlNormalizer.normalize("wss://r" + it + ".example.com") } + + cache.record(reachable = many.toSet(), dead = emptySet(), now = 1_700_000_000) + + assertEquals(many.size, cache.snapshot(now = 1_700_000_100).live.size) + } + /** A relay that went down must lose its rtt, or it still reads as live. */ @Test fun `a dead update clears the rtt it replaces`() = From 56eec420e43d43e269007709204fe638a7b315ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 18:35:57 +0000 Subject: [PATCH 163/227] fix(quartz): correct the merge's ownership, guard and timestamp rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review pass on the merge itself. Three of these reverse choices made in the previous commit; the reasoning there was wrong. The created_at cap is gone. Capping the bump to a window past `now` looked prudent and was worse: a record already further ahead than the cap can then never be replaced, because every stamp we are willing to write is older than what is stored, so the relay's live/dead verdict freezes until the wall clock catches up — 24h in the test that shipped asserting that behaviour as correct. It did not even buy the freshness it claimed: snapshot() selects on `since` alone, so a future-stamped record sits inside the window either way. A record ahead of the clock is a defect in whatever produced it; this class's job is to keep updating it. Ownership is now the full liveness set on every write — `n`, all three rtt types, and both polarities of `R auth` — rather than the narrower per-path sets. A 30166 carries ONE created_at, so a tag carried across is re-dated as a current measurement: keeping a rtt-read from an earlier observation beside a fresh rtt-open republishes a stale latency as today's, which aggregators rank on, and RelayObserver documents exactly how wrong a queued rtt can be. Carrying `R auth` forward was worse still — only an observation can clear it and that needs the connection the flag discourages, so it became permanent, a regression against the rebuild this PR replaced. Owning only the positive auth form also let `R !auth` survive while `requirement("auth")` appended the opposite, publishing a record asserting both. The per-relay guard no longer swallows. It caught Exception, which includes CancellationException, so a shutdown flush wrapped in withTimeout — the pattern RelayMonitor.close() prescribes — could not abort and would grind through every remaining relay. And a caught failure went nowhere: collectUnreported() has already cleared the observation flags by then, so the measurement is lost for good while the run reports success. Cancellation now propagates, every relay is still attempted, and the first real failure is rethrown once the loop finishes. Also corrected a comment: the 16,507-relay figure is measured in RelayObserver, not RelayProber, and the SQLite ceiling is verified here rather than quoted — 32,765 `d` values pass, 32,766 fails. Test plan: ./gradlew :quartz:jvmTest — 4,078 tests, all passing. Four new cases: a future-stamped record still updatable, a stale rtt-read not re-dated, an auth wall not outliving its observation, and a run whose writes all fail reporting failure instead of success. --- .../reachability/RelayReachabilityStore.kt | 155 +++++++++++------- .../RelayReachabilityStoreTest.kt | 101 ++++++++++-- 2 files changed, 180 insertions(+), 76 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt index 0c993461b1..dace6321f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkTypeTag import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RequirementTag import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException /** * A durable, shareable relay-reachability cache backed by an [IEventStore] as @@ -143,8 +144,10 @@ class RelayReachabilityStore( rttOpenMs: Long = 0, ) { val current = currentRecords(reachable + dead) - for (relay in reachable) writeSafely { writeOne(relay, up = true, now, rttOpenMs, current[relay]) } - for (relay in dead) if (relay !in reachable) writeSafely { writeOne(relay, up = false, now, rttOpenMs, current[relay]) } + val up = reachable.toList() + val down = dead.filterNot { it in reachable } + eachRelay(up.size) { i -> writeOne(up[i], up = true, now, rttOpenMs, current[up[i]]) } + eachRelay(down.size) { i -> writeOne(down[i], up = false, now, rttOpenMs, current[down[i]]) } } /** @@ -159,8 +162,10 @@ class RelayReachabilityStore( now: Long = TimeUtils.now(), ) { val current = currentRecords(reachableRttMs.keys + dead) - for ((relay, rtt) in reachableRttMs) writeSafely { writeOne(relay, up = true, now, rtt.coerceAtLeast(0), current[relay]) } - for (relay in dead) if (relay !in reachableRttMs) writeSafely { writeOne(relay, up = false, now, 0, current[relay]) } + val up = reachableRttMs.toList() + val down = dead.filterNot { it in reachableRttMs } + eachRelay(up.size) { i -> writeOne(up[i].first, up = true, now, up[i].second.coerceAtLeast(0), current[up[i].first]) } + eachRelay(down.size) { i -> writeOne(down[i], up = false, now, 0, current[down[i]]) } } /** @@ -178,46 +183,53 @@ class RelayReachabilityStore( ): Int { val reported = observations.filter { it.reachable || it.error != null } val current = currentRecords(reported.map { it.url }) - var written = 0 - for (o in reported) { - if (writeSafely { writeObserved(o, now, current[o.url]) }) written++ - } - return written + return eachRelay(reported.size) { i -> writeObserved(reported[i], now, current[reported[i].url]) } } /** - * Run one relay's write, keeping its failure to that relay. + * Run every relay's write, then fail if any of them did. * - * The read-modify-write below spans a store round trip and [IEventStore] - * offers no read inside a transaction, so a concurrent writer to the same - * address can still win the race — and on a store enforcing replaceable - * semantics our now-stale insert is REJECTED. Unisolated, that one throw - * ends the loop and drops every relay after it; the run reports fewer - * records than it measured and nothing says why. + * The read-modify-write spans a store round trip and [IEventStore] offers + * no read inside a transaction, so a concurrent writer to the same address + * can win the race and our now-stale insert is REJECTED. One such throw + * must not end the loop and drop every relay after it — but it must not + * vanish either: [RelayObserver.collectUnreported] has already cleared the + * flags by the time this runs, so a swallowed failure loses the + * measurement for good and the caller cannot tell an empty run from a + * failed one. So: attempt all, remember the first failure, rethrow it. + * + * Cancellation is never caught. A shutdown flush wrapped in `withTimeout` + * — the pattern [RelayMonitor.close] prescribes — would otherwise be + * unabortable, grinding through every remaining relay with each write + * throwing and being swallowed. */ - private inline fun writeSafely(write: () -> Unit): Boolean = - try { - write() - true - } catch (e: Exception) { - false + private suspend inline fun eachRelay( + count: Int, + write: (Int) -> Unit, + ): Int { + var first: Exception? = null + var written = 0 + for (i in 0 until count) { + try { + write(i) + written++ + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (first == null) first = e + } } + first?.let { throw it } + return written + } private suspend fun writeObserved( o: RelayObserver.Observation, now: Long, current: RelayDiscoveryEvent?, ) { - // Owns the `auth` REQUIREMENT VALUE, not the whole `R` tag name: an - // observation learns whether this relay challenged us and may clear - // that, but `R payment`, `R pow` and the negated forms — written by - // RelayProber among others — are somebody else's measurement. val template = - edit(o.url, now, current, { tag -> - tag.firstOrNull() == NetworkTypeTag.TAG_NAME || - tag.firstOrNull() in ALL_RTT || - (tag.firstOrNull() == RequirementTag.TAG_NAME && tag.getOrNull(1) == AUTH_REQUIREMENT) - }) { + edit(o.url, now, current, ::ownsLiveness) { networkType(networkTypeOf(o.url)) if (o.reachable) { // Liveness is the presence of rtt-open, per NIP-66. A relay we @@ -244,19 +256,8 @@ class RelayReachabilityStore( rttOpenMs: Long, current: RelayDiscoveryEvent?, ) { - // Owns every rtt only when writing a DEAD record: liveness is the - // presence of rtt-open, so a relay that went down must lose all of - // them. On the reachable path it owns rtt-open alone — deleting a - // `rtt-read` this call never measured is the same silent loss this - // whole change exists to stop. - val owned = - if (up) { - { tag: Array -> tag.firstOrNull() == NetworkTypeTag.TAG_NAME || tag.firstOrNull() == RttType.OPEN.tagName } - } else { - { tag: Array -> tag.firstOrNull() == NetworkTypeTag.TAG_NAME || tag.firstOrNull() in ALL_RTT } - } val template = - edit(relay, now, current, owned) { + edit(relay, now, current, ::ownsLiveness) { networkType(networkTypeOf(relay)) if (up) rtt(RttType.OPEN, rttOpenMs) } @@ -288,14 +289,15 @@ class RelayReachabilityStore( * whose clock runs slightly ahead — are ordinary. An update lost that way * is indistinguishable from one that had nothing to say. * - * That bump is CAPPED at [MAX_FUTURE_SKEW_SECONDS] past `now`. Without a - * ceiling a `created_at` that once landed in the future is sticky: every - * later edit derives from the bad value and never re-anchors, so the record - * never ages out of [snapshot]'s TTL window (a stale `isKnownDead` that can - * never expire) and relays enforcing future-timestamp limits reject - * everything this monitor publishes for that relay. Capped, a pathological - * record costs the updates made while `now` catches up — bounded, and it - * heals itself — instead of poisoning the slot permanently. + * The bump is deliberately NOT capped to some window past `now`. Capping + * it looks prudent and is worse: a record already further ahead than the + * cap can then never be replaced at all, because every stamp we are willing + * to write is older than what is stored, so the relay's live/dead verdict + * freezes until the wall clock catches up. It does not even buy the thing + * it appears to — [snapshot] selects on `since` alone, so a future-stamped + * record sits inside the freshness window either way. A record stamped + * ahead of the clock is a defect in whatever produced it; this class's job + * is to keep updating it, not to freeze it. */ private fun edit( relay: NormalizedRelayUrl, @@ -306,7 +308,7 @@ class RelayReachabilityStore( ) = RelayDiscoveryEvent.build( relay, current?.content ?: "", - createdAt = minOf(maxOf(now, (current?.createdAt ?: 0L) + 1), now + MAX_FUTURE_SKEW_SECONDS), + createdAt = maxOf(now, (current?.createdAt ?: 0L) + 1), ) { current?.tags?.forEach { tag -> if (tag.firstOrNull() != "d" && !owns(tag)) add(tag) @@ -314,6 +316,35 @@ class RelayReachabilityStore( measured() } + /** + * The tags this class measures, and may therefore replace. + * + * Everything here expires together with the record: a 30166 carries ONE + * `created_at` for the whole document, so a tag carried across is re-dated + * as a current measurement. Keeping a `rtt-read` from an earlier + * observation beside a fresh `rtt-open` would republish a stale latency as + * today's — and [RelayObserver] documents exactly how wrong a queued rtt + * can be. So this class's own liveness facts are rewritten wholesale on + * every write, including clearing `R auth` when nothing re-asserts it: a + * permanent auth flag is worse than a missing one, because it discourages + * the very connection that could clear it. + * + * Both polarities of the auth requirement are owned. Owning only the + * positive form let `R !auth` survive while `requirement("auth")` appended + * the opposite, publishing a record that asserted both at once. + * + * Everything NOT matched here — `R pow`, `R payment`, annotations another + * writer keeps on this address, tags this version has never heard of — is + * somebody else's measurement and is carried across untouched. + */ + private fun ownsLiveness(tag: Array): Boolean = + when (tag.firstOrNull()) { + NetworkTypeTag.TAG_NAME -> true + in ALL_RTT -> true + RequirementTag.TAG_NAME -> tag.getOrNull(1) in AUTH_REQUIREMENT_FORMS + else -> false + } + /** * This monitor's own current record for each relay, in one query. * @@ -324,10 +355,13 @@ class RelayReachabilityStore( if (relays.isEmpty()) return emptyMap() val out = HashMap() // CHUNKED: a `d` filter binds one host parameter per url, and callers - // pass the whole relay universe — RelayProber's own measurement puts - // that at 16,507. A bundled SQLite refuses past 32,766 variables, and - // the throw would land BEFORE anything was written, losing an entire - // probe run's records rather than one relay's. + // pass the whole relay universe — the fan-out this module measures + // itself against is 16,507 relays (see RelayObserver). Measured on + // BundledSQLiteDriver, 32,765 `d` values pass and 32,766 fails with + // "too many SQL variables"; the throw lands BEFORE anything is + // written, so an entire probe run's records are lost rather than one + // relay's. The headroom here is deliberate — the ceiling is a property + // of the driver, not of this query. for (chunk in relays.map { it.url }.distinct().chunked(RELAYS_PER_QUERY)) { val held = store.query( @@ -349,13 +383,8 @@ class RelayReachabilityStore( /** The one NIP-66 requirement an observation can prove: the relay challenged us. */ const val AUTH_REQUIREMENT = "auth" - /** - * How far past `now` an edit may stamp itself to clear a record that - * is already ahead of the clock. Enough to cover ordinary skew between - * two writers; small enough that a pathological record heals in - * minutes rather than never. See [edit]. - */ - const val MAX_FUTURE_SKEW_SECONDS = 60L + /** Both polarities, so an update cannot leave the record asserting `auth` and `!auth` at once. */ + private val AUTH_REQUIREMENT_FORMS = setOf(AUTH_REQUIREMENT, "!$AUTH_REQUIREMENT") /** * Urls per `d` lookup. Well under a bundled SQLite's 32,766-variable diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt index 3d5422230a..073f961f14 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt @@ -186,12 +186,16 @@ class RelayReachabilityStoreTest { } /** - * Without a ceiling the bump is sticky: a record that once landed in the - * future is derived from forever, so it never ages out of the TTL window - * and relays enforcing future-timestamp limits reject every publish. + * A record stamped ahead of the clock is a defect in whatever produced it. + * Capping our stamp to some window past `now` looks prudent and is worse: + * every stamp we would write is then older than what is stored, so the + * insert is rejected and the relay's live/dead verdict freezes until the + * wall clock catches up. It does not even buy freshness — snapshot() + * selects on `since` alone, so the future record is inside the window + * either way. */ @Test - fun `a record already far in the future is never pushed further ahead`() = + fun `a record stamped ahead of the clock can still be updated`() = runBlocking { val store = store() val signer = NostrSignerInternal(KeyPair()) @@ -201,17 +205,17 @@ class RelayReachabilityStoreTest { cache.recordProbed(mapOf(live1 to 120L), emptySet(), now = now + 86_400) cache.recordProbed(mapOf(live1 to 131L), emptySet(), now = now) - val held = - store - .query( - Filter(kinds = listOf(RelayDiscoveryEvent.KIND), authors = listOf(signer.pubKey), tags = mapOf("d" to listOf(live1.url))), - ).maxByOrNull { it.createdAt } - assertEquals(now + 86_400, held?.createdAt, "the pathological stamp was carried forward instead of capped") + assertEquals("131", tagsOf(store, signer, live1).first { it[0] == RttType.OPEN.tagName }[1]) } - /** writeOne measures rtt-open only; deleting a read/write latency it never took is the loss this guards. */ + /** + * A 30166 carries ONE created_at, so a carried tag is re-dated as a current + * measurement. This class's own liveness facts must therefore be rewritten + * wholesale, or a stale rtt-read is republished as today's number — which + * aggregators rank on. + */ @Test - fun `a reachable update keeps latencies it did not measure`() = + fun `a later observation does not re-date an older latency`() = runBlocking { val store = store() val signer = NostrSignerInternal(KeyPair()) @@ -226,10 +230,81 @@ class RelayReachabilityStoreTest { cache.recordProbed(mapOf(live1 to 120L), emptySet(), now = 1_700_000_100) val after = tagsOf(store, signer, live1) - assertEquals("55", after.first { it[0] == RttType.READ.tagName }[1], "rtt-read was deleted by a writer that never measured it") + assertTrue(RttType.READ.tagName !in names(after), "a stale rtt-read was carried onto a fresh record") assertEquals("120", after.first { it[0] == RttType.OPEN.tagName }[1]) } + /** + * Only [writeObserved] can clear `auth`, and it needs a connection the flag + * discourages — so carrying it forward would make it permanent. It expires + * with the rest of this class's liveness facts. + */ + @Test + fun `an auth requirement does not outlive the observation that set it`() = + runBlocking { + val store = store() + val signer = NostrSignerInternal(KeyPair()) + val cache = RelayReachabilityStore(store, signer, ttlSeconds = 3600) + + val walled = RelayObserver() + walled.record(live1, true, 100L, null) + walled.observationOf(live1)?.authRequired = true + cache.record(walled.collectUnreported(), now = 1_700_000_000) + assertTrue(RequirementTag.TAG_NAME in names(tagsOf(store, signer, live1))) + + cache.recordProbed(mapOf(live1 to 120L), emptySet(), now = 1_700_000_100) + + assertTrue(RequirementTag.TAG_NAME !in names(tagsOf(store, signer, live1)), "the auth wall became permanent") + } + + /** Owning only the positive form left `!auth` in place while appending `auth`. */ + @Test + fun `an update never leaves the record asserting both auth polarities`() = + runBlocking { + val store = store() + val signer = NostrSignerInternal(KeyPair()) + val cache = RelayReachabilityStore(store, signer, ttlSeconds = 3600) + + cache.recordProbed(mapOf(live1 to 120L), emptySet(), now = 1_700_000_000) + val existing = tagsOf(store, signer, live1) + val negated = + RelayDiscoveryEvent.build(live1, "", createdAt = 1_700_000_001) { + existing.forEach { if (it.firstOrNull() != "d") add(it) } + add(arrayOf(RequirementTag.TAG_NAME, "!auth")) + } + store.insert(signer.sign(negated)) + + val walled = RelayObserver() + walled.record(live1, true, 100L, null) + walled.observationOf(live1)?.authRequired = true + cache.record(walled.collectUnreported(), now = 1_700_000_002) + + val reqs = tagsOf(store, signer, live1).filter { it[0] == RequirementTag.TAG_NAME }.map { it[1] } + assertEquals(listOf(RelayReachabilityStore.AUTH_REQUIREMENT), reqs, "record asserts contradictory requirements: " + reqs) + } + + /** + * collectUnreported() has already cleared the flags by the time a write + * runs, so a swallowed failure loses the measurement for good and an empty + * run is indistinguishable from a failed one. + */ + @Test + fun `a failing write is reported, not swallowed`() = + runBlocking { + val store = store() + val signer = NostrSignerInternal(KeyPair()) + val cache = RelayReachabilityStore(store, signer, ttlSeconds = 3600) + store.close() + + var threw = false + try { + cache.recordProbed(mapOf(live1 to 120L, live2 to 130L), emptySet(), now = 1_700_000_000) + } catch (e: Exception) { + threw = true + } + assertTrue(threw, "every write failed and the run reported success") + } + /** An observation proves `R auth` and nothing else; other requirements belong to whoever measured them. */ @Test fun `an observation clears only the auth requirement`() = From 4b49032e52ee4357d95afbfbad6f20de30a0a567 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 18:59:17 +0000 Subject: [PATCH 164/227] fix(quartz): merge probe verdicts into the record they replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #3882, which made RelayReachabilityStore edit a relay's kind:30166 rather than rebuild it. toDiscoveryEventTemplate was the remaining co-writer: it builds from the verdict alone, so a consumer following its own KDoc — sign with the monitor key, insert — wipes whatever else is on that address, undoing the merge for exactly the writer #3882 set out to protect. It now takes the current record and carries across every tag the verdict did not measure, on the same rules: - Ownership is per writer, and this one measures more than the store does. A write probe determines `pow` from the OK message, so `R pow` is its own finding and must not be re-dated from an older record. Without a ReadWriteVerdict it never exercised the write path, so the same tag is somebody else's and is carried across untouched — hence the hasReadWrite flag rather than a fixed set. - Both polarities of each requirement are owned, so an update cannot leave the record asserting `pow` and `!pow` at once. - created_at is max(requested, current + 1): a store enforcing replaceable semantics rejects anything not strictly newer, and the probe would be lost with nothing to show for the round trip. The parameter defaults to null, so every existing caller keeps today's behaviour and the change is additive. Test plan: ./gradlew :quartz:jvmTest — 4,081 tests, all passing. Three new cases in RelayProberFlowTest: a foreign tag and an unmeasured `R pow` surviving a probe without a write verdict, a stale `R pow` being replaced when the write path DID run, and the stamp landing past the record it replaces. --- .../reachability/RelayProber.kt | 49 ++++++++++++++++- .../reachability/RelayProberFlowTest.kt | 55 +++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt index 711c38551a..9c56c43c99 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.networkType import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.requirement import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.rtt +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkTypeTag +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RequirementTag import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap @@ -430,8 +432,18 @@ class RelayProber( fun RelayProber.Verdict.toDiscoveryEventTemplate( createdAt: Long = TimeUtils.now(), readWrite: RelayProber.ReadWriteVerdict? = null, + current: RelayDiscoveryEvent? = null, ): EventTemplate = - RelayDiscoveryEvent.build(relay, createdAt = createdAt) { + RelayDiscoveryEvent.build( + relay, + current?.content ?: "", + // Strictly newer than what it replaces, or a store enforcing + // replaceable semantics rejects it and the probe is lost silently. + createdAt = maxOf(createdAt, (current?.createdAt ?: 0L) + 1), + ) { + current?.tags?.forEach { tag -> + if (tag.firstOrNull() != "d" && !probeOwns(tag, readWrite != null)) add(tag) + } networkType(RelayReachabilityStore.networkTypeOf(relay)) if (reachable) rtt(RttType.OPEN, rttOpenMs.coerceAtLeast(0)) if (readWrite != null) { @@ -441,6 +453,37 @@ fun RelayProber.Verdict.toDiscoveryEventTemplate( val authWalled = error?.startsWith("closed:auth-required") == true || readWrite?.writeMessage?.startsWith("auth-required") == true - if (authWalled) requirement("auth") - if (readWrite?.writeMessage?.startsWith("pow:") == true) requirement("pow") + if (authWalled) requirement(RelayReachabilityStore.AUTH_REQUIREMENT) + if (readWrite?.writeMessage?.startsWith("pow:") == true) requirement(POW_REQUIREMENT) + } + +/** The NIP-66 requirement a write probe can prove, alongside `auth`. */ +private const val POW_REQUIREMENT = "pow" + +/** + * What a probe verdict measured, and may therefore replace in [current]. + * + * A 30166 carries ONE `created_at`, so any tag carried across is re-dated as a + * current measurement — this verdict's own facts must be rewritten wholesale or + * a stale latency is republished as today's number. + * + * [hasReadWrite] narrows it: without a [RelayProber.ReadWriteVerdict] this probe + * never exercised the write path, so `R pow` is somebody else's finding and is + * carried across rather than deleted. Both polarities of each requirement are + * owned, so an update cannot leave the record asserting `pow` and `!pow` at once. + */ +private fun probeOwns( + tag: Array, + hasReadWrite: Boolean, +): Boolean = + when (tag.firstOrNull()) { + NetworkTypeTag.TAG_NAME -> true + RttType.OPEN.tagName, RttType.READ.tagName, RttType.WRITE.tagName -> true + RequirementTag.TAG_NAME -> + when (tag.getOrNull(1)) { + RelayReachabilityStore.AUTH_REQUIREMENT, "!" + RelayReachabilityStore.AUTH_REQUIREMENT -> true + POW_REQUIREMENT, "!" + POW_REQUIREMENT -> hasReadWrite + else -> false + } + else -> false } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt index f0a6be04ca..05bc37fc08 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -472,4 +473,58 @@ class RelayProberFlowTest { assertTrue(listOf("n", "tor") in tagsOf(template)) } + // ---- merging into an existing record ---------------------------------- + + /** + * A 30166 is addressable, so a consumer that follows this function's KDoc — + * sign with the monitor key, insert — replaces whatever else is on that + * address. Built from the verdict alone it deletes it. + */ + @Test + fun probeTemplateKeepsTagsItDidNotMeasure() { + val verdict = RelayProber.Verdict(fast, reachable = true, rttOpenMs = 120, rttEoseMs = 200, error = null) + val existing = + RelayDiscoveryEvent( + "id", + "pubkey", + 1_000, + arrayOf( + arrayOf("d", fast.url), + arrayOf("R", "pow"), + arrayOf("redirect", "wss://canonical.example.com/"), + ), + "", + "sig", + ) + + val tags = tagsOf(verdict.toDiscoveryEventTemplate(createdAt = 2_000, current = existing)) + + assertTrue(listOf("redirect", "wss://canonical.example.com/") in tags, "a foreign tag was deleted: $tags") + // No write probe ran, so `R pow` is somebody else's finding. + assertTrue(listOf("R", "pow") in tags, "an unmeasured requirement was deleted: $tags") + } + + /** With a write verdict the probe DOES measure pow, so a stale one must not be re-dated. */ + @Test + fun probeTemplateReplacesRequirementsItDidMeasure() { + val verdict = RelayProber.Verdict(fast, reachable = true, rttOpenMs = 120, rttEoseMs = 200, error = null) + val existing = + RelayDiscoveryEvent("id", "pubkey", 1_000, arrayOf(arrayOf("d", fast.url), arrayOf("R", "pow")), "", "sig") + val clean = RelayProber.ReadWriteVerdict(fast, rttReadMs = 10, rttWriteMs = 20, writeAccepted = true, writeMessage = null) + + val tags = tagsOf(verdict.toDiscoveryEventTemplate(createdAt = 2_000, readWrite = clean, current = existing)) + + assertTrue(listOf("R", "pow") !in tags, "a stale requirement was carried onto a fresh measurement: $tags") + } + + /** Replaceable ordering: an update not strictly newer is rejected and lost. */ + @Test + fun probeTemplateStampsPastTheRecordItReplaces() { + val verdict = RelayProber.Verdict(fast, reachable = true, rttOpenMs = 120, rttEoseMs = 200, error = null) + val existing = RelayDiscoveryEvent("id", "pubkey", 9_000, arrayOf(arrayOf("d", fast.url)), "", "sig") + + val template = verdict.toDiscoveryEventTemplate(createdAt = 2_000, current = existing) + + assertTrue(template.createdAt > 9_000, "stamped ${template.createdAt}, which cannot replace 9000") + } } From f2160f626425d5edaf6edb2a62add8644826ef45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 21:02:37 +0000 Subject: [PATCH 165/227] test(quartz): pin what a soft-banned Concord staffer can still do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORD-04 §4 row 3 of docs/concord-banlist-rank-conformance.md was left open as "a genuine fixpoint-ordering question". This reproduces what that gap costs. ConcordCommunityState.fold gates METADATA/CHANNEL/INVITE through authority.hasPermission (`!isBanned && ..`), but ROLE, GRANT and BANLIST are gated inside AuthorityResolver.resolve by holdsManageRoles / bitsOf / effectivePermissionsOf, none of which consult the banlist — and none of which can, as written, since the roles/grants fixpoint settles before `banned` is computed. So half the Control Plane honors a ban and half is blind to it. A banned member who still holds control_root therefore keeps the roster: they revoke the surviving moderators, retire the roles beneath them, ban everyone they outrank, and — since a role edition they author is honored — mint a fresh, unbanned npub at the next position down. That npub passes every ban-aware gate, so it tombstones the channels (terminal ids), rewrites the metadata, and, being a non-banned BAN holder, is accepted as a rotator by drainConcordRekeys. The tests assert the CURRENT, VULNERABLE behaviour so it cannot regress silently; each ESCALATION assertion is to be inverted, not deleted, when the ordering rule lands. Two companions pin what the fix must preserve: self-unban and puppet-unban both stay refused, closed already by the delta rank rule. Also records why a chain-local fix is insufficient — forking the banlist at genesis dodges any "was the author banned by this edition's parent" rule, and §4's re-heal union carries the rogue bans in anyway. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- .../cord04Roles/BannedStaffEscalationTest.kt | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt new file mode 100644 index 0000000000..8ad22aba6b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * What a **soft-banned staffer** can still do to a community — the reproduction behind + * `docs/concord-banlist-rank-conformance.md` §4 row 3, which the report left open as "a genuine + * fixpoint-ordering question, not a plain oversight". + * + * The asymmetry these tests pin: [ConcordCommunityState.fold] gates METADATA / CHANNEL / INVITE + * through `authority.hasPermission`, which is `!isBanned && …`, but ROLE, GRANT and BANLIST are + * gated *inside* [AuthorityResolver.resolve] by `holdsManageRoles` / `bitsOf` / + * `effectivePermissionsOf` — none of which consult the banlist. Nor could they as written: the + * roles/grants fixpoint runs before `banned` is computed at all. So half the Control Plane honors + * a ban and half is structurally blind to it, and a banned member who still holds `control_root` + * keeps full authority over the roster. + * + * **These tests assert the CURRENT, VULNERABLE behaviour**, so the escalation cannot regress + * silently or be "fixed" by accident without someone noticing. Every `ESCALATION:` assertion here + * must be INVERTED — not deleted — when the ordering rule lands. [selfUnbanIsStillRefused] and + * [aJuniorPuppetCannotLiftASeniorsBan] are the opposite: they pin behaviour the fix must preserve. + * + * Note for whoever writes that fix: a chain-local rule ("the author must not be banned by the state + * their edition chains from") is NOT sufficient — see + * [aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan]. The rule has to bind + * CORD-04 §4's re-heal union too. + */ +class BannedStaffEscalationTest { + private val owner = "0f".repeat(32) + private val alice = "a1".repeat(32) // Admin, position 1 — the member who gets banned + private val bob = "b2".repeat(32) // Mod, position 5 + private val carol = "c3".repeat(32) // plain member, no role + private val puppet = "e5".repeat(32) // a fresh npub alice controls + + private val adminRole = "11".repeat(32) + private val modRole = "22".repeat(32) + private val puppetRole = "33".repeat(32) + + private val banlistEntity = "44".repeat(32) + private val channelEntity = "55".repeat(32) + private val metadataEntity = "66".repeat(32) + private val bobGrantEntity = "32".repeat(32) + private val puppetGrantEntity = "35".repeat(32) + + // MANAGE_ROLES|MANAGE_CHANNELS|MANAGE_METADATA|KICK|BAN|CREATE_INVITE = 1+2+4+8+16+64 + private val adminJson = """{"name":"Admin","position":1,"permissions":"95"}""" + private val modJson = """{"name":"Mod","position":5,"permissions":"24"}""" // KICK|BAN + + private fun edition( + kind: ControlEntityKind, + entity: String, + version: Long, + prev: ByteArray?, + content: String, + author: String, + rumorId: String, + ) = ControlEdition(kind, entity.hexToByteArray(), version, prev, null, content, author, rumorId, 0) + + private fun role( + id: String, + json: String, + author: String = owner, + version: Long = 0, + prev: ByteArray? = null, + ) = edition(ControlEntityKind.ROLE, id, version, prev, json, author, "role-$id-$version-$author") + + private fun grant( + coordinate: String, + member: String, + roleIds: List, + author: String, + version: Long = 0, + prev: ByteArray? = null, + ) = edition( + ControlEntityKind.GRANT, + coordinate, + version, + prev, + """{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""", + author, + "grant-$coordinate-$version-$author", + ) + + private fun banlist( + author: String, + version: Long, + prev: ByteArray?, + vararg banned: String, + ) = edition( + ControlEntityKind.BANLIST, + banlistEntity, + version, + prev, + "[${banned.joinToString(",") { "\"$it\"" }}]", + author, + "ban-$version-$author", + ) + + private fun channel( + json: String, + author: String, + version: Long, + prev: ByteArray?, + ) = edition(ControlEntityKind.CHANNEL, channelEntity, version, prev, json, author, "chan-$version-$author") + + private fun metadata( + json: String, + author: String, + version: Long, + prev: ByteArray?, + ) = edition(ControlEntityKind.METADATA, metadataEntity, version, prev, json, author, "meta-$version-$author") + + private val channelV0 = channel("""{"name":"general"}""", owner, 0, null) + private val metadataV0 = metadata("""{"name":"My Community"}""", owner, 0, null) + private val bobGrantV0 = grant(bobGrantEntity, bob, listOf(modRole), owner) + private val modRoleV0 = role(modRole, modJson) + + /** The owner-authored community every test starts from: two roles, two grants, a channel, metadata. */ + private fun community() = + mutableListOf( + role(adminRole, adminJson), + modRoleV0, + grant("31".repeat(32), alice, listOf(adminRole), owner), + bobGrantV0, + channelV0, + metadataV0, + ) + + /** The owner bans alice. Genesis of the banlist, so every test can fork or chain off it. */ + private val ownerBansAlice = banlist(owner, 0, null, alice) + + /** Alice, already banned, mints a role just below herself and hands it to a fresh npub. */ + private fun aliceMintsAPuppet() = + listOf( + role(puppetRole, """{"name":"Puppet","position":2,"permissions":"95"}""", author = alice), + grant(puppetGrantEntity, puppet, listOf(puppetRole), author = alice), + ) + + @Test + fun aBanStripsTheAuthorityCheckedByFoldButNotTheOneCheckedByTheResolver() { + val r = AuthorityResolver.resolve(community() + ownerBansAlice, owner) + + assertTrue(r.isBanned(alice), "the owner's ban lands") + assertFalse(r.hasPermission(alice, ConcordPermissions.MANAGE_ROLES), "the ban-aware check refuses her") + // ...but this is the one every ROLE/GRANT/BANLIST gate inside resolve() actually consults. + assertTrue( + r.effectivePermissions(alice).has(ConcordPermissions.MANAGE_ROLES), + "ESCALATION: a banned staffer keeps the permissions the resolver's own gates read", + ) + } + + @Test + fun aBannedAdminPromotesAFreshSockpuppetToAdmin() { + val r = AuthorityResolver.resolve(community() + ownerBansAlice + aliceMintsAPuppet(), owner) + + assertEquals(2, r.rank(puppet), "ESCALATION: the banned admin's role edition is honored") + assertFalse(r.isBanned(puppet), "the puppet is a clean npub — nothing to filter it on") + assertTrue( + r.hasPermission(puppet, ConcordPermissions.MANAGE_CHANNELS), + "ESCALATION: a banned member minted a live admin with the ban-aware check passing", + ) + } + + @Test + fun theSockpuppetDeletesEveryChannelAndRewritesTheMetadata() { + val editions = + community() + ownerBansAlice + aliceMintsAPuppet() + + // A channel tombstone is terminal — CORD-03: the id is never reused. + channel("""{"name":"general","deleted":true}""", puppet, 1, channelV0.hash) + + metadata("""{"name":"Owned by the guy you banned"}""", puppet, 1, metadataV0.hash) + + val state = ConcordCommunityState.fold(editions, owner) + + assertEquals(0, state.channels.size, "ESCALATION: the community's channels are irrecoverably tombstoned") + assertEquals("Owned by the guy you banned", state.metadata?.name, "ESCALATION: and its identity rewritten") + } + + @Test + fun theSockpuppetBansEveryMemberBeneathIt() { + val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash, alice, bob, carol) + + val r = AuthorityResolver.resolve(editions, owner) + + assertTrue(r.isBanned(bob), "ESCALATION: the surviving moderator is silenced, losing all authority with it") + assertTrue(r.isBanned(carol), "ESCALATION: and the plain members with them") + } + + @Test + fun aBannedAdminBansEveryoneBeneathThemWithoutNeedingAPuppetAtAll() { + val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash, alice, bob, carol) + + val r = AuthorityResolver.resolve(editions, owner) + + assertTrue(r.isBanned(bob), "ESCALATION: banGate reads effectivePermissionsOf, which ignores her own ban") + assertTrue(r.isBanned(carol), "ESCALATION: same") + } + + @Test + fun aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan() { + // The same attack as above, except her edition does NOT chain onto the edition that banned + // her — it forks at genesis. So a rule that only asks "was the author banned by this + // edition's parent?" never sees her ban, and CORD-04 §4's re-heal union carries her bans in + // regardless. Any fix has to bind the union, not just the chain. + val editions = community() + ownerBansAlice + banlist(alice, 0, null, bob, carol) + + val r = AuthorityResolver.resolve(editions, owner) + + assertTrue(r.isBanned(alice), "the owner's ban survives the fork — the union is down-only") + assertTrue(r.isBanned(bob), "ESCALATION: and so does the banned admin's, healed in as a concurrent ban") + assertTrue(r.isBanned(carol), "ESCALATION: same") + } + + @Test + fun aBannedAdminRevokesTheSurvivingModerators() { + val editions = community() + ownerBansAlice + grant(bobGrantEntity, bob, emptyList(), author = alice, version = 1, prev = bobGrantV0.hash) + + val r = AuthorityResolver.resolve(editions, owner) + + assertEquals(null, r.rank(bob), "ESCALATION: a banned admin stripped a live moderator's roles") + assertFalse(r.hasPermission(bob, ConcordPermissions.BAN), "ESCALATION: leaving nobody but the owner able to act") + } + + @Test + fun aBannedAdminDeletesEveryRoleBeneathThem() { + val tombstone = role(modRole, """{"name":"Mod","position":5,"permissions":"24","deleted":true}""", author = alice, version = 1, prev = modRoleV0.hash) + + val r = AuthorityResolver.resolve(community() + ownerBansAlice + tombstone, owner) + + assertEquals(null, r.roles()[modRole], "ESCALATION: a banned admin retired a role beneath them") + assertEquals(null, r.rank(bob), "ESCALATION: every holder of it silently loses their standing") + } + + @Test + fun selfUnbanIsStillRefused() { + // docs/concord-banlist-rank-conformance.md §4 row 3, the half that IS closed: the delta rule + // gates removals too, and strict outranking means nobody outranks themselves. + val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash) + + assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a banned member may not lift their own ban") + } + + @Test + fun aJuniorPuppetCannotLiftASeniorsBan() { + // The puppet sits at position 2 and alice at 1, and no edition may claim a position at or + // above its own signer — so her delegation chain can only ever descend. Nothing she mints + // can outrank her, and so nothing she mints can unban her. + val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash) + + assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the puppet does not outrank its creator") + } +} From fb7c710a88a6940ff783dbbd92025e4d326e312b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 21:11:54 +0000 Subject: [PATCH 166/227] test(geode): pin that a Concord plane key cannot delete the channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A soft ban leaves the community_root in the ex-member's hands, so they keep deriving the channel's stream key. CORD-01 signs every wrap with that shared key rather than with the author, so on the wire a Concord channel looks like a single author publishing everything — and NIP-09/NIP-62 authorize on the outer pubkey. Read naively that hands any ex-member a one-event wipe of the whole community's history, and geode's own Nip09DeletionTest guarantee ("a kind-5 from pubkey X cannot delete pubkey Y's events") would be vacuous inside a plane. It is refused, but only because of a rule written for something else: Event.owner() gives a kind-1059 to its p-tag RECIPIENT rather than its signer, and ConcordStreamEnvelope stamps a freshly random p-tag on every wrap. Each wrap is therefore owned by a one-time key nobody holds, attacker included. Neither half was written with this attack in mind and either one silently re-opens it, so both are pinned: two tests fail if ownership ever moves back to the signer, and a counterfactual (a wrap addressed to a real key IS deletable by its holder) fails the moment that p-tag becomes anything a member holds. Scope: this is our relay's rule, not the protocol's. A third-party relay that authorizes deletion by matching pubkey still hands every ex-member a wipe button, and a Refounding only protects the future. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- .../geode/ConcordPlaneKeyDeletionTest.kt | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 geode/src/test/kotlin/com/vitorpamplona/geode/ConcordPlaneKeyDeletionTest.kt diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/ConcordPlaneKeyDeletionTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/ConcordPlaneKeyDeletionTest.kt new file mode 100644 index 0000000000..15c5fcd7a1 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/ConcordPlaneKeyDeletionTest.kt @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.geode + +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Why a soft-banned member cannot delete a Concord community's history from the relay — and what + * keeps it that way. + * + * The worry is real. CORD-01 inverts NIP-59: every wrap on a plane is signed by the *shared stream + * key*, not by its author, and the true author only exists inside the encrypted seal. A member + * banned yesterday still derives `group_key("concord/channel", community_root, channel_id, epoch)` + * from the root they kept, so they can still sign events *as the channel itself*. If NIP-09 and + * NIP-62 authorized on the outer `pubkey`, [Nip09DeletionTest]'s "a kind-5 from pubkey X cannot + * delete pubkey Y's events" would be vacuous inside a plane: one event from any ex-member would + * erase the whole community's history. + * + * What stops it is [com.vitorpamplona.quartz.nip01Core.store.owner]: a kind-1059 gift wrap is + * controlled by its **p-tag recipient**, not its signer. Concord stamps a *freshly random* p-tag on + * every wrap ([ConcordStreamEnvelope.wrapSeal]), so each wrap is owned by a one-time key that + * nobody — attacker, author, or owner — ever holds. The channel is undeletable by construction. + * + * Both halves of that are load-bearing and neither was written for this reason, so both are pinned + * here: [theEphemeralPTagIsWhatMakesTheChannelUndeletable] fails the moment the p-tag becomes a + * real key, and the first two tests fail the moment ownership goes back to the signer. + * + * **Scope.** This is our relay's rule, not the protocol's. A community publishes wherever its + * metadata points, and a third-party relay that reads NIP-09 the naive way — deletion authorized by + * matching `pubkey` — hands every ex-member a wipe button for the whole channel. The protocol-level + * fix is the same one that already exists for everything else: a CORD-06 Refounding rotates the + * plane address, which protects the future but cannot restore what a relay already dropped. + */ +class ConcordPlaneKeyDeletionTest { + private lateinit var hub: InProcessRelays + private lateinit var scope: CoroutineScope + private lateinit var client: NostrClient + private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") + + @BeforeTest + fun setup() { + hub = InProcessRelays() + scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + client = NostrClient(hub, scope) + } + + @AfterTest + fun teardown() { + client.disconnect() + scope.cancel() + hub.close() + } + + private suspend fun query(filter: Filter): List { + val ch = Channel(Channel.UNLIMITED) + val subId = "sub-${System.nanoTime()}" + client.subscribe( + subId, + mapOf(relayUrl to listOf(filter)), + object : SubscriptionListener { + override suspend fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Msg.Ev(event)) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Msg.Eose) + } + }, + ) + val events = mutableListOf() + withTimeout(5000) { + while (true) { + when (val msg = ch.receive()) { + is Msg.Ev -> events += msg.event + Msg.Eose -> return@withTimeout + } + } + } + client.unsubscribe(subId) + return events + } + + private sealed interface Msg { + data class Ev( + val event: Event, + ) : Msg + + object Eose : Msg + } + + /** A public channel plane: derived from the community root, so every member holds its secret. */ + private val communityRoot = RandomInstance.bytes(32) + private val channelId = RandomInstance.bytes(32) + private val plane = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch = 0) + + /** The plane's own signer — what the banned member reconstructs from the root they kept. */ + private fun planeSigner() = NostrSignerSync(KeyPair(privKey = plane.secretKey)) + + private suspend fun postAs( + author: NostrSignerInternal, + text: String, + createdAt: Long, + ): Event { + val rumor = ChannelChat.message(author.pubKey, channelId.toHexKey(), epoch = 0, text = text, createdAt = createdAt) + return ConcordStreamEnvelope.wrap(rumor, plane, author, encrypted = true, createdAt = createdAt) + } + + @Test + fun aPlaneKeyHolderCannotDeleteTheChannelsHistory() = + runBlocking { + val now = TimeUtils.now() + val bob = NostrSignerInternal(KeyPair()) + val carol = NostrSignerInternal(KeyPair()) + + val history = + listOf( + postAs(bob, "hello", now), + postAs(carol, "hi bob", now + 1), + postAs(bob, "how's the project going?", now + 2), + ) + history.forEach { assertEquals(true, client.publishAndConfirm(it, setOf(relayUrl)), "seed the channel history") } + assertEquals(3, query(Filter(authors = listOf(plane.publicKeyHex))).size, "three messages on the plane") + + // The banned member still derives `plane`, and every wrap above IS authored by it — so + // this kind-5 satisfies a same-author check. It must still be refused. + val deletion = planeSigner().sign(DeletionEvent.build(history, createdAt = now + 10)) + assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl)), "the relay accepts the event itself") + + assertEquals( + 3, + query(Filter(authors = listOf(plane.publicKeyHex), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP))).size, + "signing as the plane must NOT delete the community's messages", + ) + } + + @Test + fun aPlaneKeyHolderCannotVanishTheChannelPlane() = + runBlocking { + val now = TimeUtils.now() + val bob = NostrSignerInternal(KeyPair()) + + val history = listOf(postAs(bob, "one", now), postAs(bob, "two", now + 1)) + history.forEach { client.publishAndConfirm(it, setOf(relayUrl)) } + assertEquals(2, query(Filter(authors = listOf(plane.publicKeyHex))).size) + + // NIP-62 needs no per-event targeting: one event, and everything that pubkey published + // is gone. The sharpest version of the attack, and the same rule has to stop it. + val vanish = planeSigner().sign(RequestToVanishEvent.build(relayUrl, "", createdAt = now + 10)) + assertEquals(true, client.publishAndConfirm(vanish, setOf(relayUrl))) + + assertEquals( + 2, + query(Filter(authors = listOf(plane.publicKeyHex), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP))).size, + "a kind-62 signed as the plane must not wipe the channel", + ) + } + + @Test + fun theEphemeralPTagIsWhatMakesTheChannelUndeletableSoDoNotMakeItMeaningful() = + runBlocking { + // The counterfactual, so the invariant is visible rather than incidental: ownership of a + // 1059 follows the p-tag, so a wrap addressed to a REAL key is deletable by whoever holds + // that key. Concord is safe only because `wrapSeal` stamps a fresh throwaway pubkey there. + // If that p-tag ever becomes something a member holds — a recipient, a channel id, a + // community id — every ex-holder of it can delete the plane's history. + val now = TimeUtils.now() + val mallory = NostrSignerInternal(KeyPair()) + + val addressedWrap = + planeSigner().signNormal( + now, + ConcordStreamEnvelope.KIND_WRAP, + arrayOf(arrayOf("p", mallory.pubKey)), + "not-a-real-seal", + ) + assertEquals(true, client.publishAndConfirm(addressedWrap, setOf(relayUrl))) + assertEquals(1, query(Filter(ids = listOf(addressedWrap.id))).size) + + val deletion = mallory.sign(DeletionEvent.build(listOf(addressedWrap), createdAt = now + 1)) + assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl))) + + assertEquals( + 0, + query(Filter(ids = listOf(addressedWrap.id))).size, + "the p-tag recipient owns a 1059 — which is exactly why Concord's p-tag must stay random", + ) + } +} From 41a035034ba514f9964b8e1e1d1f6731715c789e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 21:30:14 +0000 Subject: [PATCH 167/227] test(quartz): pin the hand-crafted routes out of a Concord ban MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ban/unban verb is not the threat model — a malicious client writes editions directly, so what matters is which routes the FOLD refuses. Three more, all of them ones the UI would never author. Two are refused, and it is worth pinning why, because neither is refused by the rule you would expect. Removing yourself from the banlist is caught by the delta rule's strict outranking (nobody outranks themselves), so the sharper attempt does not remove anything: it forks the banlist at genesis, or builds a private chain, that simply never mentions him, at a version high enough to win the head fold. There is then nothing to remove and the rank rule never fires. What catches it is CORD-04 §4's re-heal — the owner's edition is not on the forged head's back-chain, so it is unioned back in as a concurrent ban. The union is load-bearing security here, not just convergence. The third works. A §3 compaction re-wraps one edition per entity and the ROTATOR picks it, so a rotator can decline to carry the banlist forward; every edition it serves is genuine and no signature check can see the omission. A banned member cannot rotate — drainConcordRekeys gates the rotator on the ban-aware hasPermission — but the puppet from the previous commit is not banned and can. EntityFloor is the entire defense, so the community splits: clients that already folded the ban refuse the rollback, fresh joiners have no floor and see no ban. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- .../cord04Roles/BannedStaffEscalationTest.kt | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt index 8ad22aba6b..aedadd885a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt @@ -273,4 +273,54 @@ class BannedStaffEscalationTest { assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the puppet does not outrank its creator") } + + @Test + fun aForkedBanlistThatOmitsHimCannotLaunderTheBanAway() { + // A malicious client is not limited to what the ban/unban verb will author. The sharpest + // hand-crafted route does not try to REMOVE his ban — removal is what the strict-outrank-self + // rule guards — it forks at genesis and simply never mentions him, at a version high enough + // to win the head fold. The head's own effective list then never carried his ban, so there is + // nothing to remove and the rank rule never fires. + // + // §4's re-heal is what closes it: the owner's edition is authorized and is NOT on the forked + // head's back-chain, so it is unioned back in as a concurrent ban. + val editions = community() + ownerBansAlice + banlist(alice, 99, null, carol) + + assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the re-heal union must put the owner's ban back") + } + + @Test + fun aPrivateBanlistChainOfHisOwnCannotLaunderTheBanAway() { + // The same idea two editions deep, so the winning head has a clean ancestry entirely of his + // own making. Ancestry is walked over the full pool, so the owner's ban is still recognised + // as a concurrent fork rather than a superseded ancestor. + val mine = banlist(alice, 50, null) + val editions = community() + ownerBansAlice + mine + banlist(alice, 51, mine.hash) + + assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a self-authored chain must not launder the ban away") + } + + @Test + fun aRogueRotatorCompactsTheBanAwayForEveryClientWithoutAFloor() { + // The route that does work, and the one no signature check can catch. A CORD-06 §3 compaction + // re-wraps ONE edition per entity and the ROTATOR picks it, so a rotator can simply not carry + // the banlist forward. Every edition it serves is genuine; the ban is erased by omission. + // + // A banned member cannot rotate (drainConcordRekeys gates the rotator on hasPermission, which + // is ban-aware) — but the puppet minted above is not banned, and it can. EntityFloor is the + // whole defense, so this splits the community in two: clients that already folded the ban + // refuse the rollback, while fresh joiners have no floor to refuse with and see no ban at all. + val editions = community() + ownerBansAlice + val floors = ConcordCommunityState.authorizedHeads(editions, owner) + val compacted = editions.filter { it.entityKind != ControlEntityKind.BANLIST } + + assertFalse( + ConcordCommunityState.fold(compacted, owner).authority.isBanned(alice), + "ESCALATION: a fresh joiner holds no floor, so the omitted ban simply never existed", + ) + assertTrue( + ConcordCommunityState.fold(compacted, owner, floors).authority.isBanned(alice), + "a client that already folded the ban must refuse the rollback", + ) + } } From 1e6cda712dc965575b39ea0553dac7887f9af3da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:06:54 +0000 Subject: [PATCH 168/227] docs(concord): audit the soft-ban and Control Plane attack surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collects the findings from this branch into docs/concord-soft-ban-audit.md, each marked Verified (a test reproduces it, named) or Read (follows from the code, untested), with a suggested order of attack. Adds the reproduction for the one finding that was still unverified, and it did not hold up the way it was first described. Version inflation does not poison the anti-rollback floor through the chain walk — that walk advances only to head.version + 1 citing the head's hash, so a fresh joiner is untouched. It goes through the COMPACTION ARM: once a client holds a floor and the entity is in the epoch snapshot, the head comes from bootstrapHead, which is highest-version at or above the floor with no prev, no hash and no contiguity. Version is then the whole contest and Long.MAX_VALUE wins it permanently — the floor rises to MAX_VALUE, no honest edition can exceed it, and a Refounding that drops the poison falls back to EntityFloor.known, which is the poison. That makes it the worst item on the list: unrecoverable, and authored in the tests by a current, legitimately granted moderator — no ban, no sockpuppet, one ordinary permission bit. compactControlPlane picks per entity by raw max version too, so honest rotators carry it into every future epoch. The banlist escapes only because AuthorityResolver folds it on a floor-less chain walk and re-heals the union, so an honest ban still lands. That accident is all that separates this from a permanently unmoderatable community, so it is pinned by its own test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- docs/concord-soft-ban-audit.md | 230 ++++++++++++++++++ .../ControlPlaneVersionExhaustionTest.kt | 167 +++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 docs/concord-soft-ban-audit.md create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt diff --git a/docs/concord-soft-ban-audit.md b/docs/concord-soft-ban-audit.md new file mode 100644 index 0000000000..239258ff40 --- /dev/null +++ b/docs/concord-soft-ban-audit.md @@ -0,0 +1,230 @@ +# Concord: soft-ban and Control Plane audit + +**Scope:** what a removed member — or a moderator who turns — can still do to a Concord community, +assuming a **malicious client** (no client-side rule binds them; only cryptography, the fold, and +the relay do). +**Date:** 2026-08-08. **Status:** findings only, nothing fixed yet. +**Companion:** `docs/concord-banlist-rank-conformance.md` (the rank half of CORD-04 §4, already +reported to Armada and fixed here). + +Each finding says how it was established. **Verified** means a test in this repo reproduces it; +**Read** means it follows from the code but no test was written. Every "Verified" line names the +test. + +--- + +## Summary + +| # | Finding | Severity | Needs a ban? | Recoverable? | +|---|---------|----------|--------------|--------------| +| [V1](#v1) | One edition at `version = Long.MAX_VALUE` pins an entity forever | **Critical** | No — any bit-holder | **No** | +| [V2](#v2) | A banned staffer keeps Role/Grant/Banlist authority | **Critical** | Yes | Yes (Refounding) | +| [V3](#v3) | A rogue rotator compacts the banlist away | High | Via V2 | Partly | +| [V4](#v4) | The Refounding recipient set is attacker-inflatable | High | No | Yes | +| [V5](#v5) | The ban is a per-pubkey display rule; the channel key is not revoked | High | Yes | Yes (Refounding) | +| [V6](#v6) | Channel history is deletable on a naive third-party relay | High | Yes | **No** (history) | +| [V7](#v7) | Banlist rank rule diverges from Armada | Medium | — | — | +| [V8](#v8) | A soft ban revokes no read access and no live invite | Medium | Yes | Yes (Refounding) | +| [V9](#v9) | The base-rekey plane is writable by every member | Low | Yes | Yes | + +The two structural causes worth naming up front, because most of the list collapses into them: + +- **Authority is checked in two places that disagree.** `ConcordCommunityState.fold` gates + METADATA/CHANNEL/INVITE through `authority.hasPermission` (`!isBanned && …`), while ROLE, GRANT + and BANLIST are gated *inside* `AuthorityResolver.resolve` by `holdsManageRoles` / `bitsOf` / + `effectivePermissionsOf`, none of which consult the banlist. That is V2, and V3 follows from it. +- **A ban removes standing, never keys.** Everything a member holds — `community_root`, channel + keys, `control_root` if staff, live invite links — survives it. Only a CORD-06 Refounding rotates + those, which is why V4 (making Refounding expensive) is worth more to an attacker than it looks. + +--- + +## V1 — One edition at `Long.MAX_VALUE` pins an entity forever + +**Critical. Does not require a banned user, a sockpuppet, or the owner's absence. Unrecoverable.** + +*Verified:* `quartz/…/cord04Roles/ControlPlaneVersionExhaustionTest.kt` (3 tests). + +Any current holder of an entity's permission bit publishes one edition at `version = +Long.MAX_VALUE`. For every client that holds an `EntityFloor` for that entity, that edition becomes +the permanent head: + +1. it wins, so the entity shows the attacker's content; +2. `authorizedHeads` raises the entity's floor to `Long.MAX_VALUE`; +3. no honest edition can ever exceed that floor, so the entity can never be repaired; +4. a Refounding that drops the poison does not help — nothing is offered at or above the floor, the + fold reports a gap, and falls back to `EntityFloor.known`, which *is* the poison. + +The chain walk is not the weakness (it advances only to `head.version + 1` citing the head's hash, +so a fresh joiner is unaffected). The weakness is the **compaction arm** of `EditionFold.foldEntity`: +once a floor exists and the entity is in the epoch snapshot — which `fold` always builds from the +editions handed to it — the head comes from `bootstrapHead`, i.e. *highest version at or above the +floor*, with no `prev`, no hash, no contiguity. Version becomes the whole contest. + +Concretely: a moderator with `MANAGE_CHANNELS` deletes `#general` permanently for everyone; one +with `MANAGE_METADATA` renames the community permanently. Demoting or banning them afterwards +changes nothing — the damage is in every client's floor. `ConcordRefounding.compactControlPlane` +also selects the head per entity by raw highest version, ungated, so an honest rotator carries the +poison into every future epoch, where fresh joiners then anchor on it as their baseline. + +The banlist survives, by accident: `AuthorityResolver` folds it on its own floor-less chain walk and +re-heals the union across authorized editions, so an honest ban still lands. That accident is the +only thing separating this from a permanently unmoderatable community, and it is now pinned by +`aPoisonedBanlistStillAcceptsTheOwnersBan`. + +**Fix direction.** The compaction arm needs a bound, since it is the arm that trades contiguity for +cross-epoch tolerance. Options, roughly in order of preference: + +- Cap the version delta the arm will accept in one step (a compacted head is legitimately ahead of + the floor, but by a chain's worth, not by 2^63). Anything above the cap is a gap, not a head. +- Make `bootstrapHead` prefer the highest version *reachable by a chain* among the offered editions, + falling back to raw version only when no chain connects. +- Have `compactControlPlane` select the authority-gated fold head rather than raw max version, so a + poison is at least not propagated by honest rotators. + +The first is the smallest change and closes the unrecoverability; the third should happen regardless. + +## V2 — A banned staffer keeps Role, Grant and Banlist authority + +**Critical.** *Verified:* `quartz/…/cord04Roles/BannedStaffEscalationTest.kt` (13 tests). + +`hasPermission` is ban-aware; the resolver's internal gates are not, and structurally cannot be as +written — the roles/grants fixpoint settles before `banned` is computed. So a banned member who +still holds `control_root` keeps the roster. In the reproduction they: + +- ban every member they outrank, directly, with no puppet; +- revoke the surviving moderators' grants and retire the roles beneath them; +- **mint a fresh, unbanned npub** at the next position down, which then passes every ban-aware gate: + deletes every channel, rewrites the metadata, bans the rest of the community, creates invites; +- and, because `drainConcordRekeys` authorizes a rotator by `hasPermission(rotator, BAN)`, that + puppet can publish a Refounding omitting the owner — every honest client follows it and the owner + is stranded on a dead root. + +Self-unban is *not* reachable and neither is a puppet-unban: the delta rule gates removals and +strict outranking means nobody outranks themselves, while no edition may claim a position at or +above its signer, so the delegation chain only descends. Two hand-crafted attempts that avoid +removal entirely — forking the banlist at genesis, and building a private chain — are also refused, +by CORD-04 §4's re-heal union rather than by the rank rule. **The union is load-bearing security +here, not just convergence.** + +**Fix direction.** Make the resolver's gates ban-aware. The ordering problem is real (you cannot +know who is banned before folding the banlist, nor who may write it before knowing who is banned), +so resolve it as a bounded two-pass where authority only ever *shrinks*: pass A settles the roster +as today and computes the banlist; pass B re-resolves roles/grants dropping editions whose author is +banned in pass A; then recompute the banlist under pass B's roster, keeping only bans still +authorized. Deterministic, terminates, no oscillation on mutual bans. **Consensus-affecting**: until +Armada ships the same rule, we will drop editions they honor. + +## V3 — A rogue rotator compacts the banlist away + +**High.** *Verified:* `aRogueRotatorCompactsTheBanAwayForEveryClientWithoutAFloor`. + +A CORD-06 §3 compaction re-wraps one edition per entity and the *rotator* picks it, so a rotator can +decline to carry the banlist forward. Every edition it serves is genuine, so no signature check sees +the omission — `EntityFloor`'s own KDoc names this case ("clearing a banlist"). A banned member +cannot rotate, but the V2 puppet can. + +The result is not a clean unban but a **split community**: clients that already folded the ban +refuse the rollback and still see it, fresh joiners have no floor and see no ban at all. Two +populations permanently disagreeing about who is a member, with no event either side can call +forged. Closing V2 removes the puppet and takes this with it; floors alone do not, since they only +protect people who were already there. + +## V4 — The Refounding recipient set is attacker-inflatable + +**High.** *Read:* `ConcordCommunitySession.allMembers()` / `emitChannelRumors`; +`AccountConcordActions.refoundConcordCommunity` step 2; `ConcordRefounding.buildBaseRekeyWraps`. + +`allMembers()` = Guestbook joins ∪ `observedAuthors` ∪ roster ∪ owner, and it *is* the Refounding +recipient set. Both contributing sets are unbounded and both are attacker-writable: Guestbook joins +are self-signed (any key, no authority), and every author we decrypt is folded into +`observedAuthors` by design (CORD-02 §5, "observably present"). + +So each throwaway npub an attacker posts from, or announces, is one more mandatory NIP-44 blob in +the next Refounding, chunked 120 per event. 100k identities ⇒ ~100k encryptions and ~830 published +events — while they keep posting. **The attack inflates the cost of its own remedy**, and the remedy +is the only hard removal Concord has. + +This is the cheapest thing on the list to fix and the only one that is not consensus-affecting: cap +the recipient set, prefer recent/attested members when over the cap, and surface what was dropped +(a silent truncation strands real members). Worth doing first. + +## V5 — The ban is a per-pubkey display rule and the channel key is not revoked + +**High.** *Read:* `Account.consumeConcordRumorGated` (`isBanned(rumor.pubKey)`), `Account.isAcceptable`. + +Writing to a channel needs the channel key, which the ban does not take away; the seal author is +whatever key the client feels like using. A malicious client therefore posts every message from a +fresh npub and `isBanned` never matches — moderation is whack-a-mole against an infinite identity +supply. Each message also costs every member two NIP-44 decrypts and two signature verifications +*before* the banlist check runs, and each fresh author inflates V4. + +There is no client-side answer; only a Refounding rotates the key out from under them. That is the +correct design, which is why V4 matters so much. + +## V6 — Channel history is deletable on a naive third-party relay + +**High, external.** *Verified (that we are safe):* +`geode/…/ConcordPlaneKeyDeletionTest.kt` (3 tests). + +CORD-01 signs every wrap with the shared stream key, so on the wire a Concord channel is one author +publishing everything — and every member holds that author's secret. NIP-09 and NIP-62 authorize on +the outer `pubkey`. Read the obvious way, that hands any ex-member a one-event wipe of the whole +community's history, and geode's own guarantee ("a kind-5 from pubkey X cannot delete pubkey Y's +events") is vacuous inside a plane. + +**On our relay it is refused, but only because of a rule written for something else:** +`Event.owner()` gives a kind-1059 to its *p-tag recipient* rather than its signer, and +`ConcordStreamEnvelope` stamps a freshly random p-tag on every wrap, so each wrap is owned by a +one-time key nobody holds. Both halves are load-bearing, neither was written for this, and either +one silently re-opens the hole — all three are now pinned, including a counterfactual showing a wrap +addressed to a *real* key is deletable by its holder. + +A community publishes wherever its metadata points. Any relay that authorizes deletion by matching +`pubkey` still hands every ex-member the wipe button, and a Refounding protects only the future. +Worth a note in the CORD-01 spec and a line in the relay-selection guidance. + +## V7 — Banlist rank rule diverges from Armada + +**Medium, known, deliberate.** See `docs/concord-banlist-rank-conformance.md`, already reported. + +We enforce §3's rank half on the Banlist and Armada does not, so the two clients can show different +banlists. Shipped knowingly. Row 3 of that report ("a banned `BAN` holder unbans themselves") was +left open as a fixpoint-ordering question — V2 is the general form of it, and the fix proposed there +resolves both. + +## V8 — A soft ban revokes no read access and no live invite + +**Medium, inherent.** *Read:* CORD-02/05. + +Until a Refounding, a banned member decrypts everything published — the ban only stops honest +clients from *showing* their posts, not from delivering the group's posts to them. They also keep +any invite links they created while privileged; those still resolve to bundles carrying the current +root. Publishing the root, or one live link, invites an unbanned crowd that each has to be banned +individually (and see V5). + +Not a bug so much as the definition of a soft ban, but it belongs on the list because the UI should +say so: "Ban" and "Remove from community" are very different promises and users will read the first +as the second. + +## V9 — The base-rekey plane is writable by every member + +**Low.** *Read:* `ConcordKeyDerivation.baseRekeyAddress`, `AccountConcordActions.drainConcordRekeys`. + +The base-rekey address derives from `community_root`, so any member — banned included — can mint +valid wraps there. Authorization happens after the blobs are scanned, so a flood costs every member +a locator scan per blob on every revision tick. Bounded work per wrap and no correctness impact; +listed for completeness. + +--- + +## Suggested order + +1. **V4** — cheapest, not consensus-affecting, and it protects the remedy every other fix depends on. +2. **V1** — worst blast radius and the only unrecoverable one; does not need an attacker to be + banned or privileged beyond a single ordinary bit. +3. **V2** (+V3, +V7 row 3) — one two-pass change closes all three. Coordinate with Armada first; + this one splits consensus. +4. **V6** — spec note + relay guidance; our own behaviour is already correct and now pinned. +5. **V5 / V8** — UI honesty about what a ban does, and a "Remove from community" affordance that + Refounds rather than bans. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt new file mode 100644 index 0000000000..bbba1dc8d3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * **V1 in `docs/concord-soft-ban-audit.md` — reproduction.** A single Control Plane edition at + * `version = Long.MAX_VALUE` pins its entity to the author's content permanently, for every client + * that holds a floor for it. + * + * The chain walk is not the weakness — it advances only to `head.version + 1` citing the head's + * hash, so an inflated version is unreachable and a fresh joiner is unaffected. The weakness is the + * **compaction arm** of [EditionFold.foldEntity]: once a client holds a floor for an entity and that + * entity appears in the epoch snapshot (which `ConcordCommunityState.fold` always builds from the + * editions handed to it), the head is chosen by [EditionFold.bootstrapHead] — *highest version at or + * above the floor*, with no `prev`, no hash, and no contiguity. Version is then the whole contest, + * and `Long.MAX_VALUE` wins it forever: + * + * 1. the poison becomes the head, so the entity shows the attacker's content; + * 2. `authorizedHeads` raises the entity's floor to `Long.MAX_VALUE`; + * 3. no honest edition can ever exceed that floor, so the entity can never be repaired; + * 4. a Refounding that drops the poison does not help either — nothing is offered at or above the + * floor, so the fold reports a gap and falls back to [EntityFloor.known], which *is* the poison. + * + * Note who the attacker is. Every test here is authored by **bob, a current and legitimately granted + * moderator** — not a banned member, not a sockpuppet. Any holder of the entity's permission bit can + * do this at any time, and demoting or banning them afterwards changes nothing, because the damage + * is already in every client's floor. It is also carried into every future epoch by + * `ConcordRefounding.compactControlPlane`, which selects the head per entity by raw highest version. + * + * The banlist is the one entity that survives, and by accident: `AuthorityResolver` folds it with + * its own floor-less chain walk and then re-heals the union across authorized editions, so an + * honest ban lands even when the head is poisoned. [aPoisonedBanlistStillAcceptsTheOwnersBan] pins + * that, because it is the only thing standing between this bug and a permanently unmoderatable + * community. + */ +class ControlPlaneVersionExhaustionTest { + private val owner = "0f".repeat(32) + private val bob = "b2".repeat(32) + + private val modRole = "22".repeat(32) + private val metadataEntity = "66".repeat(32) + private val channelEntity = "55".repeat(32) + private val banlistEntity = "44".repeat(32) + + private fun edition( + kind: ControlEntityKind, + entity: String, + version: Long, + prev: ByteArray?, + content: String, + author: String, + rumorId: String, + ) = ControlEdition(kind, entity.hexToByteArray(), version, prev, null, content, author, rumorId, 0) + + /** bob holds exactly one bit, granted by the owner, entirely legitimately. */ + private fun communityWhereBobHolds( + permissions: String, + vararg rest: ControlEdition, + ) = listOf( + edition(ControlEntityKind.ROLE, modRole, 0, null, """{"name":"Mod","position":5,"permissions":"$permissions"}""", owner, "role-mod"), + edition(ControlEntityKind.GRANT, "32".repeat(32), 0, null, """{"member":"$bob","role_ids":["$modRole"]}""", owner, "grant-bob"), + ) + rest + + @Test + fun oneEditionAtMaxVersionPinsTheMetadataForever() { + val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0") + val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0) + + // A client that has folded this community once holds a floor for the metadata entity. + val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner) + assertEquals(0, floorsBefore[metadataEntity]?.version, "an ordinary floor at the genesis edition") + + val poison = edition(ControlEntityKind.METADATA, metadataEntity, Long.MAX_VALUE, metadataV0.hash, """{"name":"PWNED"}""", bob, "meta-poison") + val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore) + assertEquals(Long.MAX_VALUE, floorsAfter[metadataEntity]?.version, "VULNERABLE: the floor is now at the top of the version space") + + // The owner tries to repair it, chaining honestly onto their own genesis. + val repair = edition(ControlEntityKind.METADATA, metadataEntity, 1, metadataV0.hash, """{"name":"My Community"}""", owner, "meta-1") + val pool = community + poison + repair + + assertEquals( + "My Community", + ConcordCommunityState.fold(pool, owner).metadata?.name, + "a fresh joiner walks the chain and is unaffected", + ) + assertEquals( + "PWNED", + ConcordCommunityState.fold(pool, owner, floorsAfter).metadata?.name, + "VULNERABLE: every client holding a floor is pinned to the attacker's content", + ) + assertEquals( + "PWNED", + ConcordCommunityState.fold(community + repair, owner, floorsAfter).metadata?.name, + "VULNERABLE: even a Refounding that drops the poison falls back to it as EntityFloor.known", + ) + } + + @Test + fun oneEditionAtMaxVersionDeletesAChannelForever() { + val channelV0 = edition(ControlEntityKind.CHANNEL, channelEntity, 0, null, """{"name":"general"}""", owner, "chan-0") + val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_CHANNELS).toWire(), channelV0) + + val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner) + val poison = edition(ControlEntityKind.CHANNEL, channelEntity, Long.MAX_VALUE, channelV0.hash, """{"name":"general","deleted":true}""", bob, "chan-poison") + val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore) + + val repair = edition(ControlEntityKind.CHANNEL, channelEntity, 1, channelV0.hash, """{"name":"general"}""", owner, "chan-1") + val pool = community + poison + repair + + assertEquals(1, ConcordCommunityState.fold(pool, owner).channels.size, "a fresh joiner still sees the channel") + assertEquals(0, ConcordCommunityState.fold(pool, owner, floorsAfter).channels.size, "VULNERABLE: the channel is gone and cannot be restored") + assertEquals( + 0, + ConcordCommunityState.fold(community + repair, owner, floorsAfter).channels.size, + "VULNERABLE: dropping the poison does not bring the channel back", + ) + } + + @Test + fun aPoisonedBanlistStillAcceptsTheOwnersBan() { + // The saving grace, and the reason this bug is "unmoderatable community" rather than + // "community with a broken name". AuthorityResolver folds the banlist on its own floor-less + // chain walk and re-heals the union across every authorized edition, so the owner's ban lands + // even while the banlist's own floor sits at Long.MAX_VALUE. Do not "unify" the banlist onto + // the floored fold without replacing this protection. + val banlistV0 = edition(ControlEntityKind.BANLIST, banlistEntity, 0, null, "[]", owner, "ban-0") + val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.BAN).toWire(), banlistV0) + + val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner) + val poison = edition(ControlEntityKind.BANLIST, banlistEntity, Long.MAX_VALUE, banlistV0.hash, "[]", bob, "ban-poison") + val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore) + assertEquals(Long.MAX_VALUE, floorsAfter[banlistEntity]?.version, "the banlist floor is poisoned like any other") + + val ownerBansBob = edition(ControlEntityKind.BANLIST, banlistEntity, 1, banlistV0.hash, """["$bob"]""", owner, "ban-1") + val pool = community + poison + ownerBansBob + + assertTrue(ConcordCommunityState.fold(pool, owner).authority.isBanned(bob), "a fresh joiner honors the ban") + assertTrue( + ConcordCommunityState.fold(pool, owner, floorsAfter).authority.isBanned(bob), + "the re-heal union must keep the banlist working even with a poisoned floor", + ) + } +} From d37e183a575ed9266f58a1404c953b4e89b3c068 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 01:28:22 +0000 Subject: [PATCH 169/227] docs(concord): audit the surfaces the first pass never opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass was bounded by the Control Plane, the fold and the relay. Three more findings from the surfaces it skipped, plus an explicit list of what is still unexamined so the next reader knows where the edges are. V10 is the serious one, and it forks. ConcordStrandedRecovery.isStranded takes only (entry, bundle): no banlist check, no check that we were legitimately re-keyed. The whole test is "the bundle at my stored invite_ref sits at a higher epoch than I do", and the unlock token lives in the link fragment an ex-member keeps forever. So whether a removed member walks back in depends only on whether anything re-mints at that coordinate. Amethyst mints a fresh link signer per invite and the Refounding neither re-mints nor revokes, so today nothing does — which means stranded recovery never fires for anyone, and the cure that drainConcordRekeys' KDoc points to for "a BAN-holder can evict anyone, the owner included, by omission" does not actually exist. If any client does re-mint at a stable coordinate, as CORD-05's design describes, then every removed member auto-recovers the new root on the 15-minute sweep and re-announces a Guestbook join. Either the safety net is missing or the only hard removal is undone; which one it is needs a spec answer, not a patch. V11: voice rooms authenticate with the channel's derived voice signer key against a stateless SFU that holds no community secret and cannot know a banlist exists, so a banned member keeps talking until a Refounding. V12: ingestTyping filters on binding and self only, so they keep showing as "typing". Checked and sound, recorded so they are not re-audited: the envelope pins rumor.pubKey == seal.pubKey (no author impersonation), and Note.latestConcordEdit is author-gated, so a member cannot rewrite someone else's message. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- docs/concord-soft-ban-audit.md | 81 ++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/docs/concord-soft-ban-audit.md b/docs/concord-soft-ban-audit.md index 239258ff40..8f284085ff 100644 --- a/docs/concord-soft-ban-audit.md +++ b/docs/concord-soft-ban-audit.md @@ -26,6 +26,9 @@ test. | [V7](#v7) | Banlist rank rule diverges from Armada | Medium | — | — | | [V8](#v8) | A soft ban revokes no read access and no live invite | Medium | Yes | Yes (Refounding) | | [V9](#v9) | The base-rekey plane is writable by every member | Low | Yes | Yes | +| [V10](#v10) | Stranded recovery: either broken, or a removal bypass | **Critical** | Yes | — | +| [V11](#v11) | Voice rooms are key-gated, not roster-gated | High | Yes | Yes (Refounding) | +| [V12](#v12) | Typing indicators are not ban-filtered | Low | Yes | Yes | The two structural causes worth naming up front, because most of the list collapses into them: @@ -216,8 +219,86 @@ valid wraps there. Authorization happens after the blobs are scanned, so a flood a locator scan per blob on every revision tick. Bounded work per wrap and no correctness impact; listed for completeness. +## V10 — Stranded recovery: either broken, or a removal bypass + +**Critical, and it forks — one of the two halves is true and both are bad.** +*Read:* `ConcordStrandedRecovery`, `AccountConcordActions.recoverStrandedConcordCommunities`, +`AccountConcordActions.mintConcordInvite`. + +`ConcordStrandedRecovery.isStranded` / `mergeForward` take only `(entry, bundle)`. There is **no +banlist check and no check that we were legitimately re-keyed** — the entire test is "the bundle at +my stored `inviteRef` sits at a higher epoch than I do". The unlock token lives in the link +fragment, which an ex-member keeps forever. So whether a removed member walks back in with the new +root depends *only* on whether the bundle at that coordinate ever advances an epoch. + +In Amethyst it never does: `mintConcordInvite` mints a **fresh link signer per mint**, so nothing +re-publishes at an existing coordinate, and `refoundConcordCommunity` does not re-mint or revoke +anything. Two consequences, and they are the fork: + +- **If nothing re-mints** — today's behaviour — then stranded recovery never fires *for anyone*. + That makes it dead code, and the cure `drainConcordRekeys`' own KDoc points to for "a BAN-holder + can evict anyone (the owner included) by omission" does not exist. An owner evicted by a rogue + admin has no way back. +- **If anything re-mints at a stable coordinate** — which is what CORD-05's design describes ("the + community keeps publishing its bundle at that same addressable coordinate, re-minted at the + current epoch"), so plausibly Armada in a cross-client community — then every removed member who + joined through a still-live link auto-recovers the new root on the 15-minute sweep, and + re-announces a Guestbook join so they look current again. **Refounding, the only hard removal, + is silently undone.** + +Note also that `refoundConcordCommunity` never revokes the invite links the removed member created +or joined through, even though `ControlEntityKind.INVITE_REVOKED` exists and `classifyInvite` +already honors it. + +**Fix direction.** Decide the intended semantics first — this needs a spec answer, not a patch. +Then: gate `mergeForward` on not being banned in the epoch we are merging *from*, have the +Refounding revoke the removed members' links, and either implement re-minting (so legitimate +recovery works) or drop the mechanism and give evicted owners a different route. + +## V11 — Voice rooms are key-gated, not roster-gated + +**High.** *Read:* `ConcordBrokerToken`, CORD-07 §2. + +A member proves voice-room membership by signing a NIP-98 kind-27235 request with the channel's +**derived voice signer key**, whose pubkey is the SFU room name. The broker is stateless and holds +no community secret, so it cannot consult the Control Plane and has no idea a banlist exists. A +banned member keeps that key until a Refounding, so they can join the voice room and stay in it. +Nothing on the client side can evict them — kicking them from the UI does not kick them from the SFU. + +This is the one place where a ban fails *audibly*, in real time, in front of everyone. Worth ranking +above its technical severity for that reason alone. + +## V12 — Typing indicators are not ban-filtered + +**Low.** *Read:* `ConcordCommunitySession.ingestTyping`. + +`ingestTyping` checks the rumor is a typing heartbeat, is bound to the channel/epoch, and is not our +own — and nothing else. A banned member (or any fresh npub holding the channel key, see V5) shows +in the "… is typing" row indefinitely. Cheap to fix and user-visible: the promise a ban makes is +that the member disappears, and here they do not. + --- +## What was NOT examined + +This audit is bounded by what was opened. Checked and found sound: the wrap/seal envelope (no author +impersonation — `rumor.pubKey == seal.pubKey` and `rumor.verifyId()`), Concord chat edits +(`Note.latestConcordEdit` is author-gated, so a member cannot rewrite someone else's message), and +self-unban (V2). + +Not looked at at all: + +- **Private channels** (CORD-03 derived keys) — key delivery on grant, and channel-scoped rekey. + Note that no channel-scoped rekey *receive* path appears to exist: `drainConcordRekeys` handles + `ROOT_SCOPE` only, and `entry.privateChannels` is carried forward but never populated by a + delivery path. If that is right, the only removal Amethyst can perform is a full-community + Refounding — which is exactly what V4 makes expensive. +- **In-plane reactions and deletes** — the edit path is author-gated; the delete path was not read. +- **Guestbook kicks** (kind 3309) — the builder documents a KICK-bit + rank rule; the receive side + was not verified against it. +- Unread counts and notification triggers, media/upload references from messages, the NIP-53 nests + overlap, and the desktop client's Concord paths. + ## Suggested order 1. **V4** — cheapest, not consensus-affecting, and it protects the remedy every other fix depends on. From f52a8b0432d3c4abd2ca10fa5b2b9fdfc51b5929 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 01:41:52 +0000 Subject: [PATCH 170/227] docs(concord): split the audit by what the attacker needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-reviewed every finding against the shipping app rather than against the protocol, and split the list in two: what a banned user can do with stock Amethyst (our bugs) versus what needs a hand-written client (fix in the fold, or defend against). Several items moved, and the review turned up a new one that belongs at the top. A1 is new and is the realistic attack. mintConcordInvite checks only that the account is writeable and that we hold the community — no CREATE_INVITE, no banlist — and unlike the Edit and channel buttons next to it, the invite IconButton carries no guard at all. A banned user stays in the app, taps person-add, and shares a working link to the community. The mint publishes a fresh link signer, so revoking the links they were given does not touch the ones they make; and because the bundle is a standalone kind-33301 outside the Control Plane, the CREATE_INVITE bit the fold enforces on INVITE_* entities never applies to the actual invite mechanism. A3 is the general form: every moderation verb checks isWriteable() and the Control write key and nothing else, so authority lives in the composable that draws the button — and those gates use effectivePermissions, which is ban-blind. Ban and Remove survive only because a second, unrelated condition routes through the ban-aware canActOn. refoundConcordCommunity guards itself with effectivePermissions outright, so a banned BAN-holder can launch a Refounding from the shipping app; honest receivers refuse it, but that is a race against banlist propagation, not a check. A2 moves to Part A because our own client is what performs it: the recovery sweep runs every 15 minutes with no banlist check. C2 (voice) is downgraded from High — ConcordBrokerToken and VoicePresence are referenced nowhere outside quartz, so there is no shipping path to attack. It is a note for whoever wires one up. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- docs/concord-soft-ban-audit.md | 347 ++++++++++++++++++++++----------- 1 file changed, 233 insertions(+), 114 deletions(-) diff --git a/docs/concord-soft-ban-audit.md b/docs/concord-soft-ban-audit.md index 8f284085ff..58429394ca 100644 --- a/docs/concord-soft-ban-audit.md +++ b/docs/concord-soft-ban-audit.md @@ -1,48 +1,205 @@ # Concord: soft-ban and Control Plane audit -**Scope:** what a removed member — or a moderator who turns — can still do to a Concord community, -assuming a **malicious client** (no client-side rule binds them; only cryptography, the fold, and -the relay do). -**Date:** 2026-08-08. **Status:** findings only, nothing fixed yet. +**Scope:** what a removed member — or a moderator who turns — can still do to a Concord community. +**Date:** 2026-08-09. **Status:** findings only, nothing fixed yet. **Companion:** `docs/concord-banlist-rank-conformance.md` (the rank half of CORD-04 §4, already reported to Armada and fixed here). Each finding says how it was established. **Verified** means a test in this repo reproduces it; -**Read** means it follows from the code but no test was written. Every "Verified" line names the -test. +**Read** means it follows from the code but no test was written. Every "Verified" line names the test. + +--- + +## How to read this list + +Findings are split by **what the attacker needs**, because that decides who owns the fix and how +urgent it is: + +- **[Part A — reachable from stock Amethyst](#part-a).** A banned user opens the shipping app and + taps a button, or our own client does it for them on a timer. These are straightforwardly *our + bugs*, they need no attacker sophistication at all, and every one of them is fixable in this repo + without touching the protocol or coordinating with anyone. +- **[Part B — requires a malicious client](#part-b).** The attacker writes their own events, so no + client-side rule binds them. We cannot stop them from *authoring* anything; we can only refuse to + *honor* it. Fixes live in the fold, the store, or the spec. +- **[Part C — interop and not-yet-shipped surfaces](#part-c).** + +The distinction is not academic. Part A is where the realistic attacker is: an irritated user who +just got banned has the app already installed and is not going to write a Nostr client. Part B is +where the *damage ceiling* is. Fix Part A first because it is cheap and it is what will actually +happen; fix Part B because it is what ends communities. + +Two structural causes account for most of both halves: + +- **Authority is checked in several places that disagree.** `ConcordCommunityState.fold` gates + METADATA/CHANNEL/INVITE through the ban-aware `authority.hasPermission`. `AuthorityResolver` + gates ROLE/GRANT/BANLIST internally through `holdsManageRoles` / `bitsOf` / + `effectivePermissionsOf`, which are ban-blind. The **UI** gates through `effectivePermissions`, + also ban-blind. The **action layer** mostly does not gate at all. Same question, four answers. +- **A ban removes standing, never keys.** `community_root`, channel keys, `control_root` if staff, + and live invite links all survive it. Only a CORD-06 Refounding rotates those — which is why + anything that makes Refounding expensive (B4) or reversible (A2) is worth more to an attacker + than it first looks. --- ## Summary -| # | Finding | Severity | Needs a ban? | Recoverable? | -|---|---------|----------|--------------|--------------| -| [V1](#v1) | One edition at `version = Long.MAX_VALUE` pins an entity forever | **Critical** | No — any bit-holder | **No** | -| [V2](#v2) | A banned staffer keeps Role/Grant/Banlist authority | **Critical** | Yes | Yes (Refounding) | -| [V3](#v3) | A rogue rotator compacts the banlist away | High | Via V2 | Partly | -| [V4](#v4) | The Refounding recipient set is attacker-inflatable | High | No | Yes | -| [V5](#v5) | The ban is a per-pubkey display rule; the channel key is not revoked | High | Yes | Yes (Refounding) | -| [V6](#v6) | Channel history is deletable on a naive third-party relay | High | Yes | **No** (history) | -| [V7](#v7) | Banlist rank rule diverges from Armada | Medium | — | — | -| [V8](#v8) | A soft ban revokes no read access and no live invite | Medium | Yes | Yes (Refounding) | -| [V9](#v9) | The base-rekey plane is writable by every member | Low | Yes | Yes | -| [V10](#v10) | Stranded recovery: either broken, or a removal bypass | **Critical** | Yes | — | -| [V11](#v11) | Voice rooms are key-gated, not roster-gated | High | Yes | Yes (Refounding) | -| [V12](#v12) | Typing indicators are not ban-filtered | Low | Yes | Yes | +### Part A — reachable from stock Amethyst (our bugs) -The two structural causes worth naming up front, because most of the list collapses into them: +| # | Finding | Severity | Was | +|---|---------|----------|-----| +| [A1](#a1) | Any member — banned included — mints a working invite in one tap | **Critical** | new | +| [A2](#a2) | Stranded recovery runs on a timer and never checks the banlist | **Critical** | V10 | +| [A3](#a3) | The action layer has no permission checks; the UI's are ban-blind | High | new | +| [A4](#a4) | A banned member keeps broadcasting "typing", and we keep showing it | Low | V12 | +| [A5](#a5) | A banned member's own client keeps reading and rendering everything | Medium | V8 | -- **Authority is checked in two places that disagree.** `ConcordCommunityState.fold` gates - METADATA/CHANNEL/INVITE through `authority.hasPermission` (`!isBanned && …`), while ROLE, GRANT - and BANLIST are gated *inside* `AuthorityResolver.resolve` by `holdsManageRoles` / `bitsOf` / - `effectivePermissionsOf`, none of which consult the banlist. That is V2, and V3 follows from it. -- **A ban removes standing, never keys.** Everything a member holds — `community_root`, channel - keys, `control_root` if staff, live invite links — survives it. Only a CORD-06 Refounding rotates - those, which is why V4 (making Refounding expensive) is worth more to an attacker than it looks. +### Part B — requires a malicious client + +| # | Finding | Severity | Needs a ban? | Recoverable? | Was | +|---|---------|----------|--------------|--------------|-----| +| [B1](#b1) | One edition at `version = Long.MAX_VALUE` pins an entity forever | **Critical** | No — any bit-holder | **No** | V1 | +| [B2](#b2) | A banned staffer keeps Role/Grant/Banlist authority | **Critical** | Yes | Yes (Refounding) | V2 | +| [B3](#b3) | A rogue rotator compacts the banlist away | High | Via B2 | Partly | V3 | +| [B4](#b4) | The Refounding recipient set is attacker-inflatable | High | No | Yes | V4 | +| [B5](#b5) | The ban is per-pubkey; the channel key is not revoked | High | Yes | Yes (Refounding) | V5 | +| [B6](#b6) | Channel history is deletable on a naive third-party relay | High | Yes | **No** (history) | V6 | +| [B7](#b7) | The base-rekey plane is writable by every member | Low | Yes | Yes | V9 | + +### Part C — interop and not-yet-shipped + +| # | Finding | Severity | Was | +|---|---------|----------|-----| +| [C1](#c1) | Banlist rank rule diverges from Armada | Medium | V7 | +| [C2](#c2) | CORD-07 voice rooms are key-gated, not roster-gated | Design | V11 | --- -## V1 — One edition at `Long.MAX_VALUE` pins an entity forever +# Part A — reachable from stock Amethyst + +No custom tooling. A banned user with the shipping app, or our own background sweep. + +## A1 — Any member, banned included, mints a working invite in one tap + +**Critical. The single most likely thing an irritated banned user actually does.** +*Read:* `AccountConcordActions.mintConcordInvite`, `ConcordChannelListScreen` (the `PersonAdd` +`IconButton`). + +`mintConcordInvite` checks exactly two things: that the account is writeable, and that we have the +community in our joined list. **No `CREATE_INVITE` check. No banlist check.** And unlike the Edit +and channel-management buttons beside it, the invite `IconButton` is rendered with no `canEdit` +guard at all — it is always there, for everyone. + +So the flow is: get banned, stay in the app, tap the person-add icon, share the link. The minted +bundle carries the community root we still hold, so anyone who opens it joins for real. Every +invited account is a fresh unbanned npub that moderators then have to ban one at a time. + +Two aggravating details. The mint publishes a **fresh link signer per invite**, so it is a brand-new +coordinate — revoking the links the banned member was given does not touch the ones they mint. +And `CREATE_INVITE` is a real permission bit that the fold enforces on `INVITE_*` Control entities, +but the actual invite mechanism is a standalone kind-33301 addressable event published *outside* the +Control Plane, so that gate never applies to it. The permission is, in practice, unenforced. + +**Fix.** Gate `mintConcordInvite` on `hasPermission(me, CREATE_INVITE) || isOwner(me)`, and gate the +button on the same. This is contained, uncontroversial, and closes the realistic attack. Do it first. + +## A2 — Stranded recovery runs on a timer and never checks the banlist + +**Critical, and it forks.** *Read:* `ConcordStrandedRecovery`, +`AccountConcordActions.recoverStrandedConcordCommunities`, `AccountConcordActions.mintConcordInvite`. + +This is in Part A because **our own client performs it, unprompted**: the recovery sweep runs on the +revision tick for every joined community holding an `inviteRef`, every 15 minutes. The banned user +does nothing but leave the app installed. + +`ConcordStrandedRecovery.isStranded` / `mergeForward` take only `(entry, bundle)` — no banlist +check, no check that we were legitimately re-keyed. The whole test is "the bundle at my stored +`inviteRef` sits at a higher epoch than I do", and the unlock token lives in the link fragment an +ex-member keeps forever. So whether a removed member walks back in depends *only* on whether +anything re-mints at that coordinate: + +- **If nothing re-mints** — today, since Amethyst mints a fresh link signer per invite and the + Refounding neither re-mints nor revokes — stranded recovery never fires for anyone. It is dead + code, and the cure `drainConcordRekeys`' own KDoc points to for "a BAN-holder can evict anyone + (the owner included) by omission" does not exist. An owner evicted by a rogue admin has no way back. +- **If anything re-mints at a stable coordinate** — which is what CORD-05's design describes, so + plausibly Armada in a cross-client community — every removed member auto-recovers the new root and + re-announces a Guestbook join, looking current again. **The only hard removal is silently undone.** + +Note also that `refoundConcordCommunity` never revokes the links the removed member created or +joined through, though `ControlEntityKind.INVITE_REVOKED` exists and `classifyInvite` honors it. + +**Fix.** Decide the intended semantics first — this needs a spec answer. Then gate `mergeForward` on +not being banned in the epoch we merge *from*, have the Refounding revoke the removed members' +links, and either implement re-minting so legitimate recovery works, or drop the mechanism and give +evicted owners another route. + +## A3 — The action layer has no permission checks; the UI's are ban-blind + +**High (defense in depth).** *Read:* `AccountConcordActions` (`banConcordMember`, +`unbanConcordMember`, `editConcordMetadata`, `deleteConcordChannel`, `refoundConcordCommunity`), +`ConcordMembersScreen`, `ConcordChannelListScreen`. + +Every moderation verb checks `isWriteable()` and the Control write key, and **nothing else** — no +permission bit, no banlist. Authority lives entirely in the composable that draws the button. Two +consequences: + +1. **The UI's own gates are ban-blind.** `iCanBan`, `canEdit` (metadata) and `canManageChannels` all + use `effectivePermissions`, which ignores the banlist. A banned admin still sees the Edit and + channel-management controls. Those particular editions are dropped by every client's fold + (METADATA/CHANNEL are `hasPermission`-gated), so the result is a **silently no-op control** — + which this codebase elsewhere explicitly calls out as worse than no control at all. +2. **Ban/Remove survive only because of a second, unrelated gate.** `canBan` is + `viewerCanBan && canBanTarget`, and `canBanTarget` routes through `canActOn`, which *is* + ban-aware. Remove the second condition and a banned admin gets a working Ban button. That is a + thin margin for a Critical-severity outcome (B2). + +`refoundConcordCommunity` is the sharpest instance: its own guard is +`isOwner || effectivePermissions(me).has(BAN)` — deliberately ban-blind — so a banned BAN-holder can +launch a full community Refounding from the shipping app. Honest receivers refuse it +(`drainConcordRekeys` checks the ban-aware `hasPermission`), so the blast radius today is noise plus +self-stranding — but it is a race against banlist propagation, and a fresh joiner who has not folded +the ban yet has no reason to refuse. + +**Fix.** Move the authority check into the action layer where it cannot be bypassed by a new caller +(desktop, CLI, a future screen), and switch every `effectivePermissions` used as an authorization +test to `hasPermission`. Keep `effectivePermissions` only where the question really is "what do +their roles say", independent of standing. + +## A4 — A banned member keeps broadcasting "typing", and we keep showing it + +**Low, both halves ours.** *Read:* `AccountConcordActions.sendConcordTyping`, +`ConcordCommunitySession.ingestTyping`. + +The send side checks `isWriteable()` and nothing else, so a banned member's stock app keeps emitting +kind-23311 heartbeats. The receive side checks that the rumor is a typing heartbeat, is bound to the +channel/epoch, and is not our own — and nothing else. So a banned member sits in the "… is typing" +row indefinitely, in a channel where every message they send is hidden. Cheap to fix on both ends, +and it directly contradicts what a ban promises the user. + +## A5 — A banned member's own client keeps reading and rendering everything + +**Medium, partly inherent.** *Read:* CORD-02/05, `ConcordCommunitySession`. + +Until a Refounding, a ban stops honest clients from *showing* the banned member's posts; it does not +stop delivering the community's posts *to* them. Their stock app keeps subscribing, decrypting and +rendering the whole community in real time. They also keep any invite links they hold (and can mint +more — A1). + +The cryptography here is inherent to a soft ban, but the **product** side is ours: "Ban" and "Remove +from community" are very different promises and the UI presents them as neighbours in one menu. +Worth making the difference explicit at the point of choice, and worth defaulting destructive +moderation to the Refounding path. + +--- + +# Part B — requires a malicious client + +The attacker writes their own events, so nothing client-side binds them. We can only refuse to honor +what they publish. + +## B1 — One edition at `Long.MAX_VALUE` pins an entity forever **Critical. Does not require a banned user, a sockpuppet, or the owner's absence. Unrecoverable.** @@ -87,7 +244,7 @@ cross-epoch tolerance. Options, roughly in order of preference: The first is the smallest change and closes the unrecoverability; the third should happen regardless. -## V2 — A banned staffer keeps Role, Grant and Banlist authority +## B2 — A banned staffer keeps Role, Grant and Banlist authority **Critical.** *Verified:* `quartz/…/cord04Roles/BannedStaffEscalationTest.kt` (13 tests). @@ -118,22 +275,22 @@ banned in pass A; then recompute the banlist under pass B's roster, keeping only authorized. Deterministic, terminates, no oscillation on mutual bans. **Consensus-affecting**: until Armada ships the same rule, we will drop editions they honor. -## V3 — A rogue rotator compacts the banlist away +## B3 — A rogue rotator compacts the banlist away **High.** *Verified:* `aRogueRotatorCompactsTheBanAwayForEveryClientWithoutAFloor`. A CORD-06 §3 compaction re-wraps one edition per entity and the *rotator* picks it, so a rotator can decline to carry the banlist forward. Every edition it serves is genuine, so no signature check sees the omission — `EntityFloor`'s own KDoc names this case ("clearing a banlist"). A banned member -cannot rotate, but the V2 puppet can. +cannot rotate, but the B2 puppet can. The result is not a clean unban but a **split community**: clients that already folded the ban refuse the rollback and still see it, fresh joiners have no floor and see no ban at all. Two populations permanently disagreeing about who is a member, with no event either side can call -forged. Closing V2 removes the puppet and takes this with it; floors alone do not, since they only +forged. Closing B2 removes the puppet and takes this with it; floors alone do not, since they only protect people who were already there. -## V4 — The Refounding recipient set is attacker-inflatable +## B4 — The Refounding recipient set is attacker-inflatable **High.** *Read:* `ConcordCommunitySession.allMembers()` / `emitChannelRumors`; `AccountConcordActions.refoundConcordCommunity` step 2; `ConcordRefounding.buildBaseRekeyWraps`. @@ -152,7 +309,7 @@ This is the cheapest thing on the list to fix and the only one that is not conse the recipient set, prefer recent/attested members when over the cap, and surface what was dropped (a silent truncation strands real members). Worth doing first. -## V5 — The ban is a per-pubkey display rule and the channel key is not revoked +## B5 — The ban is a per-pubkey display rule and the channel key is not revoked **High.** *Read:* `Account.consumeConcordRumorGated` (`isBanned(rumor.pubKey)`), `Account.isAcceptable`. @@ -160,12 +317,12 @@ Writing to a channel needs the channel key, which the ban does not take away; th whatever key the client feels like using. A malicious client therefore posts every message from a fresh npub and `isBanned` never matches — moderation is whack-a-mole against an infinite identity supply. Each message also costs every member two NIP-44 decrypts and two signature verifications -*before* the banlist check runs, and each fresh author inflates V4. +*before* the banlist check runs, and each fresh author inflates B4. There is no client-side answer; only a Refounding rotates the key out from under them. That is the -correct design, which is why V4 matters so much. +correct design, which is why B4 matters so much. -## V6 — Channel history is deletable on a naive third-party relay +## B6 — Channel history is deletable on a naive third-party relay **High, external.** *Verified (that we are safe):* `geode/…/ConcordPlaneKeyDeletionTest.kt` (3 tests). @@ -187,30 +344,7 @@ A community publishes wherever its metadata points. Any relay that authorizes de `pubkey` still hands every ex-member the wipe button, and a Refounding protects only the future. Worth a note in the CORD-01 spec and a line in the relay-selection guidance. -## V7 — Banlist rank rule diverges from Armada - -**Medium, known, deliberate.** See `docs/concord-banlist-rank-conformance.md`, already reported. - -We enforce §3's rank half on the Banlist and Armada does not, so the two clients can show different -banlists. Shipped knowingly. Row 3 of that report ("a banned `BAN` holder unbans themselves") was -left open as a fixpoint-ordering question — V2 is the general form of it, and the fix proposed there -resolves both. - -## V8 — A soft ban revokes no read access and no live invite - -**Medium, inherent.** *Read:* CORD-02/05. - -Until a Refounding, a banned member decrypts everything published — the ban only stops honest -clients from *showing* their posts, not from delivering the group's posts to them. They also keep -any invite links they created while privileged; those still resolve to bundles carrying the current -root. Publishing the root, or one live link, invites an unbanned crowd that each has to be banned -individually (and see V5). - -Not a bug so much as the definition of a soft ban, but it belongs on the list because the UI should -say so: "Ban" and "Remove from community" are very different promises and users will read the first -as the second. - -## V9 — The base-rekey plane is writable by every member +## B7 — The base-rekey plane is writable by every member **Low.** *Read:* `ConcordKeyDerivation.baseRekeyAddress`, `AccountConcordActions.drainConcordRekeys`. @@ -219,63 +353,36 @@ valid wraps there. Authorization happens after the blobs are scanned, so a flood a locator scan per blob on every revision tick. Bounded work per wrap and no correctness impact; listed for completeness. -## V10 — Stranded recovery: either broken, or a removal bypass -**Critical, and it forks — one of the two halves is true and both are bad.** -*Read:* `ConcordStrandedRecovery`, `AccountConcordActions.recoverStrandedConcordCommunities`, -`AccountConcordActions.mintConcordInvite`. +--- -`ConcordStrandedRecovery.isStranded` / `mergeForward` take only `(entry, bundle)`. There is **no -banlist check and no check that we were legitimately re-keyed** — the entire test is "the bundle at -my stored `inviteRef` sits at a higher epoch than I do". The unlock token lives in the link -fragment, which an ex-member keeps forever. So whether a removed member walks back in with the new -root depends *only* on whether the bundle at that coordinate ever advances an epoch. +# Part C — interop and not-yet-shipped -In Amethyst it never does: `mintConcordInvite` mints a **fresh link signer per mint**, so nothing -re-publishes at an existing coordinate, and `refoundConcordCommunity` does not re-mint or revoke -anything. Two consequences, and they are the fork: +## C1 — Banlist rank rule diverges from Armada -- **If nothing re-mints** — today's behaviour — then stranded recovery never fires *for anyone*. - That makes it dead code, and the cure `drainConcordRekeys`' own KDoc points to for "a BAN-holder - can evict anyone (the owner included) by omission" does not exist. An owner evicted by a rogue - admin has no way back. -- **If anything re-mints at a stable coordinate** — which is what CORD-05's design describes ("the - community keeps publishing its bundle at that same addressable coordinate, re-minted at the - current epoch"), so plausibly Armada in a cross-client community — then every removed member who - joined through a still-live link auto-recovers the new root on the 15-minute sweep, and - re-announces a Guestbook join so they look current again. **Refounding, the only hard removal, - is silently undone.** +**Medium, known, deliberate.** See `docs/concord-banlist-rank-conformance.md`, already reported. -Note also that `refoundConcordCommunity` never revokes the invite links the removed member created -or joined through, even though `ControlEntityKind.INVITE_REVOKED` exists and `classifyInvite` -already honors it. +We enforce §3's rank half on the Banlist and Armada does not, so the two clients can show different +banlists. Shipped knowingly. Row 3 of that report ("a banned `BAN` holder unbans themselves") was +left open as a fixpoint-ordering question — B2 is the general form of it, and the fix proposed there +resolves both. -**Fix direction.** Decide the intended semantics first — this needs a spec answer, not a patch. -Then: gate `mergeForward` on not being banned in the epoch we are merging *from*, have the -Refounding revoke the removed members' links, and either implement re-minting (so legitimate -recovery works) or drop the mechanism and give evicted owners a different route. +## C2 — Voice rooms are key-gated, not roster-gated -## V11 — Voice rooms are key-gated, not roster-gated +**Design-level; not currently reachable.** *Read:* `ConcordBrokerToken`, CORD-07 §2. -**High.** *Read:* `ConcordBrokerToken`, CORD-07 §2. +Downgraded from High on review: `ConcordBrokerToken` and `VoicePresence` are referenced nowhere +outside `quartz`, so Amethyst ships no Concord voice path yet. This is a note for whoever wires +one up, not a live hole. A member proves voice-room membership by signing a NIP-98 kind-27235 request with the channel's **derived voice signer key**, whose pubkey is the SFU room name. The broker is stateless and holds no community secret, so it cannot consult the Control Plane and has no idea a banlist exists. A banned member keeps that key until a Refounding, so they can join the voice room and stay in it. Nothing on the client side can evict them — kicking them from the UI does not kick them from the SFU. +It would be the one place where a ban fails *audibly*, in real time, in front of everyone, so it is +worth designing the roster check in before shipping rather than after. -This is the one place where a ban fails *audibly*, in real time, in front of everyone. Worth ranking -above its technical severity for that reason alone. - -## V12 — Typing indicators are not ban-filtered - -**Low.** *Read:* `ConcordCommunitySession.ingestTyping`. - -`ingestTyping` checks the rumor is a typing heartbeat, is bound to the channel/epoch, and is not our -own — and nothing else. A banned member (or any fresh npub holding the channel key, see V5) shows -in the "… is typing" row indefinitely. Cheap to fix and user-visible: the promise a ban makes is -that the member disappears, and here they do not. --- @@ -284,7 +391,7 @@ that the member disappears, and here they do not. This audit is bounded by what was opened. Checked and found sound: the wrap/seal envelope (no author impersonation — `rumor.pubKey == seal.pubKey` and `rumor.verifyId()`), Concord chat edits (`Note.latestConcordEdit` is author-gated, so a member cannot rewrite someone else's message), and -self-unban (V2). +self-unban (B2). Not looked at at all: @@ -292,7 +399,7 @@ Not looked at at all: Note that no channel-scoped rekey *receive* path appears to exist: `drainConcordRekeys` handles `ROOT_SCOPE` only, and `entry.privateChannels` is carried forward but never populated by a delivery path. If that is right, the only removal Amethyst can perform is a full-community - Refounding — which is exactly what V4 makes expensive. + Refounding — which is exactly what B4 makes expensive. - **In-plane reactions and deletes** — the edit path is author-gated; the delete path was not read. - **Guestbook kicks** (kind 3309) — the builder documents a KICK-bit + rank rule; the receive side was not verified against it. @@ -301,11 +408,23 @@ Not looked at at all: ## Suggested order -1. **V4** — cheapest, not consensus-affecting, and it protects the remedy every other fix depends on. -2. **V1** — worst blast radius and the only unrecoverable one; does not need an attacker to be - banned or privileged beyond a single ordinary bit. -3. **V2** (+V3, +V7 row 3) — one two-pass change closes all three. Coordinate with Armada first; - this one splits consensus. -4. **V6** — spec note + relay guidance; our own behaviour is already correct and now pinned. -5. **V5 / V8** — UI honesty about what a ban does, and a "Remove from community" affordance that - Refounds rather than bans. +**Part A first.** It is the whole of the realistic threat — a banned user with the app already +installed — and none of it needs coordination with anyone. + +1. **A1** — one guard on `mintConcordInvite` plus one on its button. Smallest fix on the list and it + closes the attack a banned user will actually reach for. +2. **A3** — move authority into the action layer and replace `effectivePermissions` with + `hasPermission` everywhere it is used as an authorization test. This is also the cheapest partial + mitigation for B2: it shrinks what a banned staffer can do *without* writing their own client. +3. **A2** — needs the semantics decided before any code. Raise it with the spec. +4. **A4 / A5** — small, user-visible, and they make the product honest about what a ban is. + +**Then Part B**, hardest first because the ceiling is highest: + +5. **B4** — cheap, not consensus-affecting, and it protects the remedy every other fix depends on. +6. **B1** — worst blast radius, the only unrecoverable one, and the bar is a single ordinary + permission bit. +7. **B2 (+B3, +C1's open row)** — one two-pass change closes all three. Coordinate with Armada + first; this one splits consensus. +8. **B6** — spec note plus relay-selection guidance; our own behaviour is already correct and pinned. +9. **B5 / B7** — accept, or bound. From 37d715830b68639392f6098ba6d1b1a7f87a7622 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 04:24:35 +0000 Subject: [PATCH 171/227] Let a drained paged walk close the leg below it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paged band records the events it SAW, never the range it asked for, so `legs()` can only ever say "walked this far" and keeps re-asking the leg below the floor. Against a relay whose corpus for one kind simply starts later than the others' that leg is unclosable: it comes back empty every cycle, an empty fetch earns no band, so the floor never moves. Measured on a live mirror of five NIP-65 indexers, three were in that state — kind 10002 re-walked from the beginning of time to Feb 2023 forever, because relay lists did not exist before then. The missing fact is why a page ended. `fetchAllPages` treated all three terminal signals as one bare `Unit`, so an empty page could not be told apart from silence or a CLOSED. It now carries a PageEnd, and reports `onDrained` only for the one ending that proves absence: an EOSE on a page that returned nothing, with no filter capped by its `limit` and no `search` filter in play (both stop the walk short of the corpus). An idle timeout is silence, not an answer, and recording it would durably claim coverage the relay never served. A callback rather than a richer return type: ~25 call sites across quartz, geode and downstream use the `Int`, and none should have to change to learn a fact they do not want. It follows `onNewPage`'s shape. `SyncCoverage.record` takes `drained` and marks the kinds that produced evidence complete — which required completeness to move from Band onto Span. It could not stay on the band: once kinds diverge, `legs()` hands each group its own ask, so a walk that drained `kinds: [10002]` proves nothing about kind 0, and a band-level flag set from that leg would claim both. That is the same over-claim per-kind spans exist to prevent, one level up. `Band.complete` stays as a DERIVED all-kinds-complete, so both state files keep writing the flag a pre-per-kind reader expects, and read it back as every span's default. A kind the walk never saw at all still earns nothing: there is no interval to anchor a claim to, and inventing one would be the over-claim again. Tests: five in NostrClientFetchAllPagesDrainTest pinning EOSE-empty vs silence vs CLOSED vs cannot-connect vs a fulfilled limit, and five in SyncCoverageTest for per-kind completeness, widening, and the deeper-floor escape hatch that a drain must not defeat. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TNy5BsU9NErXYa3UNGTeJ --- .../geode/mirror/SyncCoverageFile.kt | 20 +- .../NostrClientFetchAllPagesExt.kt | 77 +++++- .../relay/client/accessories/SyncCoverage.kt | 78 ++++-- .../client/accessories/SyncCoverageTest.kt | 120 ++++++++- .../NostrClientFetchAllPagesDrainTest.kt | 230 ++++++++++++++++++ 5 files changed, 495 insertions(+), 30 deletions(-) create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt index ea5f9aad21..a13fa57420 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/SyncCoverageFile.kt @@ -108,7 +108,6 @@ class SyncCoverageFile( key to SyncCoverage.Band( spansOf(o), - o["complete"]?.jsonPrimitive?.boolean ?: false, o["fullAt"]?.jsonPrimitive?.long ?: 0L, ) }.toMap(), @@ -128,17 +127,31 @@ class SyncCoverageFile( * discarded, and the first paged walk that reports per kind replaces it. * Dropping it instead would re-download every upstream's corpus once on * upgrade, which is the cost bands exist to avoid. + * + * Completeness is read the same way, one level down: a span written before + * it was per kind has no `complete` of its own, so it inherits the band's — + * which is precisely what that flag used to mean for every kind at once. */ private fun spansOf(o: JsonObject): Map { + val bandComplete = o["complete"]?.jsonPrimitive?.boolean ?: false o["spans"]?.jsonObject?.let { spans -> return spans.entries.associate { (kind, v) -> val span = v.jsonObject - kind.toInt() to SyncCoverage.Span(span.getValue("min").jsonPrimitive.long, span.getValue("max").jsonPrimitive.long) + kind.toInt() to + SyncCoverage.Span( + span.getValue("min").jsonPrimitive.long, + span.getValue("max").jsonPrimitive.long, + span["complete"]?.jsonPrimitive?.boolean ?: bandComplete, + ) } } return mapOf( SyncCoverage.ALL_KINDS to - SyncCoverage.Span(o.getValue("min").jsonPrimitive.long, o.getValue("max").jsonPrimitive.long), + SyncCoverage.Span( + o.getValue("min").jsonPrimitive.long, + o.getValue("max").jsonPrimitive.long, + bandComplete, + ), ) } @@ -170,6 +183,7 @@ class SyncCoverageFile( buildJsonObject { put("min", span.min) put("max", span.max) + put("complete", span.complete) }, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index f7b4834515..e08d432ee1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -32,6 +32,24 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.ensureActive import kotlin.coroutines.coroutineContext +/** + * Why one page stopped. The three terminal signals used to be indistinguishable — + * all of them put a bare `Unit` on the page's channel — and [fetchAllPages] only + * ever asked *whether* a page ended, never *how*. [onDrained] needs the + * difference: an empty page is proof the relay has nothing older only when the + * relay actually said so. + */ +private enum class PageEnd { + /** The relay finished serving its stored events for this REQ. */ + EOSE, + + /** The relay ended the subscription itself — auth required, rate limited, policy. */ + CLOSED, + + /** Never got to ask. */ + CANNOT_CONNECT, +} + /** * Downloads all pages of events matching [filters] from a single [relay] using * paginated `until` cursors. @@ -87,6 +105,19 @@ import kotlin.coroutines.coroutineContext * bounds this walk is a [Filter.limit] (the documented way to cap a download) or * cancelling the caller, which the [ensureActive] at the top of each page honors. * @param onEvent Called once for every distinct event delivered, in page order. + * @param onDrained Called at most once, just before returning, when the walk ended + * because the relay served everything [filters] match at-or-below the starting + * `until` — an empty page confirmed by an EOSE. That is the difference between + * "the relay has nothing older" and "the relay stopped answering", which the + * `Int` return cannot express: a caller recording sync coverage may treat the + * range below the oldest event it saw as verified-empty ONLY in the first case. + * Every other ending — an idle timeout, a CLOSED, a failed connect, a fulfilled + * [Filter.limit] — leaves it unfired, because none of them prove absence. + * + * A callback rather than a richer return type on purpose: this function has + * ~25 call sites across quartz, geode and downstream repos that use the `Int`, + * and none of them should have to change to learn a fact they do not want. It + * follows the shape [onNewPage] already set. * @return Total number of distinct events delivered across all pages. */ suspend fun INostrClient.fetchAllPages( @@ -94,10 +125,12 @@ suspend fun INostrClient.fetchAllPages( filters: List, idleTimeoutMs: Long = 30_000L, onNewPage: ((Long) -> Unit)? = null, + onDrained: (() -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): Int { var until: Long? = null var totalEvents = 0 + var drained = false // Track how many matching events each filter has received so far. val matchCountPerFilter = IntArray(filters.size) @@ -155,7 +188,7 @@ suspend fun INostrClient.fetchAllPages( // REQ, so firing this earlier would report a page that never happens. if (until != null) onNewPage?.invoke(until) - val doneChannel = Channel(Channel.CONFLATED) + val doneChannel = Channel(Channel.CONFLATED) // Idle watchdog for this page: every arriving event bumps it, so the page's // timeout measures silence since the relay's most recent message (the same @@ -169,6 +202,12 @@ suspend fun INostrClient.fetchAllPages( var pageMinTs = Long.MAX_VALUE val idsAtPageMin = HashSet() + // How this page ended, read after the wait: null for an idle timeout, which + // [receiveWithinIdle] reports by returning null. Only an EOSE can support a + // drain claim below — silence is not an answer, and a CLOSED is the relay + // declining to give one. + var pageEnd: PageEnd? = null + try { val listener = object : SubscriptionListener { @@ -241,7 +280,7 @@ suspend fun INostrClient.fetchAllPages( relay: NormalizedRelayUrl, forFilters: List?, ) { - doneChannel.trySend(Unit) + doneChannel.trySend(PageEnd.EOSE) } override fun onClosed( @@ -249,7 +288,7 @@ suspend fun INostrClient.fetchAllPages( relay: NormalizedRelayUrl, forFilters: List?, ) { - doneChannel.trySend(Unit) + doneChannel.trySend(PageEnd.CLOSED) } override fun onCannotConnect( @@ -257,7 +296,7 @@ suspend fun INostrClient.fetchAllPages( message: String, forFilters: List?, ) { - doneChannel.trySend(Unit) + doneChannel.trySend(PageEnd.CANNOT_CONNECT) } } @@ -266,7 +305,7 @@ suspend fun INostrClient.fetchAllPages( // Wait for the page's terminal signal (EOSE / CLOSED / cannot-connect), // giving up only after [idleTimeoutMs] of silence — the wait resets on every // arriving event, so an actively streaming page is never cut mid-delivery. - doneChannel.receiveWithinIdle(clock, idleTimeoutMs) + pageEnd = doneChannel.receiveWithinIdle(clock, idleTimeoutMs) unsubscribe(subId) doneChannel.close() @@ -277,8 +316,26 @@ suspend fun INostrClient.fetchAllPages( totalEvents += delivered - // The relay sent nothing at-or-below `until` → the whole set is drained. - if (received == 0) break + // The relay sent nothing at-or-below `until`. Whether that DRAINS the set + // depends on why the page ended and on what was asked: + // + // - only an EOSE proves absence. An idle timeout (`pageEnd == null`) is + // silence and a CLOSED is the relay declining to answer; reading either + // as "nothing older exists" would durably record coverage the relay + // never served, which is the one error a coverage claim must not make. + // - a filter that reached its [Filter.limit] stopped early on the caller's + // own instruction, so nothing below its last event was ever asked for. + // - a `search` filter runs on the first page only, so every page after it + // dropped out never carried it and cannot speak for it. + if (received == 0) { + val cappedByLimit = + filters.indices.any { i -> + val limit = filters[i].limit + limit != null && matchCountPerFilter[i] >= limit + } + drained = pageEnd == PageEnd.EOSE && !cappedByLimit && filters.none { it.search != null } + break + } if (delivered == 0) { // Every event this page was a boundary-second duplicate; nothing older @@ -312,6 +369,10 @@ suspend fun INostrClient.fetchAllPages( until = nextUntil } + // After the loop, not at the break: every other exit above leaves `drained` + // false, and firing from one place keeps "at most once" true by construction. + if (drained) onDrained?.invoke() + return totalEvents } @@ -320,6 +381,7 @@ suspend fun INostrClient.fetchAllPages( filters: List, idleTimeoutMs: Long = 30_000L, onNewPage: ((Long) -> Unit)? = null, + onDrained: (() -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): Int = fetchAllPages( @@ -327,5 +389,6 @@ suspend fun INostrClient.fetchAllPages( filters = filters, idleTimeoutMs = idleTimeoutMs, onNewPage = onNewPage, + onDrained = onDrained, onEvent = onEvent, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt index c8e2e3aed8..03a0ec6d40 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverage.kt @@ -102,12 +102,27 @@ class SyncCoverage( } } - /** A covered `created_at` interval, inclusive at both ends. */ + /** + * A covered `created_at` interval, inclusive at both ends. + * + * [complete] is the difference between "we walked this interval" and "there + * is nothing below it". A paged fetch earns the first by seeing events; it + * earns the second only when the relay EOSEs on an empty page, which is the + * one answer that distinguishes an exhausted corpus from a relay that capped + * us or went quiet (see `fetchAllPages`'s `onDrained`). A finished negentropy + * reconcile earns it too, by comparing the whole range at once. + * + * It lives HERE rather than on [Band] because a paged leg does not have to + * cover every kind: once kinds diverge, [legs] hands each group its own ask, + * so a walk that drained `kinds: [10002]` says nothing about kind 0. A + * band-level flag set from such a leg would claim both. + */ data class Span( val min: Long, val max: Long, + val complete: Boolean = false, ) { - fun widen(other: Span) = Span(minOf(min, other.min), maxOf(max, other.max)) + fun widen(other: Span) = Span(minOf(min, other.min), maxOf(max, other.max), complete || other.complete) } /** @@ -125,30 +140,36 @@ class SyncCoverage( * span under [ALL_KINDS] — the same claim as before, correctly scoped to * the case where it is the only claim available. * - * [complete] is the difference between "we walked this span" (a paged - * fetch) and "we are in sync below this point" (a finished negentropy - * reconcile, which compared the whole range). Only a complete band may - * skip its older leg. It is a property of the BAND rather than of a span: - * a reconcile compares the filter's whole id set at once, so it either - * covers every kind in it or none. + * Completeness is per kind, on [Span] — see there for why a paged leg + * cannot speak for kinds it did not ask about. * * [fullAt] is when the last pass that started from nothing finished — the * clock for the periodic re-walk. */ data class Band( val spans: Map, - val complete: Boolean = false, val fullAt: Long = 0, ) { /** The outer edges across every kind — for logging and for the file's compatibility fields. */ val minCreatedAt: Long get() = spans.values.minOfOrNull { it.min } ?: 0 val maxCreatedAt: Long get() = spans.values.maxOfOrNull { it.max } ?: 0 + /** + * The band-level claim, DERIVED: true only when every kind is complete. + * + * Kept for the state files, which still write a `complete` beside + * `min`/`max` so a build from before per-kind completeness reads them and + * behaves as it always did. `all` rather than `any` is the safe direction + * for that reader: it cannot see the per-kind detail, so it must be told + * the weakest true thing, not the strongest. + */ + val complete: Boolean get() = spans.isNotEmpty() && spans.values.all { it.complete } + /** Widen each kind by its counterpart, keeping kinds only one side knows. */ fun widen(other: Band): Band { val merged = spans.toMutableMap() for ((kind, span) in other.spans) merged[kind] = merged[kind]?.widen(span) ?: span - return Band(merged, complete || other.complete, fullAt) + return Band(merged, fullAt) } } @@ -189,7 +210,8 @@ class SyncCoverage( val kinds = filter.kinds if (kinds.isNullOrEmpty()) { // Nothing to split by. One span, exactly as before. - return windows(filter, band.spans[ALL_KINDS], band.complete, floor) + val span = band.spans[ALL_KINDS] + return windows(filter, span, span?.complete == true, floor) .map { (since, until) -> filter.copy(since = since, until = until) } } @@ -205,7 +227,12 @@ class SyncCoverage( // wider claim for every kind — the behaviour this replaces — and // self-corrects on the first paged walk that reports per kind. val span = band.spans[kind] ?: band.spans[ALL_KINDS] - byWindows.getOrPut(windows(filter, span, band.complete, floor)) { mutableListOf() }.add(kind) + // Completeness comes from the SPAN, so a leg that drained one kind + // drops only that kind's older leg. Before this it came from the + // band, and a reconcile was the only thing that could set it — which + // is why a drained paged walk over `kinds: [10002]` used to leave an + // older leg that no future walk could ever close. + byWindows.getOrPut(windows(filter, span, span?.complete == true, floor)) { mutableListOf() }.add(kind) } return byWindows.flatMap { (windows, group) -> // toList(): `group` is the mutable accumulator above, and handing @@ -266,6 +293,19 @@ class SyncCoverage( * the sync STARTED — recorded against that instant rather than the newest * event seen, because "the relay had nothing newer" and "we never asked" * must not look alike. + * + * [drained] is the paged equivalent, and the only way a paged walk can ever + * claim its older leg is finished. Pass it from `fetchAllPages`'s + * `onDrained` — the relay EOSEd on an empty page, so there is nothing below + * what was seen. Without it a paged band only ever says "walked this far", + * and the leg below it is re-asked every cycle forever: against a relay + * whose corpus for a kind simply starts later than the others', that leg + * returns nothing, records nothing, and so can never close itself. + * + * It marks only the kinds that produced evidence. A kind the walk never saw + * at all has no interval to anchor a claim to, and inventing one would be + * the over-claim [Span] exists to prevent — so it keeps no band and is + * asked again, which is the safe direction. */ fun record( url: NormalizedRelayUrl, @@ -275,13 +315,14 @@ class SyncCoverage( paged: Boolean, reconciledThrough: Long? = null, observedByKind: Map? = null, + drained: Boolean = false, ) { if (reconciledThrough != null) { // A reconcile compares the filter's whole id set in one pass, so // the span it earns is the same for every kind the filter names — // no per-kind evidence needed or possible. - val span = Span(observedMin ?: reconciledThrough, reconciledThrough) - put(url, filter, kindsOf(filter).associateWith { span }, complete = true) + val span = Span(observedMin ?: reconciledThrough, reconciledThrough, complete = true) + put(url, filter, kindsOf(filter).associateWith { span }) return } if (!paged) return @@ -298,7 +339,7 @@ class SyncCoverage( } if (plausible.isEmpty()) return val named = filter.kinds - val spans = + val spans: Map = if (named.isNullOrEmpty()) { // A filter naming no kinds cannot be split, so [legs] reads // ALL_KINDS and nothing else. Storing what the walk saw per @@ -321,7 +362,7 @@ class SyncCoverage( plausible.filterKeys { it in named } } if (spans.isEmpty()) return - put(url, filter, spans, complete = false) + put(url, filter, if (drained) spans.mapValues { it.value.copy(complete = true) } else spans) return } @@ -349,7 +390,7 @@ class SyncCoverage( // range nothing can be in, forever. if (observedMin == null || observedMax == null) return if (!isPlausible(observedMin, at) || !isPlausible(observedMax, at)) return - put(url, filter, kinds.associateWith { Span(observedMin, observedMax) }, complete = false) + put(url, filter, kinds.associateWith { Span(observedMin, observedMax, complete = drained) }) } /** The kinds a band is keyed by: the filter's, or [ALL_KINDS] when it names none. */ @@ -364,9 +405,8 @@ class SyncCoverage( url: NormalizedRelayUrl, filter: Filter, spans: Map, - complete: Boolean, ) { - val fresh = Band(spans, complete, now()) + val fresh = Band(spans, now()) bands.merge(key(url, filter), fresh) { old, new -> if (isStale(old)) new else old.widen(new) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt index abf1113f02..3ead83ba3c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/SyncCoverageTest.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue @@ -511,7 +512,6 @@ class SyncCoverageTest { key to SyncCoverage.Band( mapOf(SyncCoverage.ALL_KINDS to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)), - complete = false, fullAt = now(), ), ), @@ -579,4 +579,122 @@ class SyncCoverageTest { assertEquals(2, c.legs(relay, anyKind).size) assertEquals(1_690_000_000L, c.legs(relay, anyKind)[0].until) } + + // ---- draining: the leg a paged walk is finally allowed to close --------- + + @Test + fun `a drained paged walk stops asking about the past`() { + // THE OTHER HALF OF THE BUG ABOVE. Per-kind spans stopped kind 0 from + // vouching for 30382 — but they left 30382 an older leg that nothing + // could ever close. The relay's corpus for it simply starts later, so + // that leg comes back empty every cycle, records nothing (an empty + // fetch earns no band), and the floor never moves. Forever. + // + // A drain is the missing evidence: the relay EOSEd on an empty page, so + // there IS nothing below what we saw, and the leg is done. + val walked = SyncCoverage() + walked.record(relay, profiles, 1_690_000_000L, 1_700_000_000L, paged = true) + assertEquals(2, walked.legs(relay, profiles).size, "not drained: still asks below the floor") + + val drained = SyncCoverage() + drained.record(relay, profiles, 1_690_000_000L, 1_700_000_000L, paged = true, drained = true) + val legs = drained.legs(relay, profiles) + assertEquals(1, legs.size, "drained: only the newer leg is left") + assertEquals(1_700_000_000L, legs[0].since) + assertNull(legs[0].until) + } + + @Test + fun `a drained leg closes its own kind and not the others`() { + // Why completeness had to move from the band onto the span. After the + // kinds diverge, legs() hands each group its own ask — so a walk that + // drained `kinds: [30382]` proves nothing whatever about kind 0. A + // band-level flag set from that leg would have claimed both, which is + // the same over-claim per-kind spans exist to prevent, just one level up. + val c = SyncCoverage() + c.record( + relay, + mixed, + null, + null, + paged = true, + observedByKind = mapOf(30382 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)), + drained = true, + ) + // Kind 0 has no evidence at all here, so it keeps asking for everything. + c.record( + relay, + mixed, + null, + null, + paged = true, + observedByKind = mapOf(0 to SyncCoverage.Span(1_600_000_000L, 1_700_000_000L)), + ) + + val legs = c.legs(relay, mixed) + assertTrue(!reaches(legs, 30382, 1_650_000_000L), "30382 drained — its past is settled") + assertTrue(reaches(legs, 0, 1_500_000_000L), "kind 0 did not drain, so its older leg stands") + } + + @Test + fun `a band is complete only when every kind is`() { + // The band-level flag is derived now, and the state files still write it + // for a reader that predates per-kind completeness. That reader cannot + // see the detail, so it has to be told the weakest true thing. + val c = SyncCoverage() + c.record( + relay, + mixed, + null, + null, + paged = true, + observedByKind = mapOf(0 to SyncCoverage.Span(1_690_000_000L, 1_700_000_000L)), + drained = true, + ) + assertTrue(c.band(relay, mixed)!!.complete, "the only kind with a span drained") + + c.record( + relay, + mixed, + null, + null, + paged = true, + observedByKind = mapOf(30382 to SyncCoverage.Span(1_695_000_000L, 1_700_000_000L)), + ) + assertFalse(c.band(relay, mixed)!!.complete, "…and now one of the two has not") + } + + @Test + fun `widening carries a drain forward rather than losing it`() { + // Two legs of one walk against the same relay land in the same span. + // Widening must not let the second, undrained one erase what the first + // proved: the past below the merged floor really was checked. + val c = SyncCoverage() + c.record(relay, profiles, 1_690_000_000L, 1_695_000_000L, paged = true, drained = true) + c.record(relay, profiles, 1_696_000_000L, 1_700_000_000L, paged = true) + + assertTrue( + c + .band(relay, profiles)!! + .spans + .getValue(0) + .complete, + ) + assertEquals(1, c.legs(relay, profiles).size, "still done with the past") + } + + @Test + fun `a deeper floor still re-opens history below a drained band`() { + // A drain says "nothing below what this walk asked for", not "nothing + // below, ever". A caller that now reaches deeper than the band's floor + // gets its older leg back — the same escape hatch a finished reconcile + // has, and the reason `since` is consulted at all. + val c = SyncCoverage() + c.record(relay, profiles, 1_690_000_000L, 1_700_000_000L, paged = true, drained = true) + + assertEquals(1, c.legs(relay, profiles).size) + val deeper = c.legs(relay, profiles, floor = 1_600_000_000L) + assertEquals(2, deeper.size, "the caller's floor dropped below the band, so the past re-opens") + assertEquals(1_690_000_000L, deeper[0].until) + } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt new file mode 100644 index 0000000000..e8797a8d0f --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Pins `onDrained` — the one signal that tells a caller the relay served + * *everything* below where the walk stopped, rather than merely stopping there. + * + * The distinction is invisible in the `Int` return and matters enormously to + * anything recording sync coverage: without it, "the relay has nothing older" + * and "the relay capped us / went quiet / hung up" look identical, so a coverage + * band can never close its oldest leg and re-asks a range that will always come + * back empty, every cycle, forever. + * + * Real-clock ([runBlocking]) for the same reason the idle-timeout suite is: the + * page watchdog is a monotonic clock bumped from the socket reader thread. + */ +class NostrClientFetchAllPagesDrainTest { + /** Captures the subscription listener so the test can play a relay, page by page. */ + private class ScriptedClient : INostrClient by EmptyNostrClient() { + @Volatile + var listener: SubscriptionListener? = null + + @Volatile + var subscribeCount = 0 + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + subscribeCount++ + this.listener = listener + } + + /** Block until the walk has opened its [n]th page, so a script can answer it. */ + suspend fun awaitPage(n: Int) { + while (subscribeCount < n) delay(2) + } + } + + private val relay = RelayUrlNormalizer.normalize("wss://drain.example.com") + + private fun event(createdAt: Long) = + Event( + id = createdAt.toString(16).padStart(64, '0'), + pubKey = "f".repeat(64), + createdAt = createdAt, + kind = 1, + tags = emptyArray(), + content = "e$createdAt", + sig = "0".repeat(128), + ) + + @Test + fun anEmptyPageConfirmedByEoseDrains() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(1000), false, relay, null) + client.listener!!.onEose(relay, null) + + // The second page asks below 1000 and the relay says, with an + // EOSE, that it has nothing. THAT is a drain. + client.awaitPage(2) + client.listener!!.onEose(relay, null) + } + + var drained = false + val total = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + onDrained = { drained = true }, + ) { } + feeder.join() + + assertEquals(2, total) + assertTrue(drained, "an empty page the relay EOSEd is proof there is nothing older") + } + + @Test + fun aSilentPageDoesNotDrain() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(1000), false, relay, null) + client.listener!!.onEose(relay, null) + // Page two: the relay simply stops answering. Silence is not an + // answer — reading it as "nothing older exists" would durably + // record coverage that was never served. + client.awaitPage(2) + } + + var drained = false + val total = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 200, + onDrained = { drained = true }, + ) { } + feeder.join() + + assertEquals(2, total, "the events already delivered are still kept") + assertFalse(drained, "an idle timeout says nothing about what the relay holds") + } + + @Test + fun aClosedSubscriptionDoesNotDrain() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(1000), false, relay, null) + client.listener!!.onEose(relay, null) + // Page two: the relay ends the subscription instead of serving + // it — auth-required, rate limit, policy. It declined to answer, + // which is not the same as answering "nothing". + client.awaitPage(2) + client.listener!!.onClosed("auth-required: we don't serve that", relay, null) + } + + var drained = false + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + onDrained = { drained = true }, + ) { } + feeder.join() + + assertFalse(drained, "a CLOSED is the relay declining, not an empty corpus") + } + + @Test + fun aRelayItCannotReachDoesNotDrain() = + runBlocking { + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onCannotConnect(relay, "connection refused", null) + } + + var drained = false + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + onDrained = { drained = true }, + ) { } + feeder.join() + + assertFalse(drained, "never got to ask") + } + + @Test + fun aFulfilledLimitDoesNotDrain() = + runBlocking { + // The caller bounded the download itself, so the walk stopped on its + // own instruction rather than at the end of the relay's corpus. + // Nothing below the last event was ever asked for. + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(1000), false, relay, null) + client.listener!!.onEose(relay, null) + } + + var drained = false + val total = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1), limit = 2)), + idleTimeoutMs = 2_000, + onDrained = { drained = true }, + ) { } + feeder.join() + + assertEquals(2, total) + assertEquals(1, client.subscribeCount, "the limit was met, so there was no second page") + assertFalse(drained, "a fulfilled limit is the caller stopping, not the corpus ending") + } +} From 5136a97b16c737382a54bab04d6b57b0f1479cda Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 05:25:06 +0000 Subject: [PATCH 172/227] Return the walk's outcome instead of signalling a drain by callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `onDrained` was the wrong shape. It reported the one ending a coverage caller happens to need and threw the rest away, so a CLOSED and an idle timeout still arrived indistinguishable from a clean finish — the very conflation this branch set out to remove, just moved one step along. `fetchAllPages` now returns `PagedFetchResult(downloaded, end)`, where `end` names every way the loop can stop: DRAINED, LIMIT_REACHED, IDLE, CLOSED, CANNOT_CONNECT, UNPAGEABLE. `drained` stays as a shorthand on the result so the meaning lives in one place. A caller can no longer ignore the reason by accident, and the two failure endings are now reportable rather than silently swallowed. I argued for the callback on the grounds that ~25 call sites use the `Int`. That was overstated: most call it as a statement and never touch the return. Six needed a `.downloaded`, all mechanical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016TNy5BsU9NErXYa3UNGTeJ --- .../geode/mirror/MirrorWorker.kt | 2 +- .../NostrClientFetchAllPagesExt.kt | 139 +++++++++++++----- .../NostrClientFetchAllPagesPoolExt.kt | 4 +- .../NostrClientFetchAllPagesDrainTest.kt | 73 +++++---- ...NostrClientFetchAllPagesIdleTimeoutTest.kt | 4 +- .../NostrClientReqBypassingRelayLimitsTest.kt | 6 +- 6 files changed, 145 insertions(+), 83 deletions(-) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt index e10f2b14bf..b78c0077a9 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt @@ -548,7 +548,7 @@ class MirrorWorker( // The watchdog matches negentropySync's default rather // than fetchAllPages' shorter one: a paged catch-up // sits behind the same slow upstreams. - downloaded += client.fetchAllPages(up.url, listOf(leg), idleTimeoutMs = 120_000L) { observe(it) } + downloaded += client.fetchAllPages(up.url, listOf(leg), idleTimeoutMs = 120_000L) { observe(it) }.downloaded true } paged = paged || legPaged diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index e08d432ee1..203311e8e4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -33,13 +33,13 @@ import kotlinx.coroutines.ensureActive import kotlin.coroutines.coroutineContext /** - * Why one page stopped. The three terminal signals used to be indistinguishable — + * Why ONE page stopped. The three terminal signals used to be indistinguishable — * all of them put a bare `Unit` on the page's channel — and [fetchAllPages] only - * ever asked *whether* a page ended, never *how*. [onDrained] needs the + * ever asked *whether* a page ended, never *how*. [PagedFetchResult] needs the * difference: an empty page is proof the relay has nothing older only when the * relay actually said so. */ -private enum class PageEnd { +private enum class PageSignal { /** The relay finished serving its stored events for this REQ. */ EOSE, @@ -50,6 +50,62 @@ private enum class PageEnd { CANNOT_CONNECT, } +/** + * What a [fetchAllPages] walk delivered, and why it stopped. + * + * The count alone cannot answer the question that matters to anything recording + * coverage: is there nothing older, or did the relay simply stop giving us more? + * Those look identical from `downloaded`, and a caller that guesses wrong either + * re-walks a corpus forever or claims history it never read. + */ +data class PagedFetchResult( + /** Total number of distinct events delivered across all pages. */ + val downloaded: Int, + val end: End, +) { + enum class End { + /** + * A page came back empty and the relay EOSEd it: there is nothing at or + * below the last cursor. The only ending that proves ABSENCE, and so the + * only one a coverage claim may be built on. + */ + DRAINED, + + /** + * A [Filter.limit] was fulfilled. The caller bounded the download itself, + * so the walk stopped on its own instruction, not at the end of the + * corpus — nothing below the last event was ever asked for. + */ + LIMIT_REACHED, + + /** + * The relay went quiet for `idleTimeoutMs` without ending the page. + * Silence is not an answer; everything delivered so far is still good. + */ + IDLE, + + /** The relay ended the subscription — auth required, rate limited, policy. */ + CLOSED, + + /** Never got to ask. */ + CANNOT_CONNECT, + + /** + * The walk cannot advance its cursor: only `search` hits came back (NIP-50 + * results are relevance-ranked, so they never page), or a first page + * delivered nothing any active filter matched. + */ + UNPAGEABLE, + } + + /** + * Shorthand for the one ending that licenses skipping work later. Read this + * rather than comparing to [End.DRAINED] by hand, so the meaning stays in one + * place if the enum grows. + */ + val drained: Boolean get() = end == End.DRAINED +} + /** * Downloads all pages of events matching [filters] from a single [relay] using * paginated `until` cursors. @@ -105,32 +161,25 @@ private enum class PageEnd { * bounds this walk is a [Filter.limit] (the documented way to cap a download) or * cancelling the caller, which the [ensureActive] at the top of each page honors. * @param onEvent Called once for every distinct event delivered, in page order. - * @param onDrained Called at most once, just before returning, when the walk ended - * because the relay served everything [filters] match at-or-below the starting - * `until` — an empty page confirmed by an EOSE. That is the difference between - * "the relay has nothing older" and "the relay stopped answering", which the - * `Int` return cannot express: a caller recording sync coverage may treat the - * range below the oldest event it saw as verified-empty ONLY in the first case. - * Every other ending — an idle timeout, a CLOSED, a failed connect, a fulfilled - * [Filter.limit] — leaves it unfired, because none of them prove absence. - * - * A callback rather than a richer return type on purpose: this function has - * ~25 call sites across quartz, geode and downstream repos that use the `Int`, - * and none of them should have to change to learn a fact they do not want. It - * follows the shape [onNewPage] already set. - * @return Total number of distinct events delivered across all pages. + * @return What was delivered and WHY the walk stopped — see [PagedFetchResult]. + * The reason is part of the answer, not a detail: `downloaded` cannot tell + * "the relay has nothing older" from "the relay stopped answering", and a + * caller recording sync coverage may only treat the range below the oldest + * event it saw as verified-empty in the first case. */ suspend fun INostrClient.fetchAllPages( relay: NormalizedRelayUrl, filters: List, idleTimeoutMs: Long = 30_000L, onNewPage: ((Long) -> Unit)? = null, - onDrained: (() -> Unit)? = null, onEvent: suspend (Event) -> Unit, -): Int { +): PagedFetchResult { var until: Long? = null var totalEvents = 0 - var drained = false + // Overwritten by whichever break ends the loop. UNPAGEABLE is the honest + // default: the two breaks that leave it alone are both "the cursor cannot + // advance", and it is the reading that licenses the least. + var end = PagedFetchResult.End.UNPAGEABLE // Track how many matching events each filter has received so far. val matchCountPerFilter = IntArray(filters.size) @@ -181,14 +230,25 @@ suspend fun INostrClient.fetchAllPages( stillNeedsMore && pageableThisPage } - if (activeFilters.isEmpty()) break + if (activeFilters.isEmpty()) { + // Every filter either met its limit or is a search that has had its + // one page. The first is the caller stopping the walk; the second + // cannot page at all. Neither is the corpus ending. + end = + if (filters.any { it.limit != null }) { + PagedFetchResult.End.LIMIT_REACHED + } else { + PagedFetchResult.End.UNPAGEABLE + } + break + } // Announce the page only now that we know it will actually be fetched: a // search-only filter drops out of activeFilters above and breaks with no // REQ, so firing this earlier would report a page that never happens. if (until != null) onNewPage?.invoke(until) - val doneChannel = Channel(Channel.CONFLATED) + val doneChannel = Channel(Channel.CONFLATED) // Idle watchdog for this page: every arriving event bumps it, so the page's // timeout measures silence since the relay's most recent message (the same @@ -206,7 +266,7 @@ suspend fun INostrClient.fetchAllPages( // [receiveWithinIdle] reports by returning null. Only an EOSE can support a // drain claim below — silence is not an answer, and a CLOSED is the relay // declining to give one. - var pageEnd: PageEnd? = null + var pageEnd: PageSignal? = null try { val listener = @@ -280,7 +340,7 @@ suspend fun INostrClient.fetchAllPages( relay: NormalizedRelayUrl, forFilters: List?, ) { - doneChannel.trySend(PageEnd.EOSE) + doneChannel.trySend(PageSignal.EOSE) } override fun onClosed( @@ -288,7 +348,7 @@ suspend fun INostrClient.fetchAllPages( relay: NormalizedRelayUrl, forFilters: List?, ) { - doneChannel.trySend(PageEnd.CLOSED) + doneChannel.trySend(PageSignal.CLOSED) } override fun onCannotConnect( @@ -296,7 +356,7 @@ suspend fun INostrClient.fetchAllPages( message: String, forFilters: List?, ) { - doneChannel.trySend(PageEnd.CANNOT_CONNECT) + doneChannel.trySend(PageSignal.CANNOT_CONNECT) } } @@ -333,7 +393,15 @@ suspend fun INostrClient.fetchAllPages( val limit = filters[i].limit limit != null && matchCountPerFilter[i] >= limit } - drained = pageEnd == PageEnd.EOSE && !cappedByLimit && filters.none { it.search != null } + end = + when { + pageEnd == PageSignal.CLOSED -> PagedFetchResult.End.CLOSED + pageEnd == PageSignal.CANNOT_CONNECT -> PagedFetchResult.End.CANNOT_CONNECT + pageEnd == null -> PagedFetchResult.End.IDLE + cappedByLimit -> PagedFetchResult.End.LIMIT_REACHED + filters.any { it.search != null } -> PagedFetchResult.End.UNPAGEABLE + else -> PagedFetchResult.End.DRAINED + } break } @@ -345,14 +413,17 @@ suspend fun INostrClient.fetchAllPages( // recovers progress, dropping only the second's unreachable tail). Both // are resolved by stepping strictly past it. `boundary` is null only on // the first page, which has no dedup and so can't be all-duplicate. - val step = boundary ?: break + val step = boundary ?: break // first page, all-duplicate: impossible, and `end` stays UNPAGEABLE until = step - 1 seenAtBoundary = HashSet() continue } // Only search hits advanced nothing pageable → can't page further. - if (pageMinTs == Long.MAX_VALUE) break + if (pageMinTs == Long.MAX_VALUE) { + end = PagedFetchResult.End.UNPAGEABLE + break + } // Advance inclusively to the oldest second seen, carrying its dedup set: // still the same boundary → accumulate; a genuinely older one → replace. @@ -369,11 +440,7 @@ suspend fun INostrClient.fetchAllPages( until = nextUntil } - // After the loop, not at the break: every other exit above leaves `drained` - // false, and firing from one place keeps "at most once" true by construction. - if (drained) onDrained?.invoke() - - return totalEvents + return PagedFetchResult(totalEvents, end) } suspend fun INostrClient.fetchAllPages( @@ -381,14 +448,12 @@ suspend fun INostrClient.fetchAllPages( filters: List, idleTimeoutMs: Long = 30_000L, onNewPage: ((Long) -> Unit)? = null, - onDrained: (() -> Unit)? = null, onEvent: suspend (Event) -> Unit, -): Int = +): PagedFetchResult = fetchAllPages( relay = RelayUrlNormalizer.normalize(relay), filters = filters, idleTimeoutMs = idleTimeoutMs, onNewPage = onNewPage, - onDrained = onDrained, onEvent = onEvent, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt index 7de482b0a4..e59fcc34ed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesPoolExt.kt @@ -79,14 +79,14 @@ suspend fun INostrClient.fetchAllPagesFromPool( launch { try { onRelayStart?.invoke(relay) - val total = + val result = fetchAllPages( relay = relay, filters = filtersForRelay, idleTimeoutMs = idleTimeoutMs, onNewPage = onNewPage?.let { cb -> { until -> cb(until, relay) } }, ) { event -> onEvent(event, relay) } - onRelayComplete?.invoke(relay, total) + onRelayComplete?.invoke(relay, result.downloaded) } finally { semaphore.release() } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt index e8797a8d0f..f2de98b505 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.relay import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PagedFetchResult import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -37,14 +38,13 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * Pins `onDrained` — the one signal that tells a caller the relay served - * *everything* below where the walk stopped, rather than merely stopping there. + * Pins [PagedFetchResult.End] — WHY a walk stopped, which is the half of the + * answer `downloaded` cannot carry. * - * The distinction is invisible in the `Int` return and matters enormously to - * anything recording sync coverage: without it, "the relay has nothing older" - * and "the relay capped us / went quiet / hung up" look identical, so a coverage - * band can never close its oldest leg and re-asks a range that will always come - * back empty, every cycle, forever. + * It matters enormously to anything recording sync coverage: without it, "the + * relay has nothing older" and "the relay capped us / went quiet / hung up" look + * identical, so a coverage band can never close its oldest leg and re-asks a + * range that will always come back empty, every cycle, forever. * * Real-clock ([runBlocking]) for the same reason the idle-timeout suite is: the * page watchdog is a monotonic clock bumped from the socket reader thread. @@ -103,18 +103,17 @@ class NostrClientFetchAllPagesDrainTest { client.listener!!.onEose(relay, null) } - var drained = false - val total = + val result = client.fetchAllPages( relay = relay, filters = listOf(Filter(kinds = listOf(1))), idleTimeoutMs = 2_000, - onDrained = { drained = true }, ) { } feeder.join() - assertEquals(2, total) - assertTrue(drained, "an empty page the relay EOSEd is proof there is nothing older") + assertEquals(2, result.downloaded) + assertEquals(PagedFetchResult.End.DRAINED, result.end, "an empty page the relay EOSEd is proof there is nothing older") + assertTrue(result.drained) } @Test @@ -133,18 +132,17 @@ class NostrClientFetchAllPagesDrainTest { client.awaitPage(2) } - var drained = false - val total = + val result = client.fetchAllPages( relay = relay, filters = listOf(Filter(kinds = listOf(1))), idleTimeoutMs = 200, - onDrained = { drained = true }, ) { } feeder.join() - assertEquals(2, total, "the events already delivered are still kept") - assertFalse(drained, "an idle timeout says nothing about what the relay holds") + assertEquals(2, result.downloaded, "the events already delivered are still kept") + assertEquals(PagedFetchResult.End.IDLE, result.end) + assertFalse(result.drained, "an idle timeout says nothing about what the relay holds") } @Test @@ -164,16 +162,16 @@ class NostrClientFetchAllPagesDrainTest { client.listener!!.onClosed("auth-required: we don't serve that", relay, null) } - var drained = false - client.fetchAllPages( - relay = relay, - filters = listOf(Filter(kinds = listOf(1))), - idleTimeoutMs = 2_000, - onDrained = { drained = true }, - ) { } + val result = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + ) { } feeder.join() - assertFalse(drained, "a CLOSED is the relay declining, not an empty corpus") + assertEquals(PagedFetchResult.End.CLOSED, result.end, "a CLOSED is the relay declining, not an empty corpus") + assertFalse(result.drained) } @Test @@ -186,16 +184,16 @@ class NostrClientFetchAllPagesDrainTest { client.listener!!.onCannotConnect(relay, "connection refused", null) } - var drained = false - client.fetchAllPages( - relay = relay, - filters = listOf(Filter(kinds = listOf(1))), - idleTimeoutMs = 2_000, - onDrained = { drained = true }, - ) { } + val result = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + ) { } feeder.join() - assertFalse(drained, "never got to ask") + assertEquals(PagedFetchResult.End.CANNOT_CONNECT, result.end, "never got to ask") + assertFalse(result.drained) } @Test @@ -213,18 +211,17 @@ class NostrClientFetchAllPagesDrainTest { client.listener!!.onEose(relay, null) } - var drained = false - val total = + val result = client.fetchAllPages( relay = relay, filters = listOf(Filter(kinds = listOf(1), limit = 2)), idleTimeoutMs = 2_000, - onDrained = { drained = true }, ) { } feeder.join() - assertEquals(2, total) + assertEquals(2, result.downloaded) assertEquals(1, client.subscribeCount, "the limit was met, so there was no second page") - assertFalse(drained, "a fulfilled limit is the caller stopping, not the corpus ending") + assertEquals(PagedFetchResult.End.LIMIT_REACHED, result.end, "a fulfilled limit is the caller stopping, not the corpus ending") + assertFalse(result.drained) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt index fb9855a7a6..534f995cdb 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesIdleTimeoutTest.kt @@ -111,7 +111,7 @@ class NostrClientFetchAllPagesIdleTimeoutTest { ) { got.add(it) } feeder.join() - assertEquals(6, total, "a slowly-but-actively streaming page must never be cropped") + assertEquals(6, total.downloaded, "a slowly-but-actively streaming page must never be cropped") assertEquals(6, got.size) assertEquals(1, client.subscribeCount, "the whole stream must arrive in ONE page — a hard deadline would truncate and re-subscribe") assertEquals(0, pages, "no pagination should be needed") @@ -145,7 +145,7 @@ class NostrClientFetchAllPagesIdleTimeoutTest { feeder.join() val elapsedMs = start.elapsedNow().inWholeMilliseconds - assertEquals(2, total, "events delivered before the stall are kept") + assertEquals(2, total.downloaded, "events delivered before the stall are kept") assertTrue(elapsedMs >= 300, "must wait out at least one idle window, took ${elapsedMs}ms") assertTrue(elapsedMs < 5_000, "a stalled page must end promptly after the idle window, took ${elapsedMs}ms") } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt index 844ff29282..96971d4e0c 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt @@ -67,7 +67,7 @@ class NostrClientReqBypassingRelayLimitsTest : RelayClientTest() { events.add(event) } - assertEquals(1000, totalFound) + assertEquals(1000, totalFound.downloaded) assertEquals(1000, events.size) events.forEach { event -> assertEquals(MetadataEvent.KIND, event.kind) @@ -115,7 +115,7 @@ class NostrClientReqBypassingRelayLimitsTest : RelayClientTest() { } } - assertEquals(2500, totalFound) + assertEquals(2500, totalFound.downloaded) assertEquals(1000, metadataEvents.size) assertEquals(1500, contactListEvents.size) } @@ -165,7 +165,7 @@ class NostrClientReqBypassingRelayLimitsTest : RelayClientTest() { filters = listOf(Filter(search = "kotlin")), onNewPage = { searchPages++ }, ) { searchEvents.add(it) } - assertEquals(2, searchTotal, "a search filter must be fetched as a single page (the relay's cap)") + assertEquals(2, searchTotal.downloaded, "a search filter must be fetched as a single page (the relay's cap)") assertEquals(2, searchEvents.size) assertEquals(0, searchPages, "a search filter must never advance the until cursor") } finally { From 94b679fe7c32bdc76f4f62b1642cc42d2e878e60 Mon Sep 17 00:00:00 2001 From: sandwich <299465+dskvr@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:19:15 +0100 Subject: [PATCH 173/227] Address NIP-5D review comments --- .../amethyst/napplet/NappletBrokerService.kt | 2 +- .../amethyst/napplet/NappletIdentityWatch.kt | 22 +++--- .../amethyst/napplet/NappletLauncher.kt | 8 +-- .../gateways/NappletResourceFetcher.kt | 37 +++++++++- .../amethyst/napplet/NappletLauncherTest.kt | 68 +++++++++++++++++++ .../amethyst/commons/napplet/NappletBroker.kt | 7 +- .../commons/napplet/NappletRequestRouter.kt | 10 ++- .../napplet/protocol/NappletProtocolJson.kt | 3 + .../napplet/NappletRequestRouterTest.kt | 14 ++++ 9 files changed, 149 insertions(+), 22 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletLauncherTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 58453f479b..f49b497538 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -320,7 +320,7 @@ class NappletBrokerService : Service() { // NAP-IDENTITY has no watch/unwatch request. Once the consent-gated startup // snapshot succeeds, the runtime owns identity.changed delivery for this // trusted launch token until the broker service closes. - if (requestType == "identity.getPublicKey" && outcome.payload.contains("\"ok\":true") && launchToken != null) { + if (requestType == "identity.getPublicKey" && outcome.response is NappletResponse.PublicKey && launchToken != null) { identityWatch.start(launchToken, session.accountPubKey) { push(replyTo, it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt index bbb6a3aaa2..819c55b4a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** * Streams `identity.changed` pushes to an applet that registered `napplet.identity.onChanged`. It @@ -41,21 +42,24 @@ class NappletIdentityWatch( private val scope: CoroutineScope, private val pubKey: (boundPubKey: String) -> Flow, ) { - private val jobs = mutableMapOf() + private val jobs = ConcurrentHashMap() fun start( watchId: String, boundPubKey: String, push: (String) -> Unit, ) { - if (jobs.containsKey(watchId)) return - jobs[watchId] = - scope.launch { - pubKey(boundPubKey) - .distinctUntilChanged() - .drop(1) - .collect { push(NappletProtocolJson.encodeIdentityChanged(it)) } - } + jobs.computeIfAbsent(watchId) { id -> + scope + .launch { + pubKey(boundPubKey) + .distinctUntilChanged() + .drop(1) + .collect { push(NappletProtocolJson.encodeIdentityChanged(it)) } + }.also { job -> + job.invokeOnCompletion { jobs.remove(id, job) } + } + } } fun stopAll() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt index 29c0a2f091..c923b799d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -24,7 +24,6 @@ import android.content.Context import android.content.Intent import android.content.res.Configuration import android.os.Bundle -import android.util.Log import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity @@ -39,6 +38,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.utils.Log /** * Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only @@ -56,7 +56,7 @@ object NappletLauncher { ) { val event = manifest as? Event if (event?.verify() != true || event.pubKey != authorPubKey) { - Log.w(TAG, "Refusing NIP-5D manifest that failed signature/author verification") + Log.w(TAG) { "Refusing NIP-5D manifest that failed signature/author verification" } return } buildLaunchParams(context, manifest, authorPubKey, identifier)?.let { openHost(context, it) } @@ -80,7 +80,7 @@ object NappletLauncher { profile: HostProfile, ) { if (profile != HostProfile.WEBSITE) { - Log.w(TAG, "Refusing raw NIP-5D launch without a verified manifest") + Log.w(TAG) { "Refusing raw NIP-5D launch without a verified manifest" } return } val params = @@ -219,7 +219,7 @@ object NappletLauncher { ): Bundle? { val event = manifest as? Event if (event?.verify() != true || event.pubKey != authorPubKey) { - Log.w(TAG, "Refusing embedded NIP-5D manifest that failed signature/author verification") + Log.w(TAG) { "Refusing embedded NIP-5D manifest that failed signature/author verification" } return null } return runCatching { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index 9acdf7b690..24bf6e655c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -39,16 +39,21 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.Authenticator +import okhttp3.Call +import okhttp3.Callback import okhttp3.CookieJar import okhttp3.Dns import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response import java.io.ByteArrayOutputStream +import java.io.IOException import java.io.InterruptedIOException import java.net.InetAddress import java.net.URLDecoder @@ -127,7 +132,7 @@ class NappletResourceFetcher( ) }.build() - private fun fetchHttps( + private suspend fun fetchHttps( url: String, client: OkHttpClient, ): NappletResourceResult { @@ -141,7 +146,7 @@ class NappletResourceFetcher( .url(current) .get() .build(), - ).execute() + ).await() .use { response -> if (response.isRedirect) { if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.") @@ -227,7 +232,7 @@ class NappletResourceFetcher( * wrong server can never substitute the blob. Returns null for a malformed hash or if no server * serves it. */ - private fun fetchBlossom( + private suspend fun fetchBlossom( url: String, client: OkHttpClient, ): NappletResourceResult { @@ -257,6 +262,32 @@ class NappletResourceFetcher( return failure(ERROR_NOT_FOUND, "No Blossom server returned the verified blob.") } + private suspend fun Call.await(): Response = + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel() } + enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { + if (continuation.isActive) continuation.resumeWith(Result.failure(e)) + } + + override fun onResponse( + call: Call, + response: Response, + ) { + if (continuation.isActive) { + continuation.resumeWith(Result.success(response)) + } else { + response.close() + } + } + }, + ) + } + /** Parses a `data:[][;base64],` URL into bytes + content type. */ private fun decodeDataUrl(url: String): NappletResourceResult { val comma = url.indexOf(',') diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletLauncherTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletLauncherTest.kt new file mode 100644 index 0000000000..6145418d88 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletLauncherTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.napplethost.HostProfile +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertNull +import org.junit.Test + +class NappletLauncherTest { + private val context = mockk(relaxed = true) + private val manifest = mockk() + private val author = "aa".repeat(32) + + @Test + fun manifestLaunchRejectsNonEventManifest() { + NappletLauncher.launch(context, manifest, author, "demo") + + verify(exactly = 0) { context.startActivity(any()) } + } + + @Test + fun embeddedBuildLaunchParamsRejectsNonEventManifest() { + assertNull(NappletLauncher.buildLaunchParams(context, manifest, author, "demo")) + } + + @Test + fun rawLaunchRejectsNappletProfile() { + every { context.startActivity(any()) } returns Unit + + NappletLauncher.launch( + context = context, + paths = emptyList(), + servers = emptyList(), + authorPubKey = author, + identifier = "demo", + aggregateHash = null, + title = "Demo", + requires = emptyList(), + profile = HostProfile.NAPPLET, + ) + + verify(exactly = 0) { context.startActivity(any()) } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 0a9a923848..b7f9adf73d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope import com.vitorpamplona.amethyst.commons.napplet.protocol.toSignerOp import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -376,11 +377,11 @@ class NappletBroker( private fun storageCoordinate( identity: NappletIdentity, - scope: com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope, + scope: NappletStorageScope, ): String = when (scope) { - com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.SHARED -> identity.storageCoordinate - com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.INSTANCE -> identity.instanceStorageCoordinate + NappletStorageScope.SHARED -> identity.storageCoordinate + NappletStorageScope.INSTANCE -> identity.instanceStorageCoordinate } /** diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt index b1e7d9440f..fb27dd611e 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt @@ -42,6 +42,7 @@ object NappletRequestRouter { /** Send this `.result` payload back, correlated to the request's id. */ data class Reply( val payload: String, + val response: NappletResponse? = null, ) : Outcome /** Open a live relay subscription; the host streams `relay.event`/`relay.eose`/`relay.closed` by [subId]. */ @@ -114,7 +115,12 @@ object NappletRequestRouter { val request = runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull() - ?: return Outcome.Ignore + ?: return if (runCatching { NappletProtocolJson.readId(payload) }.getOrNull() != null) { + val failure = NappletResponse.Failed("Malformed or unsupported request.") + Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, failure), failure) + } else { + Outcome.Ignore + } val response = broker.handle(identity, request, declared) @@ -131,6 +137,6 @@ object NappletRequestRouter { } } - return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, response)) + return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, response), response) } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt index 1df933a9e7..7cfb2a95e0 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt @@ -61,6 +61,9 @@ object NappletProtocolJson { /** The `type` discriminant of a request envelope, used to build the matching `.result` type. */ fun readType(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("type") + /** The request `id`, when the envelope expects a correlated reply. */ + fun readId(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("id") + /** The `subId` of a subscription request, used to key the `relay.event`/`relay.eose` pushes back to it. */ fun readSubId(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("subId") diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt index eca9000437..6269b971ea 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt @@ -125,6 +125,20 @@ class NappletRequestRouterTest { assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"relay.publish"}""")) } + @Test + fun keyedUnknownAndMalformedRequestsReplyWithFailure() = + runTest { + val unknown = route("""{"type":"totally.unknown","id":"r1"}""") + assertIs(unknown) + assertTrue(unknown.payload.contains("totally.unknown.result")) + assertTrue(unknown.payload.contains("Malformed or unsupported request.")) + + val malformed = route("""{"type":"relay.publish","id":"r2"}""") + assertIs(malformed) + assertTrue(malformed.payload.contains("relay.publish.result")) + assertTrue(malformed.payload.contains("Malformed or unsupported request.")) + } + @Test fun queryRepliesWithItsResult() = runTest { From 395e821da2831420e2a2d5116971645ec03cce52 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:43:52 +0000 Subject: [PATCH 174/227] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-hi-rIN/strings.xml | 4 +++- amethyst/src/main/res/values-hu-rHU/strings.xml | 4 +++- amethyst/src/main/res/values-pl-rPL/strings.xml | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 6faae86163..c26533c1f8 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -3840,8 +3840,10 @@ चर्चाशाला सूचियाँ स्मृति में यन्त्र स्मृति वर्ग क्रमलेखकों के साथ बाँटें - यदि अमेथिस्ट द्वारा विद्युतकोष अथवा जानकारी यातायात का व्यय हो रहा है तो आप इस वृत्तान्त को क्रमलेखकों को भेज सकते हैं एक रहस्यीकृत सीधासन्देश में। इसमें केवल इस पटल पर दृश्यमान संख्याएँ हैं तथा इनके पीछे के तन्त्रविषयक संकलन \u2014 कोई पत्र सम्पर्क अथवा वीक्षण विवरण नहीं। कुछ भी नहीं भेजा जाएगा आपके द्वारा सन्देश पटल पर भेजें दबाने के पूर्व। + यदि अमेथिस्ट द्वारा विद्युतकोष अथवा जानकारी यातायात का व्यय हो रहा है तो आप इस वृत्तान्त को क्रमलेखकों को भेज सकते हैं एक रहस्यीकृत सीधासन्देश में। अथवा इसकी अनुकृति करके स्वयम बाँटें। इसमें केवल इस पटल पर दृश्यमान संख्याएँ हैं तथा इनके पीछे के तन्त्रविषयक संकलनसूचक \u2014 कोई पत्र अथवा सम्पर्क अथवा पुनःप्रसारक नाम अथवा जालभ्रमण विवरण नहीं। वृत्तान्त इस पटल से केवल तब जाएगा जब आप इसे भेजेंगे अथवा इसकी अनुकृति बाँटेंगे। सीधासन्देश द्वारा वृत्तान्त भेजें + अनुकृति + बाँटें उच्च संसाधन उपयोग का बोध अमेथिस्ट द्वारा अपेक्षित से अधिक उपभोग हुआ निकटकाल में : %1$s। क्या आप एक उपयोग वृत्तान्त भेजना चाहते क्रमलेखकों को एक रहस्यीकृत सीधासन्देश में। आप सम्पूर्ण वृत्तान्त देखेंगे कुछ भी भेजने के पूर्व। चलनशील जानकारी %1$s पृष्ठभूत एक दिन में diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index ec099bd65c..b586914a22 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -3841,8 +3841,10 @@ Csevegőszoba-listák a memóriában Eszköz memóriájának osztálya Megosztás a fejlesztőkkel - Ha az Amethyst túl sok akkumulátort vagy adatot használna, elküldheti ezt a jelentést a fejlesztőknek egy titkosított közvetlen üzenetben (DM). Kizárólag a képernyőn látható számokat és az azok mögött álló technikai számlálókat tartalmazza \u2014 bejegyzéseket, névjegyeket vagy böngészési adatokat nem. Semmi sem kerül elküldésre addig, amíg az üzenetküldő képernyőn rá nem koppint a Küldés gombra. + Ha úgy tűnik, hogy az Amethyst túlzottan meríti az akkumulátort vagy fogyasztja az adatkeretet, elküldheti ezt a jelentést a fejlesztőknek egy titkosított közvetlen üzenetben vagy lemásolhatja és megoszthatja saját maga. A jelentés kizárólag az ezen a képernyőn látható számokat és a mögöttük lévő technikai számlálókat tartalmazza – bejegyzéseket, névjegyeket, átjátszóneveket vagy böngészési adatokat nem. A jelentés csak akkor hagyja el ezt a képernyőt, ha Ön elküldi, lemásolja vagy megosztja azt. Jelentés elküldése közvetlen üzenetben + Másolás + Megosztás Magas erőforrás-használat észlelhető Az Amethyst a közelmúltban a vártnál többet fogyasztott: %1$s. Szeretne használati jelentést küldeni a fejlesztőknek egy titkosított közvetlen üzenetben? A küldés előtt megtekintheti a teljes jelentést. %1$s mobiladat-forgalom a háttérben egyetlen nap alatt diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 4d613a32e3..469d6f9e0f 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -3979,6 +3979,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Udostępnij deweloperom Jeśli zauważysz, że aplikacja Amethyst nadmiernie zużywa baterię lub transfer danych, możesz przesłać ten raport do twórców w zaszyfrowanej wiadomości prywatnej. Zawiera on wyłącznie liczby widoczne na tym ekranie oraz związane z nimi liczniki techniczne \u2014 nie zawiera żadnych postów, kontaktów ani szczegółów dotyczących przeglądania stron. Żadne dane nie zostaną wysłane, dopóki nie klikniesz przycisku „Wyślij” na ekranie wiadomości. Wyślij raport za pośrednictwem DM + Kopiuj + Udostępnij Wykryto wysokie użycie zasobów W ostatnim czasie Amethyst zużył więcej zasobów, niż oczekiwano: %1$s. Czy chcesz wysłać raport dotyczący zużycia do twórców w zaszyfrowanej wiadomości prywatnej? Przed wysłaniem zobaczysz pełną treść raportu. %1$s danych komórkowych w tle w ciągu jednego dnia From 3a53292993741375352ecd307025dae0672770a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 15:02:43 +0000 Subject: [PATCH 175/227] fix(concord): close the soft-ban holes reachable from the shipping app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part A of docs/concord-soft-ban-audit.md — the ones a banned user reaches by tapping a button, no custom tooling involved. A1. mintConcordInvite checked that the account was writeable and that we held the community, and nothing else, while its button was the one control on the screen with no gate at all. A member banned a minute ago could hand out a working link to the community they were removed from, and every account they invited arrived as a fresh un-banned npub. Now gated on CREATE_INVITE, both in the verb and on the button. Worth noting the bit was not enforced anywhere else: the fold gates the INVITE_* Control entities on it, but a link's bundle is a standalone kind-33301 published outside the Control Plane, so this check is the only one that exists. A3. Every moderation verb checked isWriteable() plus the Control write key — which is a spam gate, never authority (CORD-02 §5) — and left the real decision to whichever composable drew the button. Those gates then tested effectivePermissions, which ignores the banlist, so a banned staffer kept seeing the controls; the editions were dropped by everyone's fold, making them silently no-op, which this codebase elsewhere calls out as worse than absent. Ban and Remove survived only because a second, unrelated condition happened to route through the ban-aware canActOn. Authority now lives in the action layer behind isAuthorizedFor(), so a caller from desktop, amy or a future screen inherits it, and every authorization test uses hasPermission. refoundConcordCommunity's own guard was ban-blind outright and now rank-checks each removed member too. A2. The recovery sweep merges us onto any higher-epoch bundle found at our stored invite_ref, and an ex-member keeps that link's unlock token forever — so our own background timer walked a removed member back into the epoch a Refounding had rotated them out of. isStranded/mergeForward now take bannedAtCurrentEpoch as a required argument rather than leaving it to callers, because a caller that forgets it inverts the mechanism. The liveness half of that finding (nothing re-mints at a stable coordinate, so legitimate recovery never fires either) needs a spec answer and is untouched here. A4. Typing heartbeats are filtered on both ends, so a banned member stops announcing that they are typing messages nobody will see. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- .../amethyst/model/AccountConcordActions.kt | 110 ++++++++++++++++-- .../concord/ConcordChannelListScreen.kt | 49 +++++--- .../concord/ConcordMembersScreen.kt | 5 +- .../commons/actions/ConcordActions.kt | 3 +- .../model/concord/ConcordCommunitySession.kt | 3 + .../cord05Invites/ConcordStrandedRecovery.kt | 17 ++- .../ConcordStrandedRecoveryTest.kt | 26 +++-- 7 files changed, 173 insertions(+), 40 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index fba41f5e01..c403e08986 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -171,6 +171,16 @@ class AccountConcordActions( val entry = account.concordChannelList.liveCommunities.value .firstOrNull { it.id == communityId } ?: return null + // CREATE_INVITE, and not while banned. This used to check only that we held the community, + // which made minting the one moderation-free action in the app: a member the owner had just + // banned could tap the invite button and hand out a working link to the community they were + // removed from, and every account they invited arrived as a fresh un-banned npub. + // + // Note the bit is not otherwise enforced anywhere. The fold gates the INVITE_* Control + // entities on CREATE_INVITE, but a link's bundle is a standalone kind-33301 published + // OUTSIDE the Control Plane, so no fold ever sees it. This check is the only one there is. + val session = account.concordSessions.sessionFor(communityId) ?: return null + if (!isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE)) return null val invite = ConcordActions.inviteFor( communityIdHex = entry.id, @@ -430,7 +440,17 @@ class AccountConcordActions( channelIdHex: String, ) { if (!account.isWriteable()) return - val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return + val session = account.concordSessions.sessionFor(communityId) ?: return + // A ban hides every message we send, so continuing to announce that we are typing them is + // both noise and a contradiction of what the ban told the room. Filtered on the receive side + // too (ConcordCommunitySession.ingestTyping) — a malicious client would keep sending. + if (session.state.value + ?.authority + ?.isBanned(account.signer.pubKey) == true + ) { + return + } + val entry = session.entry val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now()) val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } @@ -487,6 +507,49 @@ class AccountConcordActions( return cp } + /** + * Whether this account may take the action guarded by [bit] in [session] — and, when [target] is + * given, take it *against that member* (CORD-04 §3's rank rule, "equal cannot act on equal"). + * + * Every moderation verb below funnels through this. It used to live only in the composables that + * drew the buttons, which failed three ways: the screens tested `effectivePermissions`, which + * ignores the banlist, so a banned staffer still saw the controls; a verb reached from anywhere + * else (desktop, `amy`, a new screen) inherited no check at all; and holding `control_root` — + * a spam gate, never authority (CORD-02 §5) — was the only thing actually being enforced. + * + * Fails **closed**, with one deliberate exception: the owner is read from [ConcordCommunityListEntry] + * rather than from the fold, because the community id proves them (CORD-02) and they must stay able + * to moderate before their Control Plane has finished folding — or through a fold a rogue has + * damaged. Everyone else needs a resolved roster, so an unfolded community grants nobody else + * anything. + */ + private fun isAuthorizedFor( + session: ConcordCommunitySession, + bit: Int, + target: HexKey? = null, + ): Boolean { + val me = account.signer.pubKey + if (session.entry.owner.equals(me, ignoreCase = true)) return true + val authority = session.state.value?.authority ?: return false + // hasPermission, never effectivePermissions: the latter reads the roles alone and would let a + // banned staffer keep acting for as long as they hold the key. + val allowed = if (target == null) authority.hasPermission(me, bit) else authority.canActOn(me, target, bit) + if (!allowed) { + Log.w("Concord") { "Refusing a Concord action in ${session.entry.id}: not authorized for bit $bit${target?.let { " on $it" } ?: ""} (CORD-04 §3)" } + } + return allowed + } + + /** [controlKeysForWrite] gated by [isAuthorizedFor] — the standing check and the key check together. */ + private fun controlKeysForAction( + session: ConcordCommunitySession, + bit: Int, + target: HexKey? = null, + ): ControlPlaneKeys? { + if (!isAuthorizedFor(session, bit, target)) return null + return controlKeysForWrite(session) + } + /** Grant [member] exactly [roleIds] (empty list revokes their roles). */ suspend fun grantConcordRole( communityId: String, @@ -495,7 +558,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false // A Grant that first makes its member staff must deliver the control_root in the same // edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the // roles carry a Control-writing bit and we hold the secret to hand over. @@ -567,7 +630,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false val existing = session.state.value @@ -609,7 +672,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, grantWrap) return true @@ -657,7 +720,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true @@ -670,7 +733,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false val wrap = ConcordModeration.unban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true @@ -701,10 +764,22 @@ class AccountConcordActions( val session = account.concordSessions.sessionFor(communityId) ?: return false val state = session.state.value ?: return false val authority = state.authority - val iCanBan = authority.isOwner(account.signer.pubKey) || authority.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.BAN) + // hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol + // and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the + // shipping app. Honest receivers refuse such a rotation (drainConcordRekeys checks the same + // ban-aware predicate), but that is a race against banlist propagation, not a check. + val iCanBan = authority.isOwner(account.signer.pubKey) || authority.hasPermission(account.signer.pubKey, ConcordPermissions.BAN) if (!iCanBan) return false val removedLower = removed.mapTo(HashSet()) { it.lowercase() } if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false + // Removal is the hardest form of a ban, so it takes the same rank rule (CORD-04 §3): an admin + // cannot Refound a peer admin out of the community any more than they could ban one. The owner + // short-circuits, as everywhere else, because canActOn starts at hasPermission. + if (!authority.isOwner(account.signer.pubKey) && + removedLower.any { !authority.canActOn(account.signer.pubKey, it, ConcordPermissions.BAN) } + ) { + return false + } // A Refounding writes the current plane (the pre-rotation bans) and the new one (the // compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A // rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery. @@ -986,7 +1061,18 @@ class AccountConcordActions( // Only a live bundle recovers: an expired/revoked link is not a rotation we missed. val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue - val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue + // A removed member holds the link's unlock token forever, so without this the sweep + // walks them straight back into the epoch they were rotated out of — see A2 in + // docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last + // one whose Control Plane we can still fold. + val bannedHere = + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value + ?.authority + ?.isBanned(account.signer.pubKey) == true + val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}") account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged)) @@ -1009,7 +1095,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays) val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) @@ -1027,7 +1113,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false val channelId = RandomInstance.bytes(32) val channel = ChannelEntity(name = name.trim()) val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) @@ -1043,7 +1129,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false // Carry the standing definition forward and change only the name. A ChannelEntity built from // scratch defaults `private` and `voice` to false, so renaming a private channel used to // publish an edition declaring it PUBLIC — and a voice channel became a text channel. @@ -1066,7 +1152,7 @@ class AccountConcordActions( ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false - val cp = controlKeysForWrite(session) ?: return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false // Same as rename: preserve the standing flags so a tombstone does not also silently // reclassify the channel it retires. val standing = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index d8c94f5ace..d33dfee5e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -170,10 +170,14 @@ fun ConcordChannelListScreen( // Rank alone isn't enough on a split epoch: publishing any Control edition also takes the // control_root (CORD-02 §2), which a freshly promoted staffer may not hold yet (CORD-04 §3), // so the affordance waits for the key too. + // hasPermission, never effectivePermissions: the latter reads the roles alone, so a banned + // moderator kept seeing every control here. The editions they authored were dropped by everyone's + // fold, which made these buttons silently no-op — worse than absent, and the same trap this file + // already avoids for the Roles… menu. val canManageChannels = state?.authority?.let { it.isOwner(account.signer.pubKey) || - it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS) + it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_CHANNELS) } == true && session?.controlPlaneKeys()?.canWrite == true @@ -242,10 +246,19 @@ fun ConcordChannelListScreen( val canEdit = state?.authority?.let { it.isOwner(account.signer.pubKey) || - it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA) + it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_METADATA) } == true && session?.controlPlaneKeys()?.canWrite == true + // Minting an invite hands out a working key to the community, so it takes + // CREATE_INVITE like any other privileged action. This button used to be the one + // control on the screen with no gate at all. + val canInvite = + state?.authority?.let { + it.isOwner(account.signer.pubKey) || + it.hasPermission(account.signer.pubKey, ConcordPermissions.CREATE_INVITE) + } == true + IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) { SymbolIcon(symbol = MaterialSymbols.Group, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_members_title)) } @@ -254,22 +267,24 @@ fun ConcordChannelListScreen( SymbolIcon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_edit_title)) } } - IconButton( - enabled = !minting, - onClick = { - minting = true - scope.launch { - try { - inviteLink = account.concord.mintConcordInvite(communityId) - } finally { - // Always clear the flag — a thrown mint would otherwise leave the - // button disabled until the screen is recreated. - minting = false + if (canInvite) { + IconButton( + enabled = !minting, + onClick = { + minting = true + scope.launch { + try { + inviteLink = account.concord.mintConcordInvite(communityId) + } finally { + // Always clear the flag — a thrown mint would otherwise leave the + // button disabled until the screen is recreated. + minting = false + } } - } - }, - ) { - SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action)) + }, + ) { + SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action)) + } } // Overflow, mirroring the NIP-29 relay-group top bar: destructive membership diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index 57b146653b..a9e166c46c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -133,7 +133,10 @@ fun ConcordMembersScreen( } val iAmOwner = state?.authority?.isOwner(myPubKey) == true - val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true + // hasPermission, never effectivePermissions: a banned BAN-holder used to keep the whole Ban / + // Remove menu. It only stayed harmless because `canBanTarget` below routes through canActOn, + // which IS ban-aware — a thin margin for the escalation in docs/concord-soft-ban-audit.md. + val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.hasPermission(myPubKey, ConcordPermissions.BAN) } == true val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true // The roles this viewer may actually hand out. The fold drops a grant whose granter does diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index d99679263f..dff1143c45 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -454,7 +454,8 @@ object ConcordActions { fun recoverStranded( entry: ConcordCommunityListEntry, bundle: CommunityInvite, - ): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle) + bannedAtCurrentEpoch: Boolean, + ): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle, bannedAtCurrentEpoch) /** Decrypts + validates a fetched bundle event with the link token; null if invalid. */ fun openBundle( 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 7670ff69c6..58f6156b89 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 @@ -486,6 +486,9 @@ class ConcordCommunitySession( if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, epoch)) return val who = rumor.pubKey.lowercase() if (who == myPubKey.lowercase()) return // never show my own typing back to me + // A banned member's messages are dropped everywhere, so their typing heartbeat must be too — + // otherwise they sit in the "… is typing" row forever in a channel they cannot be heard in. + if (_state.value?.authority?.isBanned(who) == true) return val now = TimeUtils.now() // Update the map and publish inside the lock so a concurrent heartbeat on another // channel can't publish an older snapshot last and drop this channel's typers. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt index 4f6e02d447..9869cadb91 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt @@ -46,12 +46,24 @@ object ConcordStrandedRecovery { * True when [bundle], resolved at [entry]'s stored invite link, proves we were * left behind: it must describe the same community and sit at a strictly higher * epoch. Same or lower is a no-op (we are current, or the bundle is stale). + * + * [bannedAtCurrentEpoch] is the caller's answer to "does the community, as I fold + * it right now, have me on its banlist?" — and a `true` refuses the recovery + * outright. It is a required argument rather than a caller-side `if` because + * getting it wrong turns this mechanism inside out: recovery exists so a member + * *wrongly* omitted from a rotation can catch up, but the test it performs (a + * higher epoch at a link whose unlock token an ex-member keeps forever) cannot + * tell that member apart from one the community deliberately removed. Without + * this, a Refounding — the only hard removal Concord has — is undone by our own + * background sweep a few minutes later. */ fun isStranded( entry: ConcordCommunityListEntry, bundle: CommunityInvite, + bannedAtCurrentEpoch: Boolean, ): Boolean = - entry.inviteRef != null && + !bannedAtCurrentEpoch && + entry.inviteRef != null && bundle.communityId.equals(entry.id, ignoreCase = true) && bundle.rootEpoch > entry.rootEpoch @@ -73,8 +85,9 @@ object ConcordStrandedRecovery { fun mergeForward( entry: ConcordCommunityListEntry, bundle: CommunityInvite, + bannedAtCurrentEpoch: Boolean, ): ConcordCommunityListEntry? { - if (!isStranded(entry, bundle)) return null + if (!isStranded(entry, bundle, bannedAtCurrentEpoch)) return null // Bank the epoch we are leaving with its control_pk, so its Control Plane // stays re-subscribable for the anti-rollback floor (a split epoch's address diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt index 6c0f9aa59c..96c1124e57 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt @@ -82,7 +82,7 @@ class ConcordStrandedRecoveryTest { val prior = HeldRoot(0L, "aa".repeat(32)) val stranded = entry(epoch = 1, heldRoots = listOf(prior)) - val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5)) + val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false) assertNotNull(merged, "a higher-epoch bundle at our own invite link means we were left behind") // adopted the new epoch's access root @@ -106,27 +106,27 @@ class ConcordStrandedRecoveryTest { @Test fun sameEpochBundleIsANoOp() { - assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5))) - assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5))) + assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false)) + assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false)) } @Test fun lowerEpochBundleIsANoOp() { // Epoch-monotonic: a stale bundle must never walk the membership backwards. - assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3))) + assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3), bannedAtCurrentEpoch = false)) } @Test fun entryWithoutInviteRefIsInert() { // Direct invites and legacy entries have no anchor — expected, not an error. val noAnchor = entry(epoch = 1, ref = null) - assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9))) - assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9))) + assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false)) + assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false)) } @Test fun bundleForAnotherCommunityIsIgnored() { - assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)))) + assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)), bannedAtCurrentEpoch = false)) } // ---- the bare `#` anchor form ---------------------------- @@ -252,4 +252,16 @@ class ConcordStrandedRecoveryTest { assertEquals(4L, other.rootEpoch) assertEquals(inviteRef, other.inviteRef) } + + @Test + fun aBannedMemberDoesNotRecoverIntoTheEpochTheyWereRemovedFrom() { + // The removal case the higher-epoch test cannot tell apart on its own: an ex-member keeps the + // link's unlock token forever, so without the ban gate the recovery sweep merges them into the + // very epoch a Refounding rotated them out of. See A2 in docs/concord-soft-ban-audit.md. + val stranded = entry(epoch = 1) + assertFalse(ConcordStrandedRecovery.isStranded(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true)) + assertNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true)) + // ...and the legitimate case still works, so the gate is not just "recovery off". + assertNotNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false)) + } } From 54c412da7aace3e93e6c82cffb85e634b5fd7638 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 15:12:41 +0000 Subject: [PATCH 176/227] fix(quartz): stop a stray edition from pinning a Control entity forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1 in docs/concord-soft-ban-audit.md, the worst item on the list: one edition at version = Long.MAX_VALUE permanently pinned its entity to the author's content, for every client holding a floor for it, with no way back. The floor rose to MAX_VALUE, no honest edition could exceed it, and a Refounding that dropped the poison fell back to EntityFloor.known — the poison. Authored in the tests by a current, legitimately granted moderator: no ban, no sockpuppet, one ordinary permission bit. The chain walk was never the weakness; it advances only to head.version + 1 citing the head's hash, so a fresh joiner was untouched. The compaction arm was: it trades contiguity for cross-epoch tolerance, which left VERSION as the only contest an edition had to win. Two changes. The arm now tries the floor-anchored chain first and falls back to the raw-version bootstrap only when nothing connects, so a stray never wins a fold where the honest chain is present. And the bootstrap will not follow a jump of more than MAX_COMPACTION_VERSION_JUMP above the floor — a compacted head is legitimately ahead by a chain's worth, not by 2^63 — so the version space cannot be exhausted in a step. A new test pins the tolerance the arm exists for, so the bound cannot later be tightened into breaking CORD-06 §3. compactControlPlane picked its per-entity head by raw highest version too, which made an honest rotator the delivery mechanism: a disconnected stray never joins the chain but won that comparison, and was re-wrapped into the new epoch as the entity's whole history, where fresh joiners anchor on it. It now picks the chain head, keeping foldEntity's fresh-joiner fallback for the dangling `prev` a prior compaction leaves behind. The three reproductions now assert the fixed behaviour. The banlist's escape hatch (a floor-less chain walk plus the re-heal union) is kept and still pinned. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- .../quartz/concord/cord04Roles/EditionFold.kt | 66 ++++++++- .../concord/cord06Rekey/ConcordRefounding.kt | 26 +++- .../ControlPlaneVersionExhaustionTest.kt | 132 +++++++++++++----- 3 files changed, 176 insertions(+), 48 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt index 4e5b8fa72c..956a0ec461 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt @@ -150,9 +150,63 @@ object EditionFold { floorVersion: Long, ): ControlEdition? = editions - .filter { it.version >= floorVersion } + .filter { it.version >= floorVersion && it.version - floorVersion <= MAX_COMPACTION_VERSION_JUMP } .minWithOrNull(compareByDescending { it.version }.thenBy { it.rumorId }) + /** + * How far above the floor the compaction arm will follow an edition in one step. + * + * The arm trades contiguity for cross-epoch tolerance, which made VERSION the only contest an + * edition had to win — so a single authorized edition at `version = Long.MAX_VALUE` used to + * become an entity's permanent head: it won the arm, `authorizedHeads` raised the floor to + * `Long.MAX_VALUE`, and from there no honest edition could ever exceed the floor again. Even a + * Refounding that dropped the poison did not help, because nothing was then offered at or above + * the floor and the fold fell back to [EntityFloor.known] — the poison itself. See B1 in + * `docs/concord-soft-ban-audit.md`. + * + * A compacted head is legitimately ahead of the floor by however many editions the entity gained + * while we were away — a chain's worth, not 2^63. This bound is deliberately far above any real + * community (a channel renamed a thousand times a day for three years stays under it) and far + * below the point where the version space can be exhausted. Anything beyond it is not a + * compaction we missed; it is someone reaching for the ceiling, and it is treated as a gap. + */ + const val MAX_COMPACTION_VERSION_JUMP = 1_000_000L + + /** + * The floor-anchored chain head among [editions], or null when nothing connects to [floor]. + * + * The same anchor-then-walk the main path uses, factored out so the compaction arm can try it + * first: the anchor is the floor's own edition (same version AND hash) or its immediate + * successor citing that hash, then the walk climbs while each `version + 1` cites the current + * head. Reports no gap — a null here means "fall back", not "refuse". + */ + private fun chainHead( + editions: List, + floor: EntityFloor, + ): ControlEdition? { + val byVersion = HashMap>() + for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e) + + val lowest = byVersion.keys.filter { it >= floor.version }.minOrNull() ?: return null + val winner = byVersion[lowest]?.minByOrNull { it.rumorId } ?: return null + var head = + when (lowest) { + floor.version -> winner.takeIf { it.hashHex == floor.hashHex } + floor.version + 1 -> winner.takeIf { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex } + else -> null + } ?: return null + + while (true) { + val next = + byVersion[head.version + 1] + ?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex } + ?.minByOrNull { it.rumorId } + ?: break + head = next + } + return head + } + /** * Groups mixed [editions] by entity id and folds each to its head, honoring the * per-entity anti-rollback [floors] (keyed by [ControlEdition.entityIdHex]). @@ -210,10 +264,16 @@ object EditionFold { // to the compacted head. Presence of the entity in the snapshot selects the ARM; // version selects the HEAD, over every edition we hold and not just the subset. if (floor != null && snapshot != null && editions.any { it.rumorId in snapshot }) { + // Chain first, bootstrap only as the fallback. The arm exists for the case where the + // offered head genuinely cannot be connected — but when it CAN be, the connected head is + // strictly better evidence than "highest number wins", and preferring it denies a stray + // high-version edition its free win in every ordinary fold. The bootstrap keeps the + // cross-epoch case working, now bounded by MAX_COMPACTION_VERSION_JUMP. + chainHead(editions, floor)?.let { return it } return bootstrapHead(editions, floor.version) ?: run { - // Nothing at or above the floor was served: the head we already accepted - // vanished from the offered set — withheld, so fail closed. + // Nothing admissible at or above the floor was served: the head we already + // accepted vanished from the offered set — withheld, so fail closed. onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version }) floor.known } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt index 18df7e3c46..7dc80de9cb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.concord.cord06Rekey import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey @@ -167,18 +168,29 @@ object ConcordRefounding { priorControlKeys: ControlPlaneKeys, newControlKeys: ControlPlaneKeys, ): List { - // entity coordinate -> (head edition, its verified seal) - val heads = HashMap>() + // entity coordinate -> every edition we can open, paired with its verified seal. + val byCoordinate = HashMap>>() for (wrap in priorWraps) { val opened = ConcordStreamEnvelope.openOrNull(wrap, priorControlKeys) ?: continue val edition = ControlEdition.fromRumor(opened.rumor) ?: continue val coord = edition.entityKind.wire + ":" + edition.entityIdHex - val current = heads[coord] - if (current == null || edition.version > current.first.version) { - heads[coord] = edition to opened.seal - } + byCoordinate.getOrPut(coord) { ArrayList() }.add(edition to opened.seal) } - return heads.values.map { (_, seal) -> ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt) } + + // The head is the CHAIN head, not the highest version. Picking by raw version made an honest + // rotator the delivery mechanism for a disconnected stray: an edition minted at an arbitrary + // version never joins the chain, but it won this comparison and was then re-wrapped into the + // new epoch as that entity's whole history — where a fresh joiner, holding no floor, anchors + // on it as their baseline. See B1 in `docs/concord-soft-ban-audit.md`. foldEntity walks from + // genesis and keeps the fresh-joiner fallback for a head whose own `prev` dangles into an + // epoch this rotator no longer holds, which is the ordinary shape after a prior compaction. + val out = ArrayList(byCoordinate.size) + for ((_, entries) in byCoordinate) { + val head = EditionFold.foldEntity(entries.map { it.first }) ?: continue + val seal = entries.firstOrNull { it.first.rumorId == head.rumorId }?.second ?: continue + out.add(ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt)) + } + return out } /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt index bbba1dc8d3..c97ed0dba9 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt @@ -27,35 +27,43 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * **V1 in `docs/concord-soft-ban-audit.md` — reproduction.** A single Control Plane edition at - * `version = Long.MAX_VALUE` pins its entity to the author's content permanently, for every client - * that holds a floor for it. + * **B1 in `docs/concord-soft-ban-audit.md` — regression guard.** A single Control Plane edition at + * `version = Long.MAX_VALUE` used to pin its entity to the author's content permanently, for every + * client that held a floor for it. These tests failed before the fix and pass after it. * - * The chain walk is not the weakness — it advances only to `head.version + 1` citing the head's - * hash, so an inflated version is unreachable and a fresh joiner is unaffected. The weakness is the - * **compaction arm** of [EditionFold.foldEntity]: once a client holds a floor for an entity and that - * entity appears in the epoch snapshot (which `ConcordCommunityState.fold` always builds from the - * editions handed to it), the head is chosen by [EditionFold.bootstrapHead] — *highest version at or - * above the floor*, with no `prev`, no hash, and no contiguity. Version is then the whole contest, - * and `Long.MAX_VALUE` wins it forever: + * The chain walk was never the weakness — it advances only to `head.version + 1` citing the head's + * hash, so an inflated version is unreachable and a fresh joiner was unaffected. The weakness was the + * **compaction arm** of `EditionFold.foldEntity`: once a client held a floor for an entity and that + * entity appeared in the epoch snapshot (which `ConcordCommunityState.fold` always builds from the + * editions handed to it), the head came from the raw-version bootstrap — *highest version at or above + * the floor*, with no `prev`, no hash, and no contiguity. Version was then the whole contest, and + * `Long.MAX_VALUE` won it forever: * - * 1. the poison becomes the head, so the entity shows the attacker's content; - * 2. `authorizedHeads` raises the entity's floor to `Long.MAX_VALUE`; - * 3. no honest edition can ever exceed that floor, so the entity can never be repaired; - * 4. a Refounding that drops the poison does not help either — nothing is offered at or above the - * floor, so the fold reports a gap and falls back to [EntityFloor.known], which *is* the poison. + * 1. the poison became the head, so the entity showed the attacker's content; + * 2. `authorizedHeads` raised the entity's floor to `Long.MAX_VALUE`; + * 3. no honest edition could ever exceed that floor, so the entity could never be repaired; + * 4. a Refounding that dropped the poison did not help either — nothing was then offered at or above + * the floor, so the fold reported a gap and fell back to `EntityFloor.known`, which *was* the poison. + * + * Two changes close it, and both are pinned below. The arm now tries the floor-anchored **chain** + * first and only falls back to the raw-version bootstrap when nothing connects, so a stray never wins + * a fold where the honest chain is present; and the bootstrap will not follow a jump larger than + * [EditionFold.MAX_COMPACTION_VERSION_JUMP], so the version space cannot be exhausted in one step. + * [aGenuineCompactionJumpIsStillFollowed] pins the tolerance the arm exists for, so the bound cannot + * be tightened into breaking CORD-06 §3. * * Note who the attacker is. Every test here is authored by **bob, a current and legitimately granted * moderator** — not a banned member, not a sockpuppet. Any holder of the entity's permission bit can - * do this at any time, and demoting or banning them afterwards changes nothing, because the damage - * is already in every client's floor. It is also carried into every future epoch by - * `ConcordRefounding.compactControlPlane`, which selects the head per entity by raw highest version. + * could do this at any time, and demoting or banning them afterwards changed nothing, because the + * damage was already in every client's floor. `ConcordRefounding.compactControlPlane` also selected + * the head per entity by raw highest version, which made an honest rotator the delivery mechanism — + * it now picks the chain head instead. * - * The banlist is the one entity that survives, and by accident: `AuthorityResolver` folds it with + * The banlist was the one entity that survived, and by accident: `AuthorityResolver` folds it with * its own floor-less chain walk and then re-heals the union across authorized editions, so an - * honest ban lands even when the head is poisoned. [aPoisonedBanlistStillAcceptsTheOwnersBan] pins - * that, because it is the only thing standing between this bug and a permanently unmoderatable - * community. + * honest ban landed even when the head was poisoned. [aPoisonedBanlistStillAcceptsTheOwnersBan] + * keeps pinning that, because it was the only thing standing between this bug and a permanently + * unmoderatable community. */ class ControlPlaneVersionExhaustionTest { private val owner = "0f".repeat(32) @@ -86,7 +94,7 @@ class ControlPlaneVersionExhaustionTest { ) + rest @Test - fun oneEditionAtMaxVersionPinsTheMetadataForever() { + fun oneEditionAtMaxVersionNoLongerPinsTheMetadata() { val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0") val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0) @@ -96,7 +104,7 @@ class ControlPlaneVersionExhaustionTest { val poison = edition(ControlEntityKind.METADATA, metadataEntity, Long.MAX_VALUE, metadataV0.hash, """{"name":"PWNED"}""", bob, "meta-poison") val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore) - assertEquals(Long.MAX_VALUE, floorsAfter[metadataEntity]?.version, "VULNERABLE: the floor is now at the top of the version space") + assertEquals(0, floorsAfter[metadataEntity]?.version, "the floor must not follow a stray to the top of the version space") // The owner tries to repair it, chaining honestly onto their own genesis. val repair = edition(ControlEntityKind.METADATA, metadataEntity, 1, metadataV0.hash, """{"name":"My Community"}""", owner, "meta-1") @@ -108,19 +116,19 @@ class ControlPlaneVersionExhaustionTest { "a fresh joiner walks the chain and is unaffected", ) assertEquals( - "PWNED", + "My Community", ConcordCommunityState.fold(pool, owner, floorsAfter).metadata?.name, - "VULNERABLE: every client holding a floor is pinned to the attacker's content", + "a client holding a floor follows the honest chain, not the stray", ) assertEquals( - "PWNED", + "My Community", ConcordCommunityState.fold(community + repair, owner, floorsAfter).metadata?.name, - "VULNERABLE: even a Refounding that drops the poison falls back to it as EntityFloor.known", + "and a Refounding that drops the poison stays repaired", ) } @Test - fun oneEditionAtMaxVersionDeletesAChannelForever() { + fun oneEditionAtMaxVersionNoLongerDeletesAChannel() { val channelV0 = edition(ControlEntityKind.CHANNEL, channelEntity, 0, null, """{"name":"general"}""", owner, "chan-0") val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_CHANNELS).toWire(), channelV0) @@ -132,28 +140,29 @@ class ControlPlaneVersionExhaustionTest { val pool = community + poison + repair assertEquals(1, ConcordCommunityState.fold(pool, owner).channels.size, "a fresh joiner still sees the channel") - assertEquals(0, ConcordCommunityState.fold(pool, owner, floorsAfter).channels.size, "VULNERABLE: the channel is gone and cannot be restored") + assertEquals(1, ConcordCommunityState.fold(pool, owner, floorsAfter).channels.size, "and so does a client holding a floor") assertEquals( - 0, + 1, ConcordCommunityState.fold(community + repair, owner, floorsAfter).channels.size, - "VULNERABLE: dropping the poison does not bring the channel back", + "the channel survives a Refounding too", ) } @Test fun aPoisonedBanlistStillAcceptsTheOwnersBan() { - // The saving grace, and the reason this bug is "unmoderatable community" rather than - // "community with a broken name". AuthorityResolver folds the banlist on its own floor-less - // chain walk and re-heals the union across every authorized edition, so the owner's ban lands - // even while the banlist's own floor sits at Long.MAX_VALUE. Do not "unify" the banlist onto - // the floored fold without replacing this protection. + // This was the saving grace before the fix — the reason the bug was "community with a broken + // name" rather than "community nobody can moderate". AuthorityResolver folds the banlist on + // its own floor-less chain walk and re-heals the union across every authorized edition, so + // the owner's ban landed even while the banlist's floor sat at Long.MAX_VALUE. The floor can + // no longer be poisoned, but keep this: do not "unify" the banlist onto the floored fold + // without replacing the protection. val banlistV0 = edition(ControlEntityKind.BANLIST, banlistEntity, 0, null, "[]", owner, "ban-0") val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.BAN).toWire(), banlistV0) val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner) val poison = edition(ControlEntityKind.BANLIST, banlistEntity, Long.MAX_VALUE, banlistV0.hash, "[]", bob, "ban-poison") val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore) - assertEquals(Long.MAX_VALUE, floorsAfter[banlistEntity]?.version, "the banlist floor is poisoned like any other") + assertEquals(0, floorsAfter[banlistEntity]?.version, "the banlist floor is no longer poisonable either") val ownerBansBob = edition(ControlEntityKind.BANLIST, banlistEntity, 1, banlistV0.hash, """["$bob"]""", owner, "ban-1") val pool = community + poison + ownerBansBob @@ -164,4 +173,51 @@ class ControlPlaneVersionExhaustionTest { "the re-heal union must keep the banlist working even with a poisoned floor", ) } + + @Test + fun aGenuineCompactionJumpIsStillFollowed() { + // The tolerance the compaction arm exists for, pinned so the bound above cannot be tightened + // into breaking CORD-06 §3. After a Refounding the compacted head carries the `prev` it had + // before compaction, citing an edition in the PRIOR epoch that this client no longer holds — + // so it connects to nothing, and its version is legitimately several ahead of our floor. + val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0") + val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0) + val floors = ConcordCommunityState.authorizedHeads(community, owner) + + val danglingPrev = ByteArray(32) { 0x7f } + val compacted = edition(ControlEntityKind.METADATA, metadataEntity, 4, danglingPrev, """{"name":"Renamed While We Were Away"}""", owner, "meta-compacted") + + assertEquals( + "Renamed While We Were Away", + ConcordCommunityState.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + compacted, owner, floors).metadata?.name, + "a compacted head whose prev dangles by design must still be adopted", + ) + } + + @Test + fun aJumpBeyondTheCapIsRefusedAsAGap() { + // Same shape as the genuine compaction above, one version past the bound: not a compaction we + // missed, so the fold reports a gap and keeps what it already had rather than following it. + val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0") + val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0) + val floors = ConcordCommunityState.authorizedHeads(community, owner) + + val danglingPrev = ByteArray(32) { 0x7f } + val tooFar = + edition( + ControlEntityKind.METADATA, + metadataEntity, + EditionFold.MAX_COMPACTION_VERSION_JUMP + 1, + danglingPrev, + """{"name":"PWNED"}""", + bob, + "meta-far", + ) + + assertEquals( + "My Community", + ConcordCommunityState.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + tooFar, owner, floors).metadata?.name, + "a jump past the bound is a gap, not a head", + ) + } } From 4e99aafb59f5f081f5fe6f13924abcc15ee38f8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 15:47:41 +0000 Subject: [PATCH 177/227] fix(quartz): honor the banlist against the Control Plane itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B2 in docs/concord-soft-ban-audit.md, plus B4's bound and the audit's status pass. hasPermission was ban-aware; the resolver's own ROLE/GRANT/BANLIST gates were not, and could not be as written — the roles/grants fixpoint settled before `banned` was computed at all. So half the Control Plane honored a ban and half was blind to it, and a banned staffer still holding control_root kept the whole roster: banning everyone beneath them, revoking the surviving moderators, retiring the roles under them, and minting a fresh un-banned npub that passed every ban-aware gate and finished the job. resolve() is now a bounded two-pass where authority only ever shrinks. Pass A resolves as before and yields a candidate banlist; pass B re-resolves with every author on it treated as holding no authority. Two passes always, so it terminates by construction, and mutual bans cannot oscillate because the rank rule makes them unreachable — only someone who strictly outranks you may ban you, and you cannot outrank them back. A chain-local rule would not have worked: forking the banlist at genesis means no parent ever mentions the ban and §4's re-heal union carries it in regardless, so the rule is a whole-pass mask rather than a per-edition check. This cascades, deliberately: every edition a banned member ever authored is dropped, grants included, so banning an admin also demotes everyone that admin promoted. That is the literal reading of CORD-04 §4 and it is what kills the sockpuppet, but a legitimate promotion by a later-banned admin vanishes with it and has to be re-issued. Both the cascade and its blast radius are pinned, and the trade-off is written up in the Armada report as the answer to its own open row 3 — which also widens the divergence recorded there: we now drop editions they honor wherever a privileged member was banned. B4: the Refounding recipient set is capped. allMembers() is the Guestbook ∪ observedAuthors ∪ the roster, and the first two are unbounded and attacker-writable, so each throwaway npub someone posts from became one more mandatory blob in the next Refounding — the attack inflating the cost of its own remedy. The owner-rooted roster is kept first and anything dropped is logged, never silently truncated, because a dropped member is stranded. The nine escalation reproductions now assert the fixed behaviour. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- .../amethyst/model/AccountConcordActions.kt | 48 ++++++- docs/concord-banlist-rank-conformance.md | 35 ++++- docs/concord-soft-ban-audit.md | 134 ++++++++++++------ .../concord/cord04Roles/AuthorityResolver.kt | 52 ++++++- .../cord04Roles/BannedStaffEscalationTest.kt | 111 ++++++++++----- 5 files changed, 299 insertions(+), 81 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index c403e08986..5b5c8fe0db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions @@ -76,6 +77,15 @@ private const val CONCORD_ADMIN_ROLE = "Admin" */ private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L +/** + * How many recipients one Refounding will re-key. See `AccountConcordActions.boundRecipients`. + * + * 120 blobs ride in each kind-3303 chunk, so this is ~42 published events and ~5k NIP-44 + * encryptions at the ceiling — heavy but survivable on a phone, and far above any real community. + * Raising it raises the cost of the attack it exists to bound, not the safety. + */ +private const val MAX_REFOUNDING_RECIPIENTS = 5_000 + /** * Concord (encrypted communities) orchestration for an [Account]: join/create/ * invite flows, channel messages/reactions/edits/typing, roles and moderation, @@ -810,7 +820,7 @@ class AccountConcordActions( .apply { removeAll(removedLower) removeAll(authority.bannedMembers()) - }.toList() + }.let { candidates -> boundRecipients(candidates, authority) } // 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs. val entry = session.entry @@ -853,6 +863,42 @@ class AccountConcordActions( return true } + /** + * Caps the Refounding recipient set, keeping the members whose standing we can actually vouch + * for when there are too many. + * + * `allMembers()` is the Guestbook ∪ `observedAuthors` ∪ the roster, and the first two are + * unbounded and attacker-writable: a Guestbook Join is self-signed by any key at all, and every + * author we decrypt is folded in by design (CORD-02 §5, "observably present"). So each throwaway + * npub someone posts from, or simply announces, becomes one more mandatory blob in the next + * Refounding — meaning the attack inflates the cost of its own remedy, and the remedy is the only + * hard removal Concord has. See B4 in `docs/concord-soft-ban-audit.md`. + * + * The roster and the owner are kept unconditionally: they are owner-rooted, so they cannot be + * padded from outside. The remainder fills the budget, and anything dropped is **logged rather + * than silently truncated** — a dropped member is stranded on the dead epoch and their only way + * back is a recovery path that needs to know it happened. + */ + private fun boundRecipients( + candidates: Set, + authority: AuthorityResolver, + ): List { + if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList() + + val vouched = authority.roleHolders() + authority.staffMembers() + val kept = LinkedHashSet(MAX_REFOUNDING_RECIPIENTS) + candidates.filterTo(kept) { it in vouched } + for (candidate in candidates) { + if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break + kept.add(candidate) + } + Log.w("Concord") { + "Refounding recipient set capped at $MAX_REFOUNDING_RECIPIENTS of ${candidates.size}: " + + "${candidates.size - kept.size} member(s) will be stranded on the prior epoch" + } + return kept.toList() + } + // Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered // in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not // adopted — and re-published — twice on successive revision ticks. diff --git a/docs/concord-banlist-rank-conformance.md b/docs/concord-banlist-rank-conformance.md index afbe98fdbd..04e92c9de7 100644 --- a/docs/concord-banlist-rank-conformance.md +++ b/docs/concord-banlist-rank-conformance.md @@ -1,7 +1,8 @@ # Concord: the Banlist is not rank-gated in any implementation (CORD-04 conformance) **Status:** conformance bug. Reproduced in Amethyst and **fixed there** (see §6); present by -inspection in Armada. +inspection in Armada. Finding #3, left open in §4 as a fixpoint-ordering question, is now also +implemented in Amethyst — see the 2026-08-09 update before §Rollout status. **Severity:** privilege escalation. Any `BAN` holder can neutralise every authority above them, including the owner. **Reported by:** Amethyst (MIT), 2026-07-20. Findings verified by unit test; see "Evidence" below. @@ -190,6 +191,38 @@ We'd also suggest **§4 restating the rank half inline**, the way §2 does for G does for Kicks. Both independent implementations read §4 in isolation and both got it wrong the same way; that is strong evidence the section is the problem, not the readers. +### Update, 2026-08-09: we have now implemented #3 + +Amethyst now answers the ordering question rather than leaving it open, because #3 turned out to be +the doorway to a full community takeover and not merely an inconsistency — a banned staffer who kept +`control_root` kept the entire roster, and could mint a fresh un-banned npub that passed every +ban-aware gate. The write-up is `docs/concord-soft-ban-audit.md` (B2). + +The rule we shipped: **authority only ever shrinks, over two passes.** Pass A resolves exactly as +before and yields a candidate banlist; pass B re-resolves with every author on that list treated as +holding no authority at all, for roles, grants and the Banlist alike. Two passes, always, so it +terminates by construction. It cannot oscillate on mutual bans either, because the rank rule makes +them unreachable: only a member who strictly outranks you may ban you, and you cannot outrank them +back. + +Note what it costs, because it is not obvious and you would hit it too: this **cascades**. Every +edition a banned member ever authored is dropped, grants included, so banning an admin also demotes +everyone that admin promoted. We think that is the literal reading of §4 and it is what kills the +sockpuppet — but a legitimate promotion by a later-banned admin vanishes with it, and the owner has +to re-issue it. If you read §4 as scoping only to editions authored *after* the ban, say so; that is +implementable too, but it needs the spec to define an ordering between an edition and a Banlist +entry, which today it does not. + +A chain-local rule is not enough, and this is the trap worth flagging: "the author must not be banned +by the state their edition chains from" is bypassed by forking the Banlist at genesis, where no +parent ever mentions the ban and §4's re-heal union carries it in anyway. The rule has to bind the +union, which is why ours is a whole-pass mask rather than a per-edition check. + +This widens the divergence in §6: we now drop editions you honor in any community where a privileged +member was banned, not only where a signer failed to outrank their target. + +--- + Separately, please rule on **#3**: whether a banned npub's Banlist edition is honored. Our reading of §4 ("drops every event from a banned npub — message, reaction, edit, or authority action") is that it must not be, but the fixpoint ordering needs to be stated for that to be implementable consistently. diff --git a/docs/concord-soft-ban-audit.md b/docs/concord-soft-ban-audit.md index 58429394ca..1da43ed4d0 100644 --- a/docs/concord-soft-ban-audit.md +++ b/docs/concord-soft-ban-audit.md @@ -1,7 +1,9 @@ # Concord: soft-ban and Control Plane audit **Scope:** what a removed member — or a moderator who turns — can still do to a Concord community. -**Date:** 2026-08-09. **Status:** findings only, nothing fixed yet. +**Date:** 2026-08-09. **Status:** A1–A4, B1, B2 and B4 are **fixed** on this branch; the rest are +accepted, deferred to the spec, or belong to other people's relays. Each section carries its own +status line. **Companion:** `docs/concord-banlist-rank-conformance.md` (the rank half of CORD-04 §4, already reported to Armada and fixed here). @@ -47,32 +49,32 @@ Two structural causes account for most of both halves: ### Part A — reachable from stock Amethyst (our bugs) -| # | Finding | Severity | Was | -|---|---------|----------|-----| -| [A1](#a1) | Any member — banned included — mints a working invite in one tap | **Critical** | new | -| [A2](#a2) | Stranded recovery runs on a timer and never checks the banlist | **Critical** | V10 | -| [A3](#a3) | The action layer has no permission checks; the UI's are ban-blind | High | new | -| [A4](#a4) | A banned member keeps broadcasting "typing", and we keep showing it | Low | V12 | -| [A5](#a5) | A banned member's own client keeps reading and rendering everything | Medium | V8 | +| # | Finding | Severity | Status | +|---|---------|----------|--------| +| [A1](#a1) | Any member — banned included — mints a working invite in one tap | **Critical** | **Fixed** | +| [A2](#a2) | Stranded recovery runs on a timer and never checks the banlist | **Critical** | **Fixed** (security half; liveness half open) | +| [A3](#a3) | The action layer has no permission checks; the UI's are ban-blind | High | **Fixed** | +| [A4](#a4) | A banned member keeps broadcasting "typing", and we keep showing it | Low | **Fixed** | +| [A5](#a5) | A banned member's own client keeps reading and rendering everything | Medium | Inherent — product decision | ### Part B — requires a malicious client -| # | Finding | Severity | Needs a ban? | Recoverable? | Was | -|---|---------|----------|--------------|--------------|-----| -| [B1](#b1) | One edition at `version = Long.MAX_VALUE` pins an entity forever | **Critical** | No — any bit-holder | **No** | V1 | -| [B2](#b2) | A banned staffer keeps Role/Grant/Banlist authority | **Critical** | Yes | Yes (Refounding) | V2 | -| [B3](#b3) | A rogue rotator compacts the banlist away | High | Via B2 | Partly | V3 | -| [B4](#b4) | The Refounding recipient set is attacker-inflatable | High | No | Yes | V4 | -| [B5](#b5) | The ban is per-pubkey; the channel key is not revoked | High | Yes | Yes (Refounding) | V5 | -| [B6](#b6) | Channel history is deletable on a naive third-party relay | High | Yes | **No** (history) | V6 | -| [B7](#b7) | The base-rekey plane is writable by every member | Low | Yes | Yes | V9 | +| # | Finding | Severity | Needs a ban? | Status | +|---|---------|----------|--------------|--------| +| [B1](#b1) | One edition at `version = Long.MAX_VALUE` pins an entity forever | **Critical** | No — any bit-holder | **Fixed** | +| [B2](#b2) | A banned staffer keeps Role/Grant/Banlist authority | **Critical** | Yes | **Fixed** (consensus-affecting) | +| [B3](#b3) | A rogue rotator compacts the banlist away | High | Via B2 | **Mitigated** by B2 | +| [B4](#b4) | The Refounding recipient set is attacker-inflatable | High | No | **Fixed** (bounded) | +| [B5](#b5) | The ban is per-pubkey; the channel key is not revoked | High | Yes | Inherent — Refounding is the answer | +| [B6](#b6) | Channel history is deletable on a naive third-party relay | High | Yes | Correct here; external relays at risk | +| [B7](#b7) | The base-rekey plane is writable by every member | Low | Yes | Accepted | ### Part C — interop and not-yet-shipped -| # | Finding | Severity | Was | -|---|---------|----------|-----| -| [C1](#c1) | Banlist rank rule diverges from Armada | Medium | V7 | -| [C2](#c2) | CORD-07 voice rooms are key-gated, not roster-gated | Design | V11 | +| # | Finding | Severity | Status | +|---|---------|----------|--------| +| [C1](#c1) | Banlist rank rule diverges from Armada | Medium | Reported; B2 widens the divergence | +| [C2](#c2) | CORD-07 voice rooms are key-gated, not roster-gated | Design | Note for whoever ships voice | --- @@ -82,6 +84,9 @@ No custom tooling. A banned user with the shipping app, or our own background sw ## A1 — Any member, banned included, mints a working invite in one tap +**Status: fixed.** `mintConcordInvite` and its button now require `CREATE_INVITE` (or ownership). + + **Critical. The single most likely thing an irritated banned user actually does.** *Read:* `AccountConcordActions.mintConcordInvite`, `ConcordChannelListScreen` (the `PersonAdd` `IconButton`). @@ -106,6 +111,12 @@ button on the same. This is contained, uncontroversial, and closes the realistic ## A2 — Stranded recovery runs on a timer and never checks the banlist +**Status: security half fixed; liveness half open.** `isStranded` / `mergeForward` now take +`bannedAtCurrentEpoch` as a *required* argument, so a removed member is no longer walked back in. +Whether anything should re-mint at a stable coordinate — without which legitimate recovery never +fires for anyone — still needs a spec answer and is untouched. + + **Critical, and it forks.** *Read:* `ConcordStrandedRecovery`, `AccountConcordActions.recoverStrandedConcordCommunities`, `AccountConcordActions.mintConcordInvite`. @@ -137,6 +148,10 @@ evicted owners another route. ## A3 — The action layer has no permission checks; the UI's are ban-blind +**Status: fixed.** Authority now lives in `AccountConcordActions.isAuthorizedFor`, which every +moderation verb funnels through, and every authorization test uses the ban-aware `hasPermission`. + + **High (defense in depth).** *Read:* `AccountConcordActions` (`banConcordMember`, `unbanConcordMember`, `editConcordMetadata`, `deleteConcordChannel`, `refoundConcordCommunity`), `ConcordMembersScreen`, `ConcordChannelListScreen`. @@ -169,6 +184,9 @@ their roles say", independent of standing. ## A4 — A banned member keeps broadcasting "typing", and we keep showing it +**Status: fixed on both ends.** + + **Low, both halves ours.** *Read:* `AccountConcordActions.sendConcordTyping`, `ConcordCommunitySession.ingestTyping`. @@ -180,6 +198,11 @@ and it directly contradicts what a ban promises the user. ## A5 — A banned member's own client keeps reading and rendering everything +**Status: inherent; no code change.** The cryptography cannot be fixed without a Refounding, so what +is left is a product decision about how "Ban" and "Remove from community" are presented. Left for a +design pass rather than guessed at here. + + **Medium, partly inherent.** *Read:* CORD-02/05, `ConcordCommunitySession`. Until a Refounding, a ban stops honest clients from *showing* the banned member's posts; it does not @@ -201,6 +224,12 @@ what they publish. ## B1 — One edition at `Long.MAX_VALUE` pins an entity forever +**Status: fixed.** The compaction arm tries the floor-anchored chain first and bounds the bootstrap +jump at `EditionFold.MAX_COMPACTION_VERSION_JUMP`; `compactControlPlane` picks the chain head rather +than raw max version. The three reproductions now assert the fixed behaviour, and +`aGenuineCompactionJumpIsStillFollowed` pins the CORD-06 §3 tolerance the bound must not break. + + **Critical. Does not require a banned user, a sockpuppet, or the owner's absence. Unrecoverable.** *Verified:* `quartz/…/cord04Roles/ControlPlaneVersionExhaustionTest.kt` (3 tests). @@ -246,6 +275,14 @@ The first is the smallest change and closes the unrecoverability; the third shou ## B2 — A banned staffer keeps Role, Grant and Banlist authority +**Status: fixed — and consensus-affecting.** `AuthorityResolver.resolve` is now a bounded two-pass +where authority only shrinks. Note the deliberate cascade it brings: every edition a banned member +ever authored is dropped, so banning an admin also demotes everyone that admin promoted. That is the +literal reading of CORD-04 §4 and it is what kills the sockpuppet, but a legitimate promotion by a +later-banned admin vanishes with it and has to be re-issued. Until Armada ships the same rule the two +clients can disagree about any community where a privileged member was banned. + + **Critical.** *Verified:* `quartz/…/cord04Roles/BannedStaffEscalationTest.kt` (13 tests). `hasPermission` is ban-aware; the resolver's internal gates are not, and structurally cannot be as @@ -277,6 +314,11 @@ Armada ships the same rule, we will drop editions they honor. ## B3 — A rogue rotator compacts the banlist away +**Status: mitigated by B2.** The rotator this needed was the sockpuppet, which can no longer be +minted. A *legitimately* privileged rotator can still omit the banlist, and `EntityFloor` remains the +only defense for clients that already folded it — unchanged, and still worth a spec fix. + + **High.** *Verified:* `aRogueRotatorCompactsTheBanAwayForEveryClientWithoutAFloor`. A CORD-06 §3 compaction re-wraps one edition per entity and the *rotator* picks it, so a rotator can @@ -292,6 +334,10 @@ protect people who were already there. ## B4 — The Refounding recipient set is attacker-inflatable +**Status: fixed (bounded).** The recipient set is capped, the owner-rooted roster is kept first, and +anything dropped is logged rather than silently truncated. + + **High.** *Read:* `ConcordCommunitySession.allMembers()` / `emitChannelRumors`; `AccountConcordActions.refoundConcordCommunity` step 2; `ConcordRefounding.buildBaseRekeyWraps`. @@ -311,6 +357,9 @@ the recipient set, prefer recent/attested members when over the cap, and surface ## B5 — The ban is a per-pubkey display rule and the channel key is not revoked +**Status: inherent.** No client-side fix exists; a Refounding is the answer, which is why B4 mattered. + + **High.** *Read:* `Account.consumeConcordRumorGated` (`isBanned(rumor.pubKey)`), `Account.isAcceptable`. Writing to a channel needs the channel key, which the ban does not take away; the seal author is @@ -324,6 +373,10 @@ correct design, which is why B4 matters so much. ## B6 — Channel history is deletable on a naive third-party relay +**Status: correct on our relay and pinned; external relays remain exposed.** Needs a CORD-01 spec note +and relay-selection guidance, not code. + + **High, external.** *Verified (that we are safe):* `geode/…/ConcordPlaneKeyDeletionTest.kt` (3 tests). @@ -346,6 +399,9 @@ Worth a note in the CORD-01 spec and a line in the relay-selection guidance. ## B7 — The base-rekey plane is writable by every member +**Status: accepted.** Bounded work per wrap, no correctness impact. + + **Low.** *Read:* `ConcordKeyDerivation.baseRekeyAddress`, `AccountConcordActions.drainConcordRekeys`. The base-rekey address derives from `community_root`, so any member — banned included — can mint @@ -406,25 +462,19 @@ Not looked at at all: - Unread counts and notification triggers, media/upload references from messages, the NIP-53 nests overlap, and the desktop client's Concord paths. -## Suggested order +## What is left -**Part A first.** It is the whole of the realistic threat — a banned user with the app already -installed — and none of it needs coordination with anyone. - -1. **A1** — one guard on `mintConcordInvite` plus one on its button. Smallest fix on the list and it - closes the attack a banned user will actually reach for. -2. **A3** — move authority into the action layer and replace `effectivePermissions` with - `hasPermission` everywhere it is used as an authorization test. This is also the cheapest partial - mitigation for B2: it shrinks what a banned staffer can do *without* writing their own client. -3. **A2** — needs the semantics decided before any code. Raise it with the spec. -4. **A4 / A5** — small, user-visible, and they make the product honest about what a ban is. - -**Then Part B**, hardest first because the ceiling is highest: - -5. **B4** — cheap, not consensus-affecting, and it protects the remedy every other fix depends on. -6. **B1** — worst blast radius, the only unrecoverable one, and the bar is a single ordinary - permission bit. -7. **B2 (+B3, +C1's open row)** — one two-pass change closes all three. Coordinate with Armada - first; this one splits consensus. -8. **B6** — spec note plus relay-selection guidance; our own behaviour is already correct and pinned. -9. **B5 / B7** — accept, or bound. +1. **A2's liveness half** — decide whether a community re-mints its invite bundle at a stable + coordinate. Today nothing does, so stranded recovery never fires for anyone, and an owner evicted + by a rogue admin has no route back. Needs a spec answer before code. +2. **C1 / B2 interop** — tell Armada about the two-pass rule, as with the rank rule before it. The + divergence is now wider: we drop editions they honor whenever a privileged member is banned. +3. **B6** — a CORD-01 note that a plane's wraps must stay owned by a key nobody holds, plus guidance + that a relay authorizing NIP-09/62 by `pubkey` hands every ex-member a wipe button. +4. **B3's residue** — a legitimately privileged rotator can still omit an entity during compaction. + `EntityFloor` catches it for clients that were present; fresh joiners have nothing. +5. **A5** — a design pass on how "Ban" and "Remove from community" are presented, since they promise + very different things. +6. **The unexamined surfaces below**, particularly private channels — there appears to be no + channel-scoped rekey receive path at all, which would mean the full-community Refounding is the + only removal Amethyst can perform. 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 039a63e7ca..ff85147ea6 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 @@ -135,9 +135,54 @@ data class AuthorityResolver private constructor( /** The owner's rank — supreme and unremovable. No Role may claim it. */ const val OWNER_RANK = 0L + /** + * The owner-rooted authority state of a community, with the banlist honored **against the + * Control Plane itself** (CORD-04 §4: a reader "drops every event from a banned npub — + * message, reaction, edit, or authority action"). + * + * This is a bounded two-pass, because the rule is circular as stated: you cannot know who is + * banned until you fold the Banlist, and you cannot decide who may write the Banlist without + * knowing who is banned. `docs/concord-banlist-rank-conformance.md` §4 row 3 flagged that to + * the spec authors and left it open. We resolve it by making authority only ever **shrink**: + * + * - **Pass A** resolves exactly as before, ban-blind, and yields a candidate banlist. + * - **Pass B** re-resolves with every author in that banlist treated as unauthorized, for + * roles, grants and the banlist alike. + * + * Two passes, always, so it terminates by construction — pass B never feeds back. It cannot + * oscillate on mutual bans either, because the rank rule makes them unreachable: only a + * member who strictly outranks you may ban you, and you cannot outrank them back. + * + * **This cascades, deliberately.** Every edition a banned member ever authored is dropped, + * including grants they made while in good standing — so banning an admin also demotes + * everyone that admin promoted. That is the literal reading of §4, and it is the point: the + * escalation in `docs/concord-soft-ban-audit.md` B2 was a banned staffer minting a fresh, + * un-banned npub and acting through it, and dropping the grant is what kills the puppet. The + * cost is that a legitimate promotion by a later-banned admin vanishes too, and the owner has + * to re-issue it. + * + * **Consensus-affecting.** Armada gates the Control Plane on role-derived permissions alone, + * so until it ships the same rule the two clients can disagree about any community where a + * privileged member was banned. + */ fun resolve( editions: Collection, ownerPubKey: String, + ): AuthorityResolver { + val passA = resolveOnce(editions, ownerPubKey, bannedAuthors = emptySet()) + if (passA.banned.isEmpty()) return passA + return resolveOnce(editions, ownerPubKey, bannedAuthors = passA.banned) + } + + /** + * One resolution pass. [bannedAuthors] are treated as holding no authority at all — their + * role, grant and banlist editions are dropped rather than merely being unable to act on + * others. Empty on pass A; pass A's banlist on pass B. See [resolve]. + */ + private fun resolveOnce( + editions: Collection, + ownerPubKey: String, + bannedAuthors: Set, ): AuthorityResolver { val ownerLower = ownerPubKey.lowercase() @@ -191,6 +236,7 @@ data class AuthorityResolver private constructor( ): Boolean { val author = e.author.lowercase() if (author == ownerLower) return true + if (author in bannedAuthors) return false if (!holdsManageRoles(author)) return false val authorRank = rankOf(author) ?: return false val r = ConcordJson.decodeOrNull(e.content) ?: return false @@ -223,6 +269,7 @@ data class AuthorityResolver private constructor( fun grantGate(e: ControlEdition): Boolean { val granter = e.author.lowercase() if (granter == ownerLower) return true + if (granter in bannedAuthors) return false if (!holdsManageRoles(granter)) return false val granterRank = rankOf(granter) ?: return false val g = ConcordJson.decodeOrNull(e.content) ?: return false @@ -269,7 +316,9 @@ data class AuthorityResolver private constructor( // a concurrent ban is never lost, while an on-chain unban still takes effect. val allBanlist = editions.filter { it.entityKind == ControlEntityKind.BANLIST } - fun banGate(e: ControlEdition): Boolean = e.author.lowercase() == ownerLower || effectivePermissionsOf(e.author.lowercase()).has(ConcordPermissions.BAN) + fun banGate(e: ControlEdition): Boolean = + e.author.lowercase() == ownerLower || + (e.author.lowercase() !in bannedAuthors && effectivePermissionsOf(e.author.lowercase()).has(ConcordPermissions.BAN)) val authorizedBanlist = allBanlist.filter(::banGate) // CORD-04 §3's rank rule binds "every action", and it names banning as its example ("an @@ -292,6 +341,7 @@ data class AuthorityResolver private constructor( // owner is never a valid target — not even for themselves. if (target == ownerLower) return false if (author == ownerLower) return true + if (author in bannedAuthors) return false if (!effectivePermissionsOf(author).has(ConcordPermissions.BAN)) return false val authorRank = rankOf(author) ?: return false val targetRank = rankOf(target) ?: Long.MAX_VALUE // no roles ⇒ lowest authority diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt index aedadd885a..5b6c0fc64d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt @@ -28,27 +28,31 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * What a **soft-banned staffer** can still do to a community — the reproduction behind - * `docs/concord-banlist-rank-conformance.md` §4 row 3, which the report left open as "a genuine - * fixpoint-ordering question, not a plain oversight". + * **B2 in `docs/concord-soft-ban-audit.md` — regression guard.** What a soft-banned staffer used to + * be able to do to a community, and can no longer. Every test here failed before the two-pass rule + * in [AuthorityResolver.resolve] and passes after it. * - * The asymmetry these tests pin: [ConcordCommunityState.fold] gates METADATA / CHANNEL / INVITE - * through `authority.hasPermission`, which is `!isBanned && …`, but ROLE, GRANT and BANLIST are - * gated *inside* [AuthorityResolver.resolve] by `holdsManageRoles` / `bitsOf` / - * `effectivePermissionsOf` — none of which consult the banlist. Nor could they as written: the - * roles/grants fixpoint runs before `banned` is computed at all. So half the Control Plane honors - * a ban and half is structurally blind to it, and a banned member who still holds `control_root` - * keeps full authority over the roster. + * The asymmetry they were written to pin: [ConcordCommunityState.fold] gates METADATA / CHANNEL / + * INVITE through `authority.hasPermission`, which is `!isBanned && …`, but ROLE, GRANT and BANLIST + * were gated *inside* [AuthorityResolver.resolve] by `holdsManageRoles` / `bitsOf` / + * `effectivePermissionsOf` — none of which consulted the banlist, nor could they as written, since + * the roles/grants fixpoint ran before `banned` was computed at all. Half the Control Plane honored + * a ban and half was structurally blind to it, so a banned member still holding `control_root` kept + * full authority over the roster: they banned everyone beneath them, revoked the surviving + * moderators, retired the roles under them, and minted a fresh un-banned npub that passed every + * ban-aware gate and finished the job. * - * **These tests assert the CURRENT, VULNERABLE behaviour**, so the escalation cannot regress - * silently or be "fixed" by accident without someone noticing. Every `ESCALATION:` assertion here - * must be INVERTED — not deleted — when the ordering rule lands. [selfUnbanIsStillRefused] and - * [aJuniorPuppetCannotLiftASeniorsBan] are the opposite: they pin behaviour the fix must preserve. + * The fix resolves the ordering by making authority only ever shrink across two passes — see + * [AuthorityResolver.resolve]. Note that a chain-local rule ("the author must not be banned by the + * state their edition chains from") would NOT have been enough: + * [aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan] forks at genesis so no + * parent ever mentions the ban, and CORD-04 §4's re-heal union would carry it in anyway. The rule + * had to bind the union too, which is why it is expressed as a whole-pass mask. * - * Note for whoever writes that fix: a chain-local rule ("the author must not be banned by the state - * their edition chains from") is NOT sufficient — see - * [aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan]. The rule has to bind - * CORD-04 §4's re-heal union too. + * Three tests pin behaviour the fix had to *preserve* rather than change: + * [selfUnbanIsStillRefused], [aJuniorPuppetCannotLiftASeniorsBan], and + * [aBanByOneAdminDoesNotDropTheGrantsOfAnother]. One pins the cost it deliberately accepts: + * [banningAnAdminAlsoDemotesEveryoneThatAdminPromoted]. */ class BannedStaffEscalationTest { private val owner = "0f".repeat(32) @@ -167,10 +171,12 @@ class BannedStaffEscalationTest { assertTrue(r.isBanned(alice), "the owner's ban lands") assertFalse(r.hasPermission(alice, ConcordPermissions.MANAGE_ROLES), "the ban-aware check refuses her") - // ...but this is the one every ROLE/GRANT/BANLIST gate inside resolve() actually consults. + // effectivePermissions still reports what her ROLES say — that is its job, and the members + // screen reads it to label her. What changed is that the resolver's own ROLE/GRANT/BANLIST + // gates no longer consult it for a banned author; they drop the edition outright. assertTrue( r.effectivePermissions(alice).has(ConcordPermissions.MANAGE_ROLES), - "ESCALATION: a banned staffer keeps the permissions the resolver's own gates read", + "the role-derived view is unchanged — only what it authorizes is", ) } @@ -178,11 +184,11 @@ class BannedStaffEscalationTest { fun aBannedAdminPromotesAFreshSockpuppetToAdmin() { val r = AuthorityResolver.resolve(community() + ownerBansAlice + aliceMintsAPuppet(), owner) - assertEquals(2, r.rank(puppet), "ESCALATION: the banned admin's role edition is honored") - assertFalse(r.isBanned(puppet), "the puppet is a clean npub — nothing to filter it on") - assertTrue( + assertEquals(null, r.rank(puppet), "the banned admin's role and grant editions are both dropped") + assertFalse(r.isBanned(puppet), "the puppet itself is a clean npub — it is never banned, just powerless") + assertFalse( r.hasPermission(puppet, ConcordPermissions.MANAGE_CHANNELS), - "ESCALATION: a banned member minted a live admin with the ban-aware check passing", + "a banned member cannot mint authority it no longer has to give", ) } @@ -196,8 +202,8 @@ class BannedStaffEscalationTest { val state = ConcordCommunityState.fold(editions, owner) - assertEquals(0, state.channels.size, "ESCALATION: the community's channels are irrecoverably tombstoned") - assertEquals("Owned by the guy you banned", state.metadata?.name, "ESCALATION: and its identity rewritten") + assertEquals(1, state.channels.size, "the puppet holds nothing, so its tombstone is inert") + assertEquals("My Community", state.metadata?.name, "and the community keeps its identity") } @Test @@ -206,8 +212,8 @@ class BannedStaffEscalationTest { val r = AuthorityResolver.resolve(editions, owner) - assertTrue(r.isBanned(bob), "ESCALATION: the surviving moderator is silenced, losing all authority with it") - assertTrue(r.isBanned(carol), "ESCALATION: and the plain members with them") + assertFalse(r.isBanned(bob), "the puppet's banlist edition is unauthorized, so the moderator stands") + assertFalse(r.isBanned(carol), "and so do the plain members") } @Test @@ -216,8 +222,9 @@ class BannedStaffEscalationTest { val r = AuthorityResolver.resolve(editions, owner) - assertTrue(r.isBanned(bob), "ESCALATION: banGate reads effectivePermissionsOf, which ignores her own ban") - assertTrue(r.isBanned(carol), "ESCALATION: same") + assertTrue(r.isBanned(alice), "her own ban stands — it was the owner's") + assertFalse(r.isBanned(bob), "banGate now drops a banned author's edition outright") + assertFalse(r.isBanned(carol), "same") } @Test @@ -231,8 +238,8 @@ class BannedStaffEscalationTest { val r = AuthorityResolver.resolve(editions, owner) assertTrue(r.isBanned(alice), "the owner's ban survives the fork — the union is down-only") - assertTrue(r.isBanned(bob), "ESCALATION: and so does the banned admin's, healed in as a concurrent ban") - assertTrue(r.isBanned(carol), "ESCALATION: same") + assertFalse(r.isBanned(bob), "the fix binds the UNION too: her fork is dropped before it can be healed in") + assertFalse(r.isBanned(carol), "same") } @Test @@ -241,8 +248,8 @@ class BannedStaffEscalationTest { val r = AuthorityResolver.resolve(editions, owner) - assertEquals(null, r.rank(bob), "ESCALATION: a banned admin stripped a live moderator's roles") - assertFalse(r.hasPermission(bob, ConcordPermissions.BAN), "ESCALATION: leaving nobody but the owner able to act") + assertEquals(5, r.rank(bob), "a banned admin's revoke is dropped, so the moderator keeps their role") + assertTrue(r.hasPermission(bob, ConcordPermissions.BAN), "and keeps the authority that comes with it") } @Test @@ -251,8 +258,8 @@ class BannedStaffEscalationTest { val r = AuthorityResolver.resolve(community() + ownerBansAlice + tombstone, owner) - assertEquals(null, r.roles()[modRole], "ESCALATION: a banned admin retired a role beneath them") - assertEquals(null, r.rank(bob), "ESCALATION: every holder of it silently loses their standing") + assertEquals(5, r.roles()[modRole]?.position, "a banned admin's tombstone is dropped, so the role survives") + assertEquals(5, r.rank(bob), "and its holders keep their standing") } @Test @@ -323,4 +330,36 @@ class BannedStaffEscalationTest { "a client that already folded the ban must refuse the rollback", ) } + + @Test + fun banningAnAdminAlsoDemotesEveryoneThatAdminPromoted() { + // The deliberate cascade, pinned because it is surprising and because it is the whole point. + // CORD-04 §4 drops every event from a banned npub, authority actions included, so a grant + // they made while in good standing goes too. That is what kills a sockpuppet minted moments + // before the ban — and the same rule costs the owner a legitimate promotion, which they have + // to re-issue. See B2 in docs/concord-soft-ban-audit.md. + val promoted = grant("36".repeat(32), carol, listOf(modRole), author = alice) + + val before = AuthorityResolver.resolve(community() + promoted, owner) + assertEquals(5, before.rank(carol), "while alice is in good standing, her grant stands") + + val after = AuthorityResolver.resolve(community() + promoted + ownerBansAlice, owner) + assertEquals(null, after.rank(carol), "banning alice retroactively drops the grant she authored") + } + + @Test + fun aBanByOneAdminDoesNotDropTheGrantsOfAnother() { + // The cascade must follow the banned author, not spread. Bob is untouched by alice's ban, so + // everything he authored keeps standing. + val carolByBob = grant("37".repeat(32), carol, listOf(modRole), author = bob) + // bob is a Mod at position 5 and the role he hands out is that same position, so the grant is + // only honored when authored by someone who outranks it — the owner does, bob does not. + val carolByOwner = grant("38".repeat(32), carol, listOf(modRole), author = owner) + + val r = AuthorityResolver.resolve(community() + ownerBansAlice + carolByBob + carolByOwner, owner) + + assertTrue(r.isBanned(alice), "alice is the only one banned") + assertEquals(5, r.rank(bob), "bob is untouched") + assertEquals(5, r.rank(carol), "and the owner's grant of carol stands") + } } From 6147f72c8112e8a0e9652064a970695634b09743 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 15:54:55 +0000 Subject: [PATCH 178/227] docs(concord): check the audit against Armada, and correct two conclusions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read gitlab.com/soapbox-pub/armada src/concord-v2/ against every finding. Two conclusions change. B2 is NOT consensus-affecting, and the warning in the last commit was wrong. Armada's foldControlState already runs the same bounded two-pass — fold once, take the banlist, re-fold with banned authors' editions excluded — arrived at independently, same shape, same CORD-04 §4 justification in the comment. This change brings us into line rather than out of it. One narrower divergence remains: they keep pass 1's banlist as final, we recompute it in pass 2, so a banned admin's mass-ban still stands for them and is dropped by us. Both defensible; ours closes an attack theirs leaves open, and the self-erasure they guard against is unreachable under the rank rule. A2's fork is resolved, in favour of the fix having been necessary. useLinkRefreshWatch2 re-posts every invite bundle on each epoch change, so the "if anything re-mints at a stable coordinate" branch is what actually happens — in any cross-client community a removed member's Amethyst client would have pulled the new root within fifteen minutes. Their catch-up is push instead: a privileged member sends a direct invite carrying the fresher root, so a human authorizes each re-admission, and useBanSelfRemove2 has a banned member's own client silently drop the community. The liveness half stands and now has two concrete options rather than an open question. Also recorded: B1 is present in Armada unfixed, in exactly the same place (bootstrapHead is unbounded, headCandidates uses it, pickHead raises the floor) — the second bug both clients share by reading one section the same way, so it goes to them in writing like the rank rule did. A1 was ours alone; they gate invite creation on CREATE_INVITE in both the hook and the page. C1 is unchanged on their side. A4 is a shared gap. And a divergence in the other direction: their banlist takes only the head's content, with no §4 re-heal union, so we honor concurrent bans they drop. B4 is marked unchecked rather than guessed at — I could not locate their recipient-set construction with confidence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- docs/concord-banlist-rank-conformance.md | 23 +++++++ docs/concord-soft-ban-audit.md | 80 +++++++++++++++++++++--- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/docs/concord-banlist-rank-conformance.md b/docs/concord-banlist-rank-conformance.md index 04e92c9de7..d48623c113 100644 --- a/docs/concord-banlist-rank-conformance.md +++ b/docs/concord-banlist-rank-conformance.md @@ -213,6 +213,29 @@ to re-issue it. If you read §4 as scoping only to editions authored *after* the implementable too, but it needs the spec to define an ordering between an edition and a Banlist entry, which today it does not. +We checked your implementation before writing this, and you got there first: `foldControlState` +already runs the same two-pass, with the same §4 justification in the comment. So this is us catching +up, not diverging — with one narrower difference. You keep **pass 1's** Banlist as the final word; +we recompute it in pass 2. So a banned admin's mass-ban of everyone beneath them still stands for you +and is dropped by us. Your stated reason is to stop the anti-roster erasing itself; ours is that an +edition should not outlive its author's removal, and the self-erasure case is unreachable under the +rank rule anyway, since only a member who strictly outranks you can ban you. We would rather converge +than be right — tell us which way and we will move. + +Two more things that fell out of reading `src/concord-v2/` side by side, both worth their own look: + +- **`bootstrapHead` has no bound** (`lib/version.ts`), and `headCandidates` uses it for the + compaction arm while `pickHead` then raises the stored floor to whatever won. One authorized + edition at `version = 2^63 - 1` therefore becomes an entity's permanent head: the floor rises to + match, nothing honest can exceed it, and even a Refounding that drops the edition falls back to the + remembered head — which is that edition. It needs no ban and no sockpuppet, just one ordinary + permission bit. This is the second bug both implementations share by reading the same section the + same way; ours is described in `docs/concord-soft-ban-audit.md` (B1), and we bounded the jump the + arm will follow. +- **Your Banlist takes only the gated head's content**, with no §4 re-heal union. We union in every + authorized non-ancestor edition, which is what defeats an attempt to launder a ban away by forking + the list at genesis. So we honor concurrent bans you drop. Which is normative? + A chain-local rule is not enough, and this is the trap worth flagging: "the author must not be banned by the state their edition chains from" is bypassed by forking the Banlist at genesis, where no parent ever mentions the ban and §4's re-heal union carries it in anyway. The rule has to bind the diff --git a/docs/concord-soft-ban-audit.md b/docs/concord-soft-ban-audit.md index 1da43ed4d0..6040d8ee7f 100644 --- a/docs/concord-soft-ban-audit.md +++ b/docs/concord-soft-ban-audit.md @@ -62,7 +62,7 @@ Two structural causes account for most of both halves: | # | Finding | Severity | Needs a ban? | Status | |---|---------|----------|--------------|--------| | [B1](#b1) | One edition at `version = Long.MAX_VALUE` pins an entity forever | **Critical** | No — any bit-holder | **Fixed** | -| [B2](#b2) | A banned staffer keeps Role/Grant/Banlist authority | **Critical** | Yes | **Fixed** (consensus-affecting) | +| [B2](#b2) | A banned staffer keeps Role/Grant/Banlist authority | **Critical** | Yes | **Fixed** (matches Armada) | | [B3](#b3) | A rogue rotator compacts the banlist away | High | Via B2 | **Mitigated** by B2 | | [B4](#b4) | The Refounding recipient set is attacker-inflatable | High | No | **Fixed** (bounded) | | [B5](#b5) | The ban is per-pubkey; the channel key is not revoked | High | Yes | Inherent — Refounding is the answer | @@ -111,10 +111,13 @@ button on the same. This is contained, uncontroversial, and closes the realistic ## A2 — Stranded recovery runs on a timer and never checks the banlist -**Status: security half fixed; liveness half open.** `isStranded` / `mergeForward` now take -`bannedAtCurrentEpoch` as a *required* argument, so a removed member is no longer walked back in. -Whether anything should re-mint at a stable coordinate — without which legitimate recovery never -fires for anyone — still needs a spec answer and is untouched. +**Status: security half fixed; liveness half open — and the fork is now resolved.** `isStranded` / +`mergeForward` take `bannedAtCurrentEpoch` as a *required* argument, so a removed member is no longer +walked back in. The open question was whether anything re-mints at a stable coordinate. **Armada +does** — `useLinkRefreshWatch2` re-posts every bundle on each epoch change — so in any cross-client +community this was a *live* removal bypass, not a hypothetical, and the fix was load-bearing. The +liveness half stands: Amethyst re-mints nothing, so legitimate recovery never fires for an +Amethyst-only community. See [the Armada comparison](#armada) for the two ways out. **Critical, and it forks.** *Read:* `ConcordStrandedRecovery`, @@ -275,13 +278,13 @@ The first is the smallest change and closes the unrecoverability; the third shou ## B2 — A banned staffer keeps Role, Grant and Banlist authority -**Status: fixed — and consensus-affecting.** `AuthorityResolver.resolve` is now a bounded two-pass +**Status: fixed. Not consensus-affecting after all** — see [Armada comparison](#armada). Armada +already implements the same two-pass, so this brings us *into* line rather than out of it. One +narrower divergence remains, described there. `AuthorityResolver.resolve` is now a bounded two-pass where authority only shrinks. Note the deliberate cascade it brings: every edition a banned member ever authored is dropped, so banning an admin also demotes everyone that admin promoted. That is the literal reading of CORD-04 §4 and it is what kills the sockpuppet, but a legitimate promotion by a -later-banned admin vanishes with it and has to be re-issued. Until Armada ships the same rule the two -clients can disagree about any community where a privileged member was banned. - +later-banned admin vanishes with it and has to be re-issued. **Critical.** *Verified:* `quartz/…/cord04Roles/BannedStaffEscalationTest.kt` (13 tests). @@ -440,6 +443,65 @@ It would be the one place where a ban fails *audibly*, in real time, in front of worth designing the roster check in before shipping rather than after. + +--- + +## Armada comparison (checked 2026-08-09) + +Read against `gitlab.com/soapbox-pub/armada` at `src/concord-v2/`. Worth doing before shipping any of +this, and it changed two conclusions. + +**B2 — they already do it, and we had it backwards.** `foldControlState` (`lib/control.ts`) runs the +same bounded two-pass: fold once, take the banlist, and if any edition was authored by someone on it, +re-fold with those editions excluded. Independently arrived at, same shape, same CORD-04 §4 +justification in the comment. So this change brings us *into* line with Armada rather than out of it, +and the consensus warning in the earlier revision of this doc was wrong. + +One real divergence remains, and it is ours to defend: Armada keeps **pass 1's** banlist as the final +word ("the first pass's Banlist stays the final word"), while we recompute the banlist in pass 2. So +a banned admin's mass-ban of everyone beneath them still stands in Armada and is dropped by us — the +`aBannedAdminBansEveryoneBeneathThemWithoutNeedingAPuppetAtAll` case. Their stated reason is to stop +the anti-roster erasing itself; ours is that an edition from a banned author should not survive its +own author's removal. Both are defensible; ours closes an attack theirs leaves open, and the +self-erasure they worry about is unreachable for us because the rank rule makes mutual bans +impossible (only someone who strictly outranks you can ban you). Worth raising with them. + +**B1 — the same bug, unfixed, in exactly the same place.** `bootstrapHead` (`lib/version.ts:155`) +takes the highest version at or above the floor with no bound; `headCandidates` uses it for the +compaction arm; `pickHead` then raises the stored floor to whatever won. That is the whole +version-exhaustion chain. This is now the second bug both implementations share because both read +the same section the same way, and it deserves the same treatment as the rank rule: a written report. + +**A1 — ours alone.** Armada gates invite creation on `CREATE_INVITE` in both the hook +(`useInvites2.ts`) and the page (`canCreateInvite`). We were the only client handing a banned member +a working invite button. + +**A2 — different architecture, and it is better.** Armada's catch-up is **push**, not pull: a +privileged member sends a stranded member a direct invite carrying the fresher root +(`useDirectInvites2`, `catchUp`), so a human authorizes each re-admission. `useRekeyWatch2` merely +reports `{ stranded: boolean }` for the UI. They also ship `useBanSelfRemove2`: a banned member's own +client silently drops the community from their private list — network-silent, deliberately narrower +than rekey-exclusion, because "a rotation can be a mistake; a ban is a judgment". Our pull-from-my-own- +old-link design is what made the bypass possible, and their per-epoch bundle refresh is what would +have supplied the higher epoch to pull. Two ways forward: adopt a refresh of our own (restores +liveness, keeps the pull design and its risk), or move to their push model (safer, and it is what the +one existing implementation does). + +**C1 — still open on their side.** `banlistGate` remains a bare `isAuthorized(roster, author, owner, +BAN)`: no rank check, no delta rule. The divergence from +`docs/concord-banlist-rank-conformance.md` is unchanged. + +**A divergence in the other direction.** Armada's banlist takes only the gated head's content — +there is no §4 re-heal union. Ours unions in every authorized non-ancestor edition, which is what +defeats the genesis-fork laundering attempt in `BannedStaffEscalationTest`. So we honor concurrent +bans they drop. Worth a spec question about which is normative. + +**A4 — shared gap.** No ban filter on typing there either. + +**B4 — not established.** I could not locate a recipient-set bound in their rekey path, but I also +could not locate the recipient-set construction itself with confidence, so treat this as unchecked +rather than as a finding either way. + --- ## What was NOT examined From 54d3bfc84a6f78d5adb3f108c8f3c23ba3813ee5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 16:09:53 +0000 Subject: [PATCH 179/227] perf(quartz): skip the resolver's second pass when it cannot change anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-pass ban-aware fold doubles resolve(), which runs once per held epoch in controlFloorsLocked plus once in fold, on a client that re-folds the whole buffer from scratch on every Control Plane change. So the second pass is now skipped unless a banned member actually authored a Control edition — not merely when the banlist is empty. Bans overwhelmingly land on plain members who hold no role and write nothing, and for those pass B is provably identical to pass A. Armada's fold checks the same condition. Measured over ConcordCommunityState.fold (throwaway benchmark, not committed; 226 and 2059 editions, 200 reps after warmup). Pass A is byte-for-byte the old algorithm, so the single-pass rows are the before-numbers: 226 eds, no bans 1457 us 226 eds, 20 bans, none authored 994 us 226 eds, 20 bans, one authored -> pass B 1881 us 2059 eds, no bans 2194 us 2059 eds, 50 bans, none authored 2001 us 2059 eds, 50 bans, one authored -> pass B 5697 us 2059 eds, with floors (B1's arm) 2015 us So the common case is free, and B1's chain-first compaction arm is not measurable — the floored fold matches the unfloored one. A banned staffer costs ~2-3x, which is the price of the fix and is paid only under the attack. The audit records this, plus the standing opportunity it surfaced: we have no fold memoization where Armada does, which predates this work and would absorb the pass-B cost too. Not done here — that is a change to make on its own merits. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- docs/concord-soft-ban-audit.md | 40 +++++++++++++++++++ .../concord/cord04Roles/AuthorityResolver.kt | 5 +++ 2 files changed, 45 insertions(+) diff --git a/docs/concord-soft-ban-audit.md b/docs/concord-soft-ban-audit.md index 6040d8ee7f..8e766c8b46 100644 --- a/docs/concord-soft-ban-audit.md +++ b/docs/concord-soft-ban-audit.md @@ -502,6 +502,46 @@ bans they drop. Worth a spec question about which is normative. could not locate the recipient-set construction itself with confidence, so treat this as unchecked rather than as a finding either way. + +--- + +## Performance of the fixes + +Measured on the JVM with a synthetic Control Plane (throwaway benchmark, not committed — +`ConcordCommunityState.fold` over 226 and 2059 editions, 200 reps after warmup). Pass A of the +two-pass resolver is byte-for-byte the old algorithm, so the single-pass rows below *are* the +before-numbers. + +| Case | µs / fold | +|---|---| +| 226 editions, no bans | 1457 | +| 226 editions, 20 bans, none of them authors | 994 | +| 226 editions, 20 bans, one an author → pass B runs | 1881 | +| 2059 editions, no bans | 2194 | +| 2059 editions, 50 bans, none of them authors | 2001 | +| 2059 editions, 50 bans, one an author → pass B runs | 5697 | +| 2059 editions, with floors (B1's compaction arm) | 2015 | + +Two things to take from it. + +**B1 costs nothing measurable.** Trying the floor-anchored chain before the raw-version bootstrap +adds a per-entity version index on the compaction arm, but an entity carries a handful of editions, +and the floored fold measures the same as the unfloored one. + +**B2 costs a second fold, but only when it can change the answer.** `resolve` skips pass B when +nobody is banned *or* when nobody banned ever authored a Control edition — the overwhelmingly common +shape, since bans land on plain members who hold no role and write nothing. Those rows show no +regression. When a banned member *did* author editions — a banned staffer, exactly the case B2 exists +for — the fold costs ~2–3× more. That is the price of the fix and it is paid only by communities +under the attack. + +**Worth knowing, unrelated to this work:** Amethyst re-folds the whole buffer from scratch on every +Control Plane change, and `resolve` runs once per held epoch inside `controlFloorsLocked` plus once +in `fold`, so a refresh is already several folds. Armada memoizes the fold by +`(community, owner, floors, snapshot, edition ids)`; we do not. That is the real optimization here, +it predates these fixes, and it would also absorb the pass-B cost. Left alone deliberately — it is a +change to make on its own merits, with its own measurements. + --- ## What was NOT examined 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 ff85147ea6..ffc637bd2c 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 @@ -170,7 +170,12 @@ data class AuthorityResolver private constructor( ownerPubKey: String, ): AuthorityResolver { val passA = resolveOnce(editions, ownerPubKey, bannedAuthors = emptySet()) + // Pass B costs a whole second fold, so skip it unless it could change something. Nobody + // banned, or nobody banned who ever wrote to the Control Plane — the overwhelmingly common + // shape, since most bans land on plain members who hold no role and author no editions — + // and pass B is provably identical to pass A. This is also what Armada's fold checks. if (passA.banned.isEmpty()) return passA + if (editions.none { it.author.lowercase() in passA.banned }) return passA return resolveOnce(editions, ownerPubKey, bannedAuthors = passA.banned) } From 9cc19c60ca6f9d83b548b741488a95b7003e6904 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 16:38:35 +0000 Subject: [PATCH 180/227] fix(concord): three defects found auditing this branch's own changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of the diff before merge. One of these is a real correctness bug in the B2 fix as shipped. The resolver stopped after two passes, which left the mask a pass resolved UNDER disagreeing with the banlist that pass produced — and the disagreement is not cosmetic. A moderator whose only ban came from an admin the owner banned concurrently is released by pass 2, correctly; but pass 2 had already dropped her editions, because she was on pass 1's list. The fold then reported her as a moderator in good standing whose promotions had silently vanished, and did so deterministically, so she never got them back. resolve() now iterates until the mask and the resulting banlist agree. The mask cannot simply be assumed to shrink, which is why this is bounded rather than proven monotone: masking an author can strip a THIRD member's role, which drops their rank to roleless, which lets a junior BAN holder who previously could not reach them ban them after all. The loop keeps its last pass if it does not settle within the cap — still better than the two-pass answer, and it always terminates. Real communities settle on the first or second pass, and the skip-if-no-banned-author guard means most never enter the loop at all. boundRecipients could exceed its own budget while reporting that it had capped at it, because the roster was added with filterTo before the budget loop ran. The roster now goes in whole deliberately — it is owner-rooted and cannot be padded from outside, and dropping an admin to make room for a stranger inverts the point — and the log reports what was actually kept and dropped. mintConcordInvite started requiring a session, which the owner's own invite button would not have on a cold start, since sessions are built asynchronously off the joined list. The owner is proven by the community id, so they are read off the entry; everyone else still needs the folded roster. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- .../amethyst/model/AccountConcordActions.kt | 24 +++++++++---- docs/concord-soft-ban-audit.md | 7 ++-- .../concord/cord04Roles/AuthorityResolver.kt | 35 +++++++++++++++++-- .../cord04Roles/BannedStaffEscalationTest.kt | 32 +++++++++++++++++ 4 files changed, 86 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index 5b5c8fe0db..fc812f1bb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -189,8 +189,13 @@ class AccountConcordActions( // Note the bit is not otherwise enforced anywhere. The fold gates the INVITE_* Control // entities on CREATE_INVITE, but a link's bundle is a standalone kind-33301 published // OUTSIDE the Control Plane, so no fold ever sees it. This check is the only one there is. - val session = account.concordSessions.sessionFor(communityId) ?: return null - if (!isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE)) return null + // The owner is proven by the community id (CORD-02), so they are read off the entry and can + // mint before the session exists — the session is built asynchronously off the joined list, + // and requiring it here would have made the owner's own invite button fail on a cold start. + // Everyone else needs the folded roster, so no session means no invite. + val session = account.concordSessions.sessionFor(communityId) + val amOwner = entry.owner.equals(account.signer.pubKey, ignoreCase = true) + if (!amOwner && (session == null || !isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE))) return null val invite = ConcordActions.inviteFor( communityIdHex = entry.id, @@ -885,16 +890,23 @@ class AccountConcordActions( ): List { if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList() + // The roster goes in whole even if it alone exceeds the budget: it is owner-rooted, so it + // cannot be padded from outside, and dropping an admin to make room for a stranger inverts + // the point of the cap. val vouched = authority.roleHolders() + authority.staffMembers() - val kept = LinkedHashSet(MAX_REFOUNDING_RECIPIENTS) + val kept = LinkedHashSet() candidates.filterTo(kept) { it in vouched } for (candidate in candidates) { if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break kept.add(candidate) } - Log.w("Concord") { - "Refounding recipient set capped at $MAX_REFOUNDING_RECIPIENTS of ${candidates.size}: " + - "${candidates.size - kept.size} member(s) will be stranded on the prior epoch" + val dropped = candidates.size - kept.size + if (dropped > 0) { + Log.w("Concord") { + "Refounding recipient set trimmed to ${kept.size} of ${candidates.size} " + + "(budget $MAX_REFOUNDING_RECIPIENTS, roster kept whole): $dropped member(s) will be " + + "stranded on the prior epoch" + } } return kept.toList() } diff --git a/docs/concord-soft-ban-audit.md b/docs/concord-soft-ban-audit.md index 8e766c8b46..43848a1302 100644 --- a/docs/concord-soft-ban-audit.md +++ b/docs/concord-soft-ban-audit.md @@ -528,12 +528,13 @@ Two things to take from it. adds a per-entity version index on the compaction arm, but an entity carries a handful of editions, and the floored fold measures the same as the unfloored one. -**B2 costs a second fold, but only when it can change the answer.** `resolve` skips pass B when +**B2 costs a further fold, but only when it can change the answer.** `resolve` skips pass B when nobody is banned *or* when nobody banned ever authored a Control edition — the overwhelmingly common shape, since bans land on plain members who hold no role and write nothing. Those rows show no regression. When a banned member *did* author editions — a banned staffer, exactly the case B2 exists -for — the fold costs ~2–3× more. That is the price of the fix and it is paid only by communities -under the attack. +for — the fold costs ~2–3× more, and one more pass again in the rare case where a banned member had +themselves authored a ban. That is the price of the fix and it is paid only by communities under the +attack. **Worth knowing, unrelated to this work:** Amethyst re-folds the whole buffer from scratch on every Control Plane change, and `resolve` runs once per held epoch inside `controlFloorsLocked` plus once 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 ffc637bd2c..126eaade9b 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 @@ -135,6 +135,14 @@ data class AuthorityResolver private constructor( /** The owner's rank — supreme and unremovable. No Role may claim it. */ const val OWNER_RANK = 0L + /** + * How many times [resolve] will re-fold chasing a stable banlist. Real communities settle on + * the first or second — the mask only moves when a banned member authored a *ban*, and it + * stops moving as soon as those are gone. The cap is a termination backstop for an + * adversarial edition set, not a tuning knob. + */ + private const val MAX_BAN_RESOLUTION_PASSES = 4 + /** * The owner-rooted authority state of a community, with the banlist honored **against the * Control Plane itself** (CORD-04 §4: a reader "drops every event from a banned npub — @@ -170,13 +178,34 @@ data class AuthorityResolver private constructor( ownerPubKey: String, ): AuthorityResolver { val passA = resolveOnce(editions, ownerPubKey, bannedAuthors = emptySet()) - // Pass B costs a whole second fold, so skip it unless it could change something. Nobody + // A further pass costs a whole fold, so skip it unless it could change something. Nobody // banned, or nobody banned who ever wrote to the Control Plane — the overwhelmingly common // shape, since most bans land on plain members who hold no role and author no editions — - // and pass B is provably identical to pass A. This is also what Armada's fold checks. + // and the next pass is provably identical to this one. Armada's fold checks the same. if (passA.banned.isEmpty()) return passA if (editions.none { it.author.lowercase() in passA.banned }) return passA - return resolveOnce(editions, ownerPubKey, bannedAuthors = passA.banned) + + // Iterate to a fixpoint where the mask a pass was resolved UNDER equals the banlist that + // pass produced. Stopping at two passes leaves those two disagreeing, and the disagreement + // is not cosmetic: a moderator whose only ban came from an admin the owner banned + // concurrently is released by pass 2 — correctly — but pass 2 dropped her editions too, + // because she was on pass 1's list. The fold then reports her as a moderator in good + // standing whose promotions have silently vanished, and it does so deterministically, so + // she never gets them back. + // + // The mask cannot simply be assumed to shrink: masking an author can strip a THIRD + // member's role, dropping their rank to "roleless", which lets a junior BAN holder who + // could not previously reach them ban them after all. So this is bounded rather than + // proven monotone, and it keeps the last pass it computed if it somehow does not settle — + // still strictly better than the two-pass answer, and it always terminates. + var mask = passA.banned + var result = passA + repeat(MAX_BAN_RESOLUTION_PASSES) { + result = resolveOnce(editions, ownerPubKey, bannedAuthors = mask) + if (result.banned == mask) return result + mask = result.banned + } + return result } /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt index 5b6c0fc64d..3bed1c8575 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt @@ -362,4 +362,36 @@ class BannedStaffEscalationTest { assertEquals(5, r.rank(bob), "bob is untouched") assertEquals(5, r.rank(carol), "and the owner's grant of carol stands") } + + @Test + fun aMemberReleasedByTheSecondPassKeepsTheEditionsTheyAuthored() { + // The mask a pass resolves UNDER has to equal the banlist that pass produces, or the fold + // reports a state that contradicts itself. Concretely: the rogue admin bans a moderator while + // the owner concurrently bans the rogue. The moderator is correctly released — the only ban on + // her came from someone who turned out to be banned — but a fold that stops after two passes + // has already dropped her editions, because she was on the FIRST pass's list. She then reads + // as a moderator in good standing whose promotions silently vanished, deterministically and + // forever. resolve() iterates until the two agree. + val juniorRole = "23".repeat(32) + val seniorRole = "24".repeat(32) + val editions = + community() + + // the baseline Mod role carries no MANAGE_ROLES, so give bob one that can grant + role(seniorRole, """{"name":"Senior","position":5,"permissions":"95"}""") + + grant(bobGrantEntity, bob, listOf(seniorRole), author = owner, version = 1, prev = bobGrantV0.hash) + + role(juniorRole, """{"name":"Junior","position":9,"permissions":"8"}""") + + // bob promotes carol himself, while in good standing + grant("39".repeat(32), carol, listOf(juniorRole), author = bob) + + // the rogue admin bans bob... + banlist(alice, 0, null, bob) + + // ...while the owner concurrently bans the rogue, never naming bob + ownerBansAlice + + val r = AuthorityResolver.resolve(editions, owner) + + assertTrue(r.isBanned(alice), "the owner's ban of the rogue stands") + assertFalse(r.isBanned(bob), "and the rogue's ban of the moderator falls with them") + assertEquals(5, r.rank(bob), "the released moderator keeps their own role") + assertEquals(9, r.rank(carol), "and the promotion they authored survives with them") + } } From d15ee295d7bc8d1ce2932566fbd1c1f081a79253 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 16:45:12 +0000 Subject: [PATCH 181/227] fix: stop reposts from being bundled with the always-on service notification Android 16 force-groups notifications the app leaves loose. A group child whose summary is missing counts as ungrouped (GroupHelper. isGroupChildWithoutSummary), and with config_autoGroupAtCount at 2 it takes one such child plus one other ungrouped notification in the same shade section to form an aggregate bundle. We produced both halves. sendGroupSummary only posted the summary once two children of a group were live, so a lone repost or reaction sat there as a summary-less child; and the always-on relay service posts an ongoing, IMPORTANCE_LOW notification, which shares the Silent section with those two IMPORTANCE_LOW kinds. The system's aggregate summary inherits FLAG_ONGOING_EVENT from any child that has it, so the resulting bundle could not be swiped away without dismissing the service notification. Post our own group summary from the first child on, which keeps the group app-owned and off the system's list. It changes nothing visually: the shade hides any group with fewer than two children and renders the child on its own. That promotion is also why the summary now needs cleaning up: a promoted child is no longer "the only child in its group", so dismissing it leaves the summary behind, and a childless summary is both shown standalone by the shade and force-grouped by the system. Children now carry a delete intent that prunes it, the mark-read and inline-reply paths prune through cancelAndPrune, and the pruning ignores an id cancelled moments ago (cancel and notify are asynchronous, so activeNotifications can still list it) and leaves the platform's own aggregate summaries alone. Summaries also gain GROUP_ALERT_CHILDREN so they stay silent now that they go up alongside the first child. --- .../notifications/NotificationRelayService.kt | 10 ++ .../NotificationReplyReceiver.kt | 12 ++- .../notifications/NotificationUtils.kt | 102 ++++++++++++++++-- 3 files changed, 114 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index 3411379e4c..d90c812658 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -373,6 +373,16 @@ class NotificationRelayService : Service() { if (fresh.isNotEmpty()) lastBreakdown = fresh val breakdown = fresh.ifEmpty { lastBreakdown }.takeIf { it.isNotEmpty() } + // Deliberately left ungrouped. This notification is ongoing and IMPORTANCE_LOW, so it + // sits in the shade's Silent section next to the low-importance content kinds + // (reactions, reposts) — and Android 16 sweeps everything ungrouped in a section into + // one aggregate bundle whose summary inherits FLAG_ONGOING_EVENT from any child that + // has it, making the whole bundle un-swipeable. Giving this one a group of its own + // would not help: a group with a summary but no children, or a child with no summary, + // is force-grouped just the same. What keeps content notifications out of that bundle + // is that they always post their own group summary (see NotificationUtils), which + // leaves this the only ungrouped silent notification we post — one is below the + // threshold, so no bundle is formed and nothing gets stapled to it. return NotificationCompat .Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.always_on_notif_title)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index ddb16c72cb..635ce0a930 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelAndPrune +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelChildlessGroupSummaries import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -60,7 +62,13 @@ class NotificationReplyReceiver : BroadcastReceiver() { when (intent.action) { NotificationUtils.MARK_READ_ACTION -> { - notificationManager.cancel(notificationId) + notificationManager.cancelAndPrune(notificationId) + } + + // The user swiped the notification away. It is already gone; all that is left + // is to take its group summary with it when it was the last child. + NotificationUtils.DISMISS_ACTION -> { + notificationManager.cancelChildlessGroupSummaries(alreadyGone = notificationId) } NotificationUtils.REPLY_ACTION -> { @@ -138,7 +146,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { try { block() - notificationManager.cancel(notificationId) + notificationManager.cancelAndPrune(notificationId) } catch (e: Exception) { if (e is CancellationException) throw e Log.e("NotificationReply") { "Failed to send reply: ${e.message}" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index a1879a70f4..d7191cf468 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -70,6 +70,7 @@ object NotificationUtils { const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION" const val MARMOT_REPLY_ACTION = "com.vitorpamplona.amethyst.MARMOT_REPLY_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" + const val DISMISS_ACTION = "com.vitorpamplona.amethyst.DISMISS_ACTION" const val KEY_REPLY_TEXT = "key_reply_text" const val KEY_NOTIFICATION_ID = "key_notification_id" const val KEY_ACCOUNT_NPUB = "key_account_npub" @@ -82,6 +83,14 @@ object NotificationUtils { const val REPLY_GROUP_KEY_PREFIX = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION" private const val REPLY_SUMMARY_ID_BASE = 0x50000 + /** + * Every group key this object posts under starts with this. Used to tell our own + * summaries apart from the ones the system creates when it force-groups us (those + * live under `userId|pkg|g:Aggregate_…`), so the cleanup below never fights the + * platform over a bundle it owns. + */ + private const val OWN_GROUP_PREFIX = "com.vitorpamplona.amethyst." + // Event ids the user has just read/dismissed in-app. The enrichment path // re-posts a notification as metadata arrives; without this guard a // notification the user already dismissed would be resurrected seconds later @@ -218,6 +227,7 @@ object NotificationUtils { .setPriority(category.priority()) .setCategory(NotificationCompat.CATEGORY_SOCIAL) .setGroup(groupKey) + .setDeleteIntent(dismissIntent(applicationContext, notId)) .setAutoCancel(true) .setOnlyAlertOnce(true) .setWhen(time * 1000) @@ -335,6 +345,7 @@ object NotificationUtils { .setPriority(category.priority()) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setGroup(groupKey) + .setDeleteIntent(dismissIntent(applicationContext, notId)) .setAutoCancel(true) .setOnlyAlertOnce(true) .setWhen(time * 1000) @@ -370,6 +381,36 @@ object NotificationUtils { ) } + /** + * Fires when the user swipes this notification away (or hits "Clear all"), so the + * group summary can follow its last child out. + * + * We can't rely on the shade to take the summary with it: SystemUI hides a group + * with a single child and renders that child at the top level + * (`ShadeListBuilder.MIN_CHILDREN_FOR_GROUP`), and once promoted the child no + * longer counts as "the only child in its group", so dismissing it leaves our + * summary behind. A childless summary is not harmless — SystemUI promotes it into + * the shade on its own, and the system force-groups it + * (`GroupHelper.isGroupSummaryWithoutChildren`) into the same aggregate bundle we + * post summaries to stay out of. + */ + private fun dismissIntent( + applicationContext: Context, + notId: Int, + ): PendingIntent { + val intent = + Intent(applicationContext, NotificationReplyReceiver::class.java).apply { + action = DISMISS_ACTION + putExtra(KEY_NOTIFICATION_ID, notId) + } + return PendingIntent.getBroadcast( + applicationContext, + notId + 2, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + private fun replyRemoteInput(applicationContext: Context): RemoteInput = RemoteInput .Builder(KEY_REPLY_TEXT) @@ -549,16 +590,32 @@ object NotificationUtils { // Group summaries, dedup, dismissal // --------------------------------------------------------------------- + /** + * Posts (or refreshes) our own summary for [groupKey]. + * + * The summary goes up with the **first** child, not once a second one shows up. + * Android 16 counts a group child whose summary is missing as ungrouped + * (`GroupHelper.isGroupChildWithoutSummary`) and force-groups it into the + * package's per-section aggregate bundle, next to every other ungrouped + * notification in the same shade section. The bar is low: `config_autoGroupAtCount` + * is 2, so a single summary-less child plus one other ungrouped notification is a + * bundle. The always-on relay service is exactly that other notification — ongoing + * and IMPORTANCE_LOW, it shares the Silent section with our two IMPORTANCE_LOW + * kinds (reactions and reposts), so one lone repost would end up bundled with it. + * The bundle then refuses to swipe away, because the system's aggregate summary + * inherits FLAG_ONGOING_EVENT from any child carrying it — and the service + * notification always does. + * + * Providing the summary from the start keeps the group ours and the system leaves + * it alone. It costs nothing visually: the shade hides any group with fewer than + * two children and shows the child on its own. + */ private fun NotificationManager.sendGroupSummary( category: NotificationCategory, groupKey: String, summaryId: Int, applicationContext: Context, ) { - val activeCount = activeNotifications.count { it.notification.group == groupKey && it.id != summaryId } - - if (activeCount < 2) return - val summaryBuilder = NotificationCompat .Builder(applicationContext, category.channelId(applicationContext)) @@ -566,6 +623,9 @@ object NotificationUtils { .setColor(category.color) .setGroup(groupKey) .setGroupSummary(true) + // The children do the alerting. Without this the summary would buzz on + // its own the moment it starts going up alongside the first child. + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) .setAutoCancel(true) .setOnlyAlertOnce(true) .setStyle( @@ -604,16 +664,42 @@ object NotificationUtils { // items), so bail out before touching anything when nothing is posted for it. if (activeNotifications.none { it.id == notId }) return - cancel(notId) - cancelChildlessGroupSummaries() + cancelAndPrune(notId) } - private fun NotificationManager.cancelChildlessGroupSummaries() { + /** + * Cancels [notId] and drops the group summary it leaves behind, if it was the last + * child. Use this instead of a bare [NotificationManager.cancel] for anything we + * posted through [postStandard] / [postConversation] — every one of those is a + * group child with a summary above it. + */ + fun NotificationManager.cancelAndPrune(notId: Int) { + cancel(notId) + cancelChildlessGroupSummaries(alreadyGone = notId) + } + + /** + * Drops our summaries that no longer have any children. + * + * [alreadyGone] is the id of a notification cancelled moments ago: both + * [NotificationManager.cancel] and [NotificationManager.notify] are asynchronous, + * so [NotificationManager.activeNotifications] can still be listing it and would + * otherwise keep its summary alive forever. + * + * Only summaries under [OWN_GROUP_PREFIX] are touched. The system's own aggregate + * summaries also carry FLAG_GROUP_SUMMARY and show up in this list; cancelling one + * only makes the platform rebuild it. + */ + fun NotificationManager.cancelChildlessGroupSummaries(alreadyGone: Int? = null) { val active: Array = activeNotifications for (summary in active) { if (summary.notification.flags and Notification.FLAG_GROUP_SUMMARY == 0) continue val group = summary.notification.group ?: continue - val hasChildren = active.any { it.id != summary.id && it.notification.group == group } + if (!group.startsWith(OWN_GROUP_PREFIX)) continue + val hasChildren = + active.any { + it.id != summary.id && it.id != alreadyGone && it.notification.group == group + } if (!hasChildren) cancel(summary.id) } } From f1241e891bb46ae9bbcdbfb48a3a1a23f653f894 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 17:11:45 +0000 Subject: [PATCH 182/227] fix(quartz): compaction must carry the authority-gated head, not the chain head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from an independent review of this branch (a different model, per CONTRIBUTING-WITH-AI.md). Two were real, and the first is a regression this branch introduced. compactControlPlane picked its per-entity head with a bare structural chain walk, which is worse than the raw max-version it replaced. With no floor, foldEntity anchors at the lowest-version edition carrying no `prev` — and after a PRIOR compaction the genuine head's `prev` dangles into a trimmed epoch by design. So a forged `version = 1, prev = null` decoy outranks a real v50→v52 chain, and because nothing in this path checks a signature it became the entity's entire carried-forward state. A forged empty banlist would have erased every ban at the next Refounding. Reproduced, then fixed by selecting the owner-rooted authority-gated head — the same edition ConcordCommunityState.fold would seat, so the new epoch starts where the old one left off, and an unprivileged author cannot influence the choice at all. recoverStrandedConcordCommunities derived its new ban gate with `?.isBanned(..) == true`, which reads "not banned" when the session does not exist yet or its first fold has not landed. The sweep runs on the revision tick, so a banned member's own client would have hit that window on cold start and recovered itself — the exact bypass the gate exists to stop. It now fails closed and retries on the next sweep. Also from the review: resolve() now warns when the ban fixpoint exhausts its pass cap without settling, instead of silently returning a roster folded under a mask that no longer matches its banlist; and banGate stops lowercasing the same author three times. Two review findings are accepted rather than fixed, and recorded on the PR: the anchor tie-break picks the lowest rumor id before testing whether that candidate connects (pre-existing, and changing it is consensus-affecting), and non-owner moderators now need a resolved roster before a verb succeeds, which is the intended fail-closed trade. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- .../amethyst/model/AccountConcordActions.kt | 16 +++- .../commons/actions/ConcordActions.kt | 2 + .../commons/actions/ConcordActionsTest.kt | 1 + .../model/concord/ConcordRollbackFloorTest.kt | 6 +- .../concord/cord04Roles/AuthorityResolver.kt | 18 ++++- .../concord/cord06Rekey/ConcordRefounding.kt | 38 ++++++---- .../cord06Rekey/ConcordRefoundingTest.kt | 76 +++++++++++++++++++ .../cord06Rekey/ControlRootRotationTest.kt | 2 + 8 files changed, 138 insertions(+), 21 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index fc812f1bb8..f659e38564 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -852,6 +852,7 @@ class AccountConcordActions( recipientsXOnly = recipients, staffXOnly = staff, createdAt = TimeUtils.now(), + ownerPubKey = entry.owner, ) // 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs @@ -1123,13 +1124,24 @@ class AccountConcordActions( // walks them straight back into the epoch they were rotated out of — see A2 in // docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last // one whose Control Plane we can still fold. - val bannedHere = + // + // Fails CLOSED. `?.isBanned(..) == true` reads "not banned" for a session that does not + // exist yet or whose first fold has not landed, and this sweep runs on the revision tick + // — so a banned member's own client would have hit that window on cold start and + // recovered itself, which is precisely the bypass this gate exists to stop. No verdict + // means no recovery; the next sweep retries once the roster is known. + val authority = account.concordSessions .sessionFor(entry.id) ?.state ?.value ?.authority - ?.isBanned(account.signer.pubKey) == true + if (authority == null) { + Log.i("Concord") { "Stranded-recovery check deferred for ${entry.id}: control plane not folded yet" } + lastConcordRecoveryCheck.remove(entry.id) + continue + } + val bannedHere = authority.isBanned(account.signer.pubKey) val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index dff1143c45..e70fe18364 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -545,6 +545,7 @@ object ConcordActions { recipientsXOnly: List, staffXOnly: Set, createdAt: Long, + ownerPubKey: HexKey, ): RefoundingBuild = ConcordRefounding.build( rotatorSigner = rotatorSigner, @@ -558,6 +559,7 @@ object ConcordActions { recipientsXOnly = recipientsXOnly, staffXOnly = staffXOnly, createdAt = createdAt, + ownerPubKey = ownerPubKey, ) /** diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt index a5d8ff772d..82169a7708 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt @@ -123,6 +123,7 @@ class ConcordActionsTest { // Only the owner is staff, so only the owner's blob carries the secret. staffXOnly = setOf(owner.pubKey), createdAt = 5L, + ownerPubKey = owner.pubKey, ) val baseRekey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt index d3c1a50a75..ae06a84891 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt @@ -78,7 +78,7 @@ class ConcordRollbackFloorTest { // new epoch's plane is split and addressed by the derived signer, not the root. val newControlRoot = ByteArray(32) { 0x44 } val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot) - val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl) + val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey) val entry = ConcordCommunityListEntry( @@ -145,7 +145,7 @@ class ConcordRollbackFloorTest { val newControlRoot = ByteArray(32) { 0x44 } val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot) // Honest: compacted from the FULL prior plane, so each entity's head (metadata v1) survives. - val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl) + val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl, community.ownerPubKey) val entry = ConcordCommunityListEntry( @@ -186,7 +186,7 @@ class ConcordRollbackFloorTest { // new epoch's plane is split and addressed by the derived signer, not the root. val newControlRoot = ByteArray(32) { 0x44 } val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot) - val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl) + val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey) val entry = ConcordCommunityListEntry( 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 126eaade9b..5677e56244 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 @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.concord.cord04Roles import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Log /** * Resolves the owner-rooted authority state of a Concord community from its @@ -132,6 +133,8 @@ data class AuthorityResolver private constructor( } companion object { + private const val TAG = "ConcordAuthorityResolver" + /** The owner's rank — supreme and unremovable. No Role may claim it. */ const val OWNER_RANK = 0L @@ -205,6 +208,14 @@ data class AuthorityResolver private constructor( if (result.banned == mask) return result mask = result.banned } + // Exhausted the cap without settling. The returned roster was folded under a mask that is + // no longer the banlist beside it, so this is reported rather than swallowed — the same + // reasoning as EditionFold.LOG_GAP: an unsettled fold is either an adversarial edition set + // or a rule of ours that does not converge, and both are things a reader wants to know. + Log.w(TAG) { + "Banlist resolution did not settle in $MAX_BAN_RESOLUTION_PASSES passes for owner $ownerPubKey " + + "(${editions.size} editions, ${result.banned.size} banned): keeping the last pass" + } return result } @@ -350,9 +361,10 @@ data class AuthorityResolver private constructor( // a concurrent ban is never lost, while an on-chain unban still takes effect. val allBanlist = editions.filter { it.entityKind == ControlEntityKind.BANLIST } - fun banGate(e: ControlEdition): Boolean = - e.author.lowercase() == ownerLower || - (e.author.lowercase() !in bannedAuthors && effectivePermissionsOf(e.author.lowercase()).has(ConcordPermissions.BAN)) + fun banGate(e: ControlEdition): Boolean { + val author = e.author.lowercase() + return author == ownerLower || (author !in bannedAuthors && effectivePermissionsOf(author).has(ConcordPermissions.BAN)) + } val authorizedBanlist = allBanlist.filter(::banGate) // CORD-04 §3's rank rule binds "every action", and it names banning as its example ("an diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt index 7dc80de9cb..74fb73b594 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.concord.cord06Rekey +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition -import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey @@ -124,11 +124,12 @@ object ConcordRefounding { recipientsXOnly: List, staffXOnly: Set, createdAt: Long, + ownerPubKey: HexKey, ): RefoundingBuild { val newEpoch = rootEpoch + 1 val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot) - val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys) + val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, ownerPubKey) val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch) val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey() @@ -167,6 +168,7 @@ object ConcordRefounding { priorWraps: List, priorControlKeys: ControlPlaneKeys, newControlKeys: ControlPlaneKeys, + ownerPubKey: HexKey, ): List { // entity coordinate -> every edition we can open, paired with its verified seal. val byCoordinate = HashMap>>() @@ -177,17 +179,27 @@ object ConcordRefounding { byCoordinate.getOrPut(coord) { ArrayList() }.add(edition to opened.seal) } - // The head is the CHAIN head, not the highest version. Picking by raw version made an honest - // rotator the delivery mechanism for a disconnected stray: an edition minted at an arbitrary - // version never joins the chain, but it won this comparison and was then re-wrapped into the - // new epoch as that entity's whole history — where a fresh joiner, holding no floor, anchors - // on it as their baseline. See B1 in `docs/concord-soft-ban-audit.md`. foldEntity walks from - // genesis and keeps the fresh-joiner fallback for a head whose own `prev` dangles into an - // epoch this rotator no longer holds, which is the ordinary shape after a prior compaction. - val out = ArrayList(byCoordinate.size) - for ((_, entries) in byCoordinate) { - val head = EditionFold.foldEntity(entries.map { it.first }) ?: continue - val seal = entries.firstOrNull { it.first.rumorId == head.rumorId }?.second ?: continue + // The head to carry forward is the one every READER honors — the authority-gated head — not + // the highest version and not the bare structural chain head. + // + // Raw highest version made an honest rotator the delivery mechanism for a disconnected stray: + // an edition minted at an arbitrary version never joins the chain, but it won that comparison + // and was re-wrapped into the new epoch as the entity's whole history (B1 in + // `docs/concord-soft-ban-audit.md`). The bare chain walk is *worse*, and this is the trap: + // with no floor it anchors at the lowest-version edition carrying no `prev`, and after a prior + // compaction the real head's `prev` dangles by design — so a forged `version = 1, prev = null` + // decoy outranks a genuine v50→v52 chain and, because nothing here checks signatures, becomes + // the entity's entire carried-forward state. A forged empty banlist would erase every ban. + // + // Gating on the owner-rooted roster is the only selection that cannot be gamed by an + // unprivileged author, and it is exactly what ConcordCommunityState.fold would seat, so the + // compacted epoch starts where the previous one left off. + val editions = byCoordinate.values.flatten() + val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, ownerPubKey) + val out = ArrayList(honored.size) + for ((_, floor) in honored) { + val head = floor.known ?: continue + val seal = editions.firstOrNull { it.first.rumorId == head.rumorId }?.second ?: continue out.add(ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt)) } return out diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt index 06778c4c71..dbc6e9c81d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt @@ -74,6 +74,7 @@ class ConcordRefoundingTest { recipientsXOnly = listOf(alice.pubKey, bob.pubKey), staffXOnly = setOf(owner.pubKey), createdAt = now, + ownerPubKey = owner.pubKey, ) assertEquals(community.rootEpoch + 1, build.newEpoch) @@ -113,6 +114,7 @@ class ConcordRefoundingTest { recipientsXOnly = listOf(alice.pubKey), staffXOnly = setOf(owner.pubKey), createdAt = now, + ownerPubKey = owner.pubKey, ) val newControl = build.newControlKeys @@ -184,6 +186,7 @@ class ConcordRefoundingTest { recipientsXOnly = listOf(alice.pubKey), staffXOnly = setOf(owner.pubKey), createdAt = now, + ownerPubKey = owner.pubKey, ) val newControl = build.newControlKeys @@ -223,6 +226,7 @@ class ConcordRefoundingTest { recipientsXOnly = listOf(alice.pubKey), staffXOnly = setOf(owner.pubKey), createdAt = now, + ownerPubKey = owner.pubKey, ) val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch) @@ -230,4 +234,76 @@ class ConcordRefoundingTest { val wrongRoot = ByteArray(32) { 0x11 } assertNull(ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, community.communityId, wrongRoot, community.rootEpoch)) } + + @Test + fun compactionRefusesAForgedGenesisAndCarriesTheAuthorizedHead() = + runTest { + // A compaction re-wraps ONE edition per entity and nothing downstream re-checks the + // choice, so how that edition is picked is a security decision, not a detail. + // + // Raw highest-version lets a stray at an arbitrary version through. But the bare + // structural chain walk is worse: with no floor it anchors at the lowest-version edition + // carrying no `prev`, and after a PRIOR compaction the real head's `prev` dangles into a + // trimmed epoch by design — so a forged `version = 1, prev = null` decoy outranks a + // genuine v50→v52 chain, and becomes the entity's entire carried-forward state. A forged + // empty banlist would erase every ban that way. Only the owner-rooted gate is safe. + val community = ConcordCommunityFactory.create(owner, "Test", now) + val communityId = community.communityId + val control = community.controlPlane + + // The metadata entity, already compacted once: its head chains from an epoch we no longer hold. + val danglingPrev = ByteArray(32) { 0x7F } + val realHead = + ConcordStreamEnvelope.wrap( + ControlEditionBuilder.rumor( + owner.pubKey, + ControlEntityKind.METADATA, + communityId, + 50, + danglingPrev, + ConcordJson.instance.encodeToString(MetadataEntity.serializer(), MetadataEntity(name = "Real")), + now, + null, + ), + control, + owner, + encrypted = false, + createdAt = now, + ) + + // carol holds nothing at all and mints a genesis-shaped decoy at version 1. + val forged = + ConcordStreamEnvelope.wrap( + ControlEditionBuilder.rumor( + carol.pubKey, + ControlEntityKind.METADATA, + communityId, + 1, + null, + ConcordJson.instance.encodeToString(MetadataEntity.serializer(), MetadataEntity(name = "PWNED")), + now, + null, + ), + control, + carol, + encrypted = false, + createdAt = now, + ) + + val newEpoch = community.rootEpoch + 1 + val newControl = + com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys + .forStaff(newRoot, communityId, newEpoch, newControlRoot) + val compacted = ConcordRefounding.compactControlPlane(listOf(realHead, forged), control, newControl, owner.pubKey) + + val carried = + compacted + .mapNotNull { ConcordStreamEnvelope.openOrNull(it, newControl) } + .mapNotNull { ControlEdition.fromRumor(it.rumor) } + .filter { it.entityKind == ControlEntityKind.METADATA } + + assertEquals(1, carried.size, "one metadata edition carried forward") + assertEquals(50, carried.single().version, "the owner's real head, not the forged genesis") + assertEquals("Real", ConcordJson.decodeOrNull(carried.single().content)?.name) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt index bcd5eec1e5..7a4440e1a0 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt @@ -109,6 +109,7 @@ class ControlRootRotationTest { recipientsXOnly = listOf(owner.pubKey, moderator.pubKey, member.pubKey), staffXOnly = setOf(owner.pubKey, moderator.pubKey), createdAt = now, + ownerPubKey = owner.pubKey, ) val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch) @@ -149,6 +150,7 @@ class ControlRootRotationTest { recipientsXOnly = listOf(owner.pubKey, member.pubKey), staffXOnly = setOf(owner.pubKey), createdAt = now, + ownerPubKey = owner.pubKey, ) // The rotator's own view writes; a member's view of the same epoch only reads. From 9bfec38697533cd1e02183227d24be7dd2fb5a24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 17:24:46 +0000 Subject: [PATCH 183/227] fix: bring the embed keyboard back on a tap in an already-focused field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning to a warm embedded tab left the page's field focused but with no way to get the keyboard back: the surface just moves off-screen, so the page never fires another focus event, and `ime.focus` was the host's ONLY keyboard-raising signal. A tap on the still-focused field only produced `ime.carettap` (which re-shows the insertion handle), so the keyboard stayed down until the user tapped away and tapped back. Same dead end after dismissing the keyboard with back, without leaving the tab at all. Keep the page focus — blurring it on tab-away would fire the page's own blur handlers (validation, autocomplete dismissal, submit-on-blur) for a switch the user never made inside the page, and lose the caret the user came back to — and give the host the two signals it was missing: - `ime.refocus` (page -> host), the focus payload for a field that is already focused, sent on every tap inside it. The host re-takes the field and raises the keyboard; when it is still the field's mirror it only re-raises, so a live composing region survives. - `ime.resync` (host -> page), sent when a tab becomes active again, answered with the same payload. The keyboard comes back only for a tab that was left with it up, the way Android restores a window's IME state; a tab whose keyboard the user had dismissed comes back with the caret in place and the page unobstructed. Also folds the two identical private `parseImeEvent` copies in the browser and napplet controllers into one shared parser next to the bridge. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS --- .../browser/EmbeddedWebAppController.kt | 36 +---------- .../loggedIn/embed/EmbeddedImeBridge.kt | 59 +++++++++++++++++++ .../screen/loggedIn/embed/EmbeddedTabHost.kt | 21 +++++++ .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 27 +++++++++ .../ui/screen/loggedIn/embed/RemoteImeView.kt | 40 +++++++++++-- .../favorites/EmbeddedNostrAppController.kt | 36 +---------- .../composeResources/files/napplet/shim.js | 23 +++++++- 7 files changed, 167 insertions(+), 75 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 2e615a33d2..82df6800dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -49,8 +49,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProb import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame -import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseSelectionGeometry -import org.json.JSONObject +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent import java.util.concurrent.atomic.AtomicLong /** @@ -337,39 +336,6 @@ class EmbeddedWebAppController( putLong(NappletBrowserContract.KEY_MAG_REQ_T, SystemClock.elapsedRealtimeNanos()) } - private fun parseImeEvent(payload: String): ImeEvent? { - val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null - return when (o.optString("type")) { - "ime.focus" -> - ImeEvent.Focus( - inputType = o.optString("inputType", "text"), - enterKeyHint = o.optString("enterKeyHint", ""), - multiline = o.optBoolean("multiline", false), - text = o.optString("text", ""), - selStart = o.optInt("selStart", 0), - selEnd = o.optInt("selEnd", 0), - geometry = parseSelectionGeometry(o.optJSONObject("geom")), - ) - "ime.blur" -> ImeEvent.Blur - "ime.state" -> - ImeEvent.State( - text = o.optString("text", ""), - selStart = o.optInt("selStart", 0), - selEnd = o.optInt("selEnd", 0), - geometry = parseSelectionGeometry(o.optJSONObject("geom")), - ) - "ime.pagesel" -> - ImeEvent.PageSelection( - active = o.optBoolean("active", false), - text = o.optString("text", ""), - geometry = parseSelectionGeometry(o.optJSONObject("geom")), - ) - "ime.scroll" -> ImeEvent.Scroll(active = o.optBoolean("active", false)) - "ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.optJSONObject("geom"))) - else -> null - } - } - private inline fun send( what: Int, crossinline block: Bundle.() -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt index d0b55dc108..469d01ae87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt @@ -56,6 +56,54 @@ interface EmbeddedImeBridge { fun sendImeOp(json: String) } +/** + * Asks the page to re-announce its focused field (as an [ImeEvent.ReFocus]) — sent when a tab becomes the + * active one again. A warm tab keeps the page's DOM focus while it sits off-screen, so returning to it fires + * no `focusin` and the host would otherwise never learn there is a field to put the keyboard back on. + */ +fun EmbeddedImeBridge.requestImeResync() = sendImeOp(JSONObject().put("type", "ime.resync").toString()) + +/** Parses one page → host `ime.*` envelope into an [ImeEvent], or null for anything unrecognized. */ +fun parseImeEvent(payload: String): ImeEvent? { + val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null + return when (o.optString("type")) { + "ime.focus" -> parseFocus(o) + "ime.refocus" -> + ImeEvent.ReFocus( + focus = parseFocus(o), + userAsked = o.optBoolean("raise", false), + ) + "ime.blur" -> ImeEvent.Blur + "ime.state" -> + ImeEvent.State( + text = o.optString("text", ""), + selStart = o.optInt("selStart", 0), + selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + "ime.pagesel" -> + ImeEvent.PageSelection( + active = o.optBoolean("active", false), + text = o.optString("text", ""), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + "ime.scroll" -> ImeEvent.Scroll(active = o.optBoolean("active", false)) + "ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.optJSONObject("geom"))) + else -> null + } +} + +private fun parseFocus(o: JSONObject) = + ImeEvent.Focus( + inputType = o.optString("inputType", "text"), + enterKeyHint = o.optString("enterKeyHint", ""), + multiline = o.optBoolean("multiline", false), + text = o.optString("text", ""), + selStart = o.optInt("selStart", 0), + selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + /** What the focused page field reports up to the host keyboard. */ sealed interface ImeEvent { /** A field took focus; carries enough to configure the keyboard and seed the editing buffer. */ @@ -69,6 +117,17 @@ sealed interface ImeEvent { val geometry: SelectionGeometry? = null, ) : ImeEvent + /** + * A field that is **already** focused re-announced itself: the user tapped inside it ([userAsked]), or the + * tab was re-activated and answered the host's resync. Distinct from [Focus] because page focus never + * changed — the host must not restart the input on a field it is already hosting (that would kill a live + * composing region mid-word); it only re-takes the keyboard when it isn't hosting the field anymore. + */ + data class ReFocus( + val focus: Focus, + val userAsked: Boolean, + ) : ImeEvent + /** The field lost focus — dismiss the keyboard. */ data object Blur : ImeEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt index 7246b19577..9f4bf544d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt @@ -149,9 +149,27 @@ object EmbeddedTabHost { contentBounds = bounds } + // Tabs whose soft keyboard was up at the moment the user left them. A warm tab keeps its page focus while + // it sits off-screen, so coming back is a *resume*: the keyboard returns only for the tabs that had one, + // the way Android restores a window's IME state. A tab the user deliberately typed on and then dismissed + // the keyboard for comes back with the caret still in the field but the page unobstructed. + private val keyboardUpOnLeave = mutableSetOf() + + /** Records whether the soft keyboard was up as [id] stops being the active tab. */ + fun noteKeyboardOnLeave( + id: String, + wasUp: Boolean, + ) { + if (wasUp) keyboardUpOnLeave.add(id) else keyboardUpOnLeave.remove(id) + } + + /** Consumes the "restore the keyboard" mark for [id] (a restore happens at most once per leave). */ + fun takeKeyboardRestore(id: String): Boolean = keyboardUpOnLeave.remove(id) + fun evict(id: String) { val w = warm.firstOrNull { it.id == id } ?: return if (activeId == id) activeId = null + keyboardUpOnLeave.remove(id) warm.remove(w) w.controller.teardown() } @@ -165,6 +183,7 @@ object EmbeddedTabHost { fun evictAll() { activeId = null + keyboardUpOnLeave.clear() val copy = warm.toList() warm.clear() copy.forEach { it.controller.teardown() } @@ -177,6 +196,8 @@ object EmbeddedTabHost { * its screen re-acquires — the user just sees the current tab reload, not a blanked-out surface. */ fun rebuildAll() { + // Every page is about to be rebuilt from scratch, so no field survives to restore a keyboard onto. + keyboardUpOnLeave.clear() val copy = warm.toList() warm.clear() copy.forEach { it.controller.teardown() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index 77021fa742..93ee6131a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -348,7 +348,16 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // the selection). All the show/hide state lives in one [SelectionUiState] so the rules — toolbar hides // while dragging or scrolling, handles hide while scrolling — are expressed in one place. val sel = remember { SelectionUiState() } + // Read at dispose time to record whether the keyboard was up as the user left this tab (see + // [EmbeddedTabHost.noteKeyboardOnLeave]). The dispose runs before anything hides the IME — our own + // onPageBlur below is what takes it down — so this still reads the pre-switch state. + val keyboardUp = rememberUpdatedState(imeBottomPx > 0) DisposableEffect(imeBridge) { + val boundId = activeId + // A warm tab keeps its page focus while parked, so returning to it fires no focus event: ask the + // page to re-announce whatever field is still focused. Restores the mirror (so a tap can raise the + // keyboard) and, when the user left mid-typing, brings the keyboard straight back. + var pendingRestore = boundId != null && EmbeddedTabHost.takeKeyboardRestore(boundId) imeView.bind(imeBridge) imeView.onRangeSelectionChanged = { sel.onFieldRangeToggle(it) } imeView.onEdited = { sel.onEdited() } @@ -364,6 +373,15 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { sel.scrolling = false sel.onFieldGeometry(event.geometry, event.text.isNotEmpty()) } + is ImeEvent.ReFocus -> { + // Raise the keyboard when the user asked for it (a tap in the field) or when this tab + // was left with the keyboard up; either way re-take the field so typing works again. + imeView.onPageReFocus(event.focus, event.userAsked || pendingRestore) + pendingRestore = false + // Only a tap re-arms the caret handle. A silent tab-return resync must not pop one + // up on its own — native shows nothing until the user touches the field. + if (event.userAsked) sel.onFieldGeometry(event.focus.geometry, event.focus.text.isNotEmpty()) + } ImeEvent.Blur -> { imeView.onPageBlur() sel.onBlur() @@ -377,11 +395,20 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { is ImeEvent.CaretTap -> sel.onCaretTap(event.geometry) } } + // Posted, not sent inline: the session's own `active` effect resumes a paused (parked) WebView in + // this same effect pass, and a message delivered to a still-paused page can be lost. One turn of + // the main looper later, the resume is already on its way over the same (FIFO) channel. + imeView.post { imeBridge?.requestImeResync() } onDispose { imeBridge?.onImeEvent = null imeView.onRangeSelectionChanged = null imeView.onEdited = null sel.reset() + // Remember whether the keyboard was up BEFORE onPageBlur takes it down, so returning to this + // tab resumes exactly the state it was left in. The page keeps its focus (and caret) either + // way: blurring it here would fire the page's own blur handlers — validation, autocomplete + // dismissal, submit-on-blur — for a switch the user never made inside the page. + if (boundId != null) EmbeddedTabHost.noteKeyboardOnLeave(boundId, keyboardUp.value) imeView.onPageBlur() imeView.bind(null) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt index 2feb4901e8..6efed253c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt @@ -152,9 +152,15 @@ class RemoteImeView( fun selectAllText(): Boolean = onTextContextMenuItem(android.R.id.selectAll) - /** A page field focused: configure the keyboard, seed the buffer, and raise the IME. */ - @Suppress("DEPRECATION") // InputMethodManager.SHOW_IMPLICIT is deprecated; no equivalent flag on the newer API. - fun onPageFocus(focus: ImeEvent.Focus) { + /** + * A page field focused: configure the keyboard, seed the buffer, and — unless [raiseKeyboard] is false — + * raise the IME. A restored tab passes false: its field is focused again in this mirror (so a tap can put + * the keyboard straight back) without a keyboard the user had dismissed popping up over the page. + */ + fun onPageFocus( + focus: ImeEvent.Focus, + raiseKeyboard: Boolean = true, + ) { configureFor(focus) // Focus the EditText BEFORE seeding text/selection. An EditText jumps its caret to the end when it // gains focus; if we seed first, that end-position then overrides the seed and gets shipped to the @@ -164,7 +170,33 @@ class RemoteImeView( requestFocus() imm.restartInput(this) applyRemote(focus.text, focus.selStart, focus.selEnd) - // Post the show so it runs after focus/attachment has settled (showSoftInput can no-op otherwise). + if (raiseKeyboard) showKeyboard() + } + + /** + * The page re-announced a field that is **already** focused there: the user tapped inside it, or the tab + * came back on screen and answered our resync. + * + * Page focus never moved, so when this view is still the field's mirror there is nothing to re-seed — + * restarting the input would drop a live composing region mid-word — and the only thing left to do is put + * the keyboard back if it was dismissed. When the mirror was released (a tab switch clears it, see + * [onPageBlur]) this is the ONLY way back: the page will never fire another focus event for a field it + * never blurred, so re-take it here from the re-announced state. + */ + fun onPageReFocus( + focus: ImeEvent.Focus, + raiseKeyboard: Boolean, + ) { + if (hasFocus()) { + if (raiseKeyboard) showKeyboard() + } else { + onPageFocus(focus, raiseKeyboard) + } + } + + /** Post the show so it runs after focus/attachment has settled (showSoftInput can no-op otherwise). */ + @Suppress("DEPRECATION") // InputMethodManager.SHOW_IMPLICIT is deprecated; no equivalent flag on the newer API. + private fun showKeyboard() { post { if (hasFocus()) imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt index 9e6ea3531c..ec509755da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt @@ -47,8 +47,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProb import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame -import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseSelectionGeometry -import org.json.JSONObject +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent import java.util.concurrent.atomic.AtomicLong /** @@ -282,39 +281,6 @@ class EmbeddedNostrAppController( putLong(NappletEmbedContract.KEY_MAG_REQ_T, SystemClock.elapsedRealtimeNanos()) } - private fun parseImeEvent(payload: String): ImeEvent? { - val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null - return when (o.optString("type")) { - "ime.focus" -> - ImeEvent.Focus( - inputType = o.optString("inputType", "text"), - enterKeyHint = o.optString("enterKeyHint", ""), - multiline = o.optBoolean("multiline", false), - text = o.optString("text", ""), - selStart = o.optInt("selStart", 0), - selEnd = o.optInt("selEnd", 0), - geometry = parseSelectionGeometry(o.optJSONObject("geom")), - ) - "ime.blur" -> ImeEvent.Blur - "ime.state" -> - ImeEvent.State( - text = o.optString("text", ""), - selStart = o.optInt("selStart", 0), - selEnd = o.optInt("selEnd", 0), - geometry = parseSelectionGeometry(o.optJSONObject("geom")), - ) - "ime.pagesel" -> - ImeEvent.PageSelection( - active = o.optBoolean("active", false), - text = o.optString("text", ""), - geometry = parseSelectionGeometry(o.optJSONObject("geom")), - ) - "ime.scroll" -> ImeEvent.Scroll(active = o.optBoolean("active", false)) - "ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.optJSONObject("geom"))) - else -> null - } - } - fun back() = send(NappletEmbedContract.MSG_BACK) fun reload() = send(NappletEmbedContract.MSG_RELOAD) diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js index 89e256a2f4..8e52213e68 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shim.js +++ b/commons/src/commonMain/composeResources/files/napplet/shim.js @@ -378,6 +378,15 @@ return { type:'ime.focus', inputType: inputType, enterKeyHint: (n.enterKeyHint || ''), multiline: multiline, text: valOf(n), selStart: sel[0], selEnd: sel[1], geom: fieldGeom(n) }; } + // Same payload as `ime.focus`, but for a field that is ALREADY focused — the host uses it to (re-)take the + // keyboard without treating it as a fresh focus. `raise` marks the cases the user explicitly asked for the + // keyboard (a tap in the field); a resync reply leaves the decision to the host. + function refocusInfo(n, raise){ + var info = focusInfo(n); + info.type = 'ime.refocus'; + info.raise = !!raise; + return info; + } // Last selection we either applied (applyState) or already reported, so the asynchronous // selectionchange our own setSel triggers doesn't echo back to the host as a fresh edit. var lastSel = null; @@ -464,7 +473,16 @@ if (s[0] !== s[1] && !sameSel(s, lastSel)) reportState(); // report the word only if not already sent }, true); document.addEventListener('click', function(e){ - if (!el || isCE(el) || e.target !== el) return; + if (!el) return; + // A tap inside the ALREADY-focused editable fires no `focusin`, so the host — whose only keyboard-raising + // signal used to be `ime.focus` — never learned that the user wants the keyboard back after dismissing it + // (or after a tab switch dropped it). Re-announce the field on every such tap: the host raises the + // keyboard and, if it isn't hosting this field anymore, re-seeds its mirror from this payload. + // Sent on the focusing tap too (which the host answers with a no-op, since it is already hosting the + // field): telling the two apart here would take a focusin-to-click timing guess, and a guess that comes + // in late swallows a real "give me the keyboard back" tap. + if (e.target === el || (el.contains && el.contains(e.target))) send(refocusInfo(el, true)); + if (isCE(el) || e.target !== el) return; var sel = selOf(el); if (sel[0] !== sel[1]) { // Tap landed on a selection. Defer the collapse: if a dblclick follows (within the tap window) it @@ -708,6 +726,9 @@ document.addEventListener('scroll', onAnyScroll, true); // capture: any scroller, not just the document window.__nappletImeHandle = function(msg){ + // The tab came back on screen. Focus never left the page (the surface just moved off-screen), so no + // `focusin` will fire — re-announce the still-focused field so the host can re-take the keyboard. + if (msg.type === 'ime.resync') { if (el) send(refocusInfo(el, false)); return; } if (msg.type === 'ime.set') applyState(msg); else if (msg.type === 'ime.action') enter(el); else if (msg.type === 'ime.pageextend') pageExtend(msg.edge, msg.x, msg.y); From 59994a7847091d354f80d7a2003709587dc065d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 17:32:42 +0000 Subject: [PATCH 184/227] fix: mark a notification dismissed on swipe, mark-read and inline reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 25s enrichment window re-posts a notification every time metadata for it lands, and postStandard/postConversation only skip that when NotificationUtils.wasDismissed says the user is done with the event. Only one path ever recorded that: reading the note in-app. Swiping the notification away, hitting "mark as read", or replying from the tray all just cancelled the notification id, so the enricher happily put it back seconds later — and kept a relay subscription and a wakelock open for it until the window elapsed. Every notification already carries a delete intent and its actions target the same receiver, so thread the event id through them and mark it dismissed there. Replies mark it only once the send succeeds, leaving a failed send free to enrich and retry. Also, in the same area: - Pin the group summary's timestamp to the child's event time. It defaulted to "now", and the summary is re-posted on every enrichment re-render, so the group kept re-sorting in the shade while the user was reading it. - Make the childless-summary scan a single pass. Now that every child ships with a summary the active list is about twice as long and the pairwise scan grew four-fold. Deciding what is a child by the summary flag instead of by comparing ids also fixes the case where a child's id equals the summary's. --- .../NotificationReplyReceiver.kt | 24 +++++- .../notifications/NotificationUtils.kt | 78 ++++++++++++++----- 2 files changed, 78 insertions(+), 24 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index 635ce0a930..26b2930f02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -56,6 +56,22 @@ class NotificationReplyReceiver : BroadcastReceiver() { intent: Intent, ) { val notificationId = intent.getIntExtra(NotificationUtils.KEY_NOTIFICATION_ID, 0) + + // Whatever the action, the user is done with this notification, so record it before + // doing anything else. An enrichment window may still be open on the event (up to + // 25s from the first post), and it re-posts the notification every time metadata + // lands — without this the notification the user just dealt with comes back, and the + // enricher keeps a relay subscription and a wakelock alive for it until the window + // elapses. Replies mark it after the send succeeds instead, so a failure leaves the + // notification to enrich and retry. + val eventId = intent.getStringExtra(NotificationUtils.KEY_EVENT_ID) + if (intent.action != NotificationUtils.REPLY_ACTION && + intent.action != NotificationUtils.PUBLIC_REPLY_ACTION && + intent.action != NotificationUtils.MARMOT_REPLY_ACTION + ) { + eventId?.let { NotificationUtils.markDismissed(it) } + } + val notificationManager = ContextCompat.getSystemService(context, NotificationManager::class.java) as NotificationManager @@ -86,7 +102,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { if (members.isEmpty()) return - runOnRelay(notificationManager, notificationId) { + runOnRelay(notificationManager, notificationId, eventId) { sendReply(accountNpub, members, replyText) } } @@ -103,7 +119,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return val targetEventId = intent.getStringExtra(NotificationUtils.KEY_TARGET_EVENT_ID) ?: return - runOnRelay(notificationManager, notificationId) { + runOnRelay(notificationManager, notificationId, eventId) { sendPublicReply(accountNpub, targetEventId, replyText) } } @@ -122,7 +138,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID) val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR) - runOnRelay(notificationManager, notificationId) { + runOnRelay(notificationManager, notificationId, eventId) { sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText) } } @@ -132,6 +148,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { private fun runOnRelay( notificationManager: NotificationManager, notificationId: Int, + eventId: String?, block: suspend () -> Unit, ) { val pendingResult = goAsync() @@ -146,6 +163,7 @@ class NotificationReplyReceiver : BroadcastReceiver() { try { block() + eventId?.let { NotificationUtils.markDismissed(it) } notificationManager.cancelAndPrune(notificationId) } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index d7191cf468..6d4791386c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -73,6 +73,13 @@ object NotificationUtils { const val DISMISS_ACTION = "com.vitorpamplona.amethyst.DISMISS_ACTION" const val KEY_REPLY_TEXT = "key_reply_text" const val KEY_NOTIFICATION_ID = "key_notification_id" + + /** + * Hex id of the event this notification was posted for, carried on every action + * and on the delete intent so the receiver can mark it dismissed. Distinct from + * [KEY_TARGET_EVENT_ID], which is the note an inline reply is addressed to. + */ + const val KEY_EVENT_ID = "key_event_id" const val KEY_ACCOUNT_NPUB = "key_account_npub" const val KEY_CHATROOM_MEMBERS = "key_chatroom_members" const val KEY_TARGET_EVENT_ID = "key_target_event_id" @@ -91,16 +98,19 @@ object NotificationUtils { */ private const val OWN_GROUP_PREFIX = "com.vitorpamplona.amethyst." - // Event ids the user has just read/dismissed in-app. The enrichment path - // re-posts a notification as metadata arrives; without this guard a - // notification the user already dismissed would be resurrected seconds later - // when its author's kind:0 lands. Keyed by the event id string (not the - // hashCode) so distinct events can't collide. Entries self-expire after a - // window comfortably longer than the 25s enrichment window. + // Event ids the user is done with. The enrichment path re-posts a notification + // as metadata arrives; without this guard a notification the user already got + // rid of would be resurrected seconds later when its author's kind:0 lands, and + // the enricher would go on holding a relay window and a wakelock open for it. + // Every way a user can be done with a notification has to record here — reading + // the event in-app, swiping the notification away, "mark as read", and replying + // inline — or that path leaks the resurrection. Keyed by the event id string + // (not the hashCode) so distinct events can't collide. Entries self-expire after + // a window comfortably longer than the 25s enrichment window. private const val DISMISS_GUARD_MS = 90_000L private val recentlyDismissed = ConcurrentHashMap() - private fun markDismissed(eventId: String) { + fun markDismissed(eventId: String) { val now = SystemClock.elapsedRealtime() recentlyDismissed[eventId] = now + DISMISS_GUARD_MS if (recentlyDismissed.size > 256) { @@ -227,7 +237,7 @@ object NotificationUtils { .setPriority(category.priority()) .setCategory(NotificationCompat.CATEGORY_SOCIAL) .setGroup(groupKey) - .setDeleteIntent(dismissIntent(applicationContext, notId)) + .setDeleteIntent(dismissIntent(applicationContext, notId, id)) .setAutoCancel(true) .setOnlyAlertOnce(true) .setWhen(time * 1000) @@ -246,11 +256,11 @@ object NotificationUtils { } if (inlineReply != null) { - builder.addAction(publicReplyAction(applicationContext, notId, inlineReply)) + builder.addAction(publicReplyAction(applicationContext, notId, id, inlineReply)) } notify(notId, builder.build()) - sendGroupSummary(category, groupKey, summaryId, applicationContext) + sendGroupSummary(category, groupKey, summaryId, time, applicationContext) } // --------------------------------------------------------------------- @@ -345,21 +355,21 @@ object NotificationUtils { .setPriority(category.priority()) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setGroup(groupKey) - .setDeleteIntent(dismissIntent(applicationContext, notId)) + .setDeleteIntent(dismissIntent(applicationContext, notId, id)) .setAutoCancel(true) .setOnlyAlertOnce(true) .setWhen(time * 1000) when (replyAction) { - is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, replyAction)) - is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, replyAction)) - null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, it)) } + is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, id, replyAction)) + is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, id, replyAction)) + null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, id, it)) } } - if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId)) + if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId, id)) notify(notId, builder.build()) - sendGroupSummary(category, groupKey, summaryId, applicationContext) + sendGroupSummary(category, groupKey, summaryId, time, applicationContext) } // --------------------------------------------------------------------- @@ -397,11 +407,13 @@ object NotificationUtils { private fun dismissIntent( applicationContext: Context, notId: Int, + eventId: String, ): PendingIntent { val intent = Intent(applicationContext, NotificationReplyReceiver::class.java).apply { action = DISMISS_ACTION putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) } return PendingIntent.getBroadcast( applicationContext, @@ -440,12 +452,14 @@ object NotificationUtils { private fun dmReplyAction( applicationContext: Context, notId: Int, + eventId: String, action: ReplyAction.Dm, ): NotificationCompat.Action { val intent = Intent(applicationContext, NotificationReplyReceiver::class.java).apply { this.action = REPLY_ACTION putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) putExtra(KEY_ACCOUNT_NPUB, action.accountNpub) putExtra(KEY_CHATROOM_MEMBERS, action.chatroomMembers) } @@ -455,12 +469,14 @@ object NotificationUtils { private fun marmotReplyAction( applicationContext: Context, notId: Int, + eventId: String, action: ReplyAction.Marmot, ): NotificationCompat.Action { val intent = Intent(applicationContext, NotificationReplyReceiver::class.java).apply { this.action = MARMOT_REPLY_ACTION putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) putExtra(KEY_ACCOUNT_NPUB, action.accountNpub) putExtra(KEY_MARMOT_GROUP_ID, action.nostrGroupId) action.replyToInnerEventId?.let { putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, it) } @@ -472,12 +488,14 @@ object NotificationUtils { private fun publicReplyAction( applicationContext: Context, notId: Int, + eventId: String, target: InlineReplyTarget, ): NotificationCompat.Action { val intent = Intent(applicationContext, NotificationReplyReceiver::class.java).apply { action = PUBLIC_REPLY_ACTION putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) putExtra(KEY_ACCOUNT_NPUB, target.accountNpub) putExtra(KEY_TARGET_EVENT_ID, target.targetEventId) } @@ -487,11 +505,13 @@ object NotificationUtils { private fun markReadAction( applicationContext: Context, notId: Int, + eventId: String, ): NotificationCompat.Action { val markReadIntent = Intent(applicationContext, NotificationReplyReceiver::class.java).apply { action = MARK_READ_ACTION putExtra(KEY_NOTIFICATION_ID, notId) + putExtra(KEY_EVENT_ID, eventId) } val markReadPendingIntent = PendingIntent.getBroadcast( @@ -614,6 +634,7 @@ object NotificationUtils { category: NotificationCategory, groupKey: String, summaryId: Int, + time: Long, applicationContext: Context, ) { val summaryBuilder = @@ -628,6 +649,11 @@ object NotificationUtils { .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) .setAutoCancel(true) .setOnlyAlertOnce(true) + // Pinned to the child's event time rather than left to default to "now". + // The summary is re-posted on every one of the enrichment path's re-renders, + // and a fresh timestamp each time would keep re-sorting the group in the + // shade while the user is looking at it. + .setWhen(time * 1000) .setStyle( NotificationCompat .InboxStyle() @@ -692,15 +718,25 @@ object NotificationUtils { */ fun NotificationManager.cancelChildlessGroupSummaries(alreadyGone: Int? = null) { val active: Array = activeNotifications + + // Collect the groups that still have a child in one pass, then cancel the + // summaries not in that set. Every child now ships with a summary, so this list + // is about twice as long as it used to be and the pairwise scan it replaces grew + // four-fold. Membership is decided by the summary flag rather than by comparing + // ids, which is also what makes it correct when a child's id happens to equal the + // summary's. + val groupsWithChildren = HashSet(active.size) + for (child in active) { + if (child.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0) continue + if (child.id == alreadyGone) continue + child.notification.group?.let { groupsWithChildren.add(it) } + } + for (summary in active) { if (summary.notification.flags and Notification.FLAG_GROUP_SUMMARY == 0) continue val group = summary.notification.group ?: continue if (!group.startsWith(OWN_GROUP_PREFIX)) continue - val hasChildren = - active.any { - it.id != summary.id && it.id != alreadyGone && it.notification.group == group - } - if (!hasChildren) cancel(summary.id) + if (group !in groupsWithChildren) cancel(summary.id) } } From a0f4328f2a34aa17164f2c6d3604de7a3f02d067 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 19:38:50 +0000 Subject: [PATCH 185/227] fix: keep the embed re-focus ping payload-free and re-seed via resync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the previous commit found three problems in it. The `ime.refocus` sent on every tap carried the field's full editing state, so a tap in a long textarea put 40KB on the wire per tap (145B before), and its geometry made the page mirror the whole field into a hidden div and force a synchronous layout — measured at ~3.5ms per tap on a 40k-char textarea, doubling the cost of every tap in a field. Split the message in two: taps ring a payload-free `ime.wantkb` doorbell, and the host answers it with the `ime.resync` it already had — but only when it no longer mirrors the field, so the common "keyboard was dismissed, tap to get it back" case is one small message and no round trip. Per-tap payload is now constant (~180B) and the per-tap CPU cost is back at parity with before the fix. The "am I already hosting this field" check read `hasFocus()` alone, so a focus that lingers past `clearFocus()` (a lone focusable in the hierarchy can take it straight back) would have skipped the re-seed and shipped the previous tab's text to the page on the first keystroke. Track mirroring explicitly. The keyboard-restore mark was armed by any keyboard up at tab-switch time, including one belonging to the browser's own address bar; require that the mirror actually holds it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS --- .../loggedIn/embed/EmbeddedImeBridge.kt | 35 ++++++++++------ .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 35 ++++++++++++---- .../ui/screen/loggedIn/embed/RemoteImeView.kt | 35 ++++++++++++---- .../composeResources/files/napplet/shim.js | 42 +++++++++++-------- 4 files changed, 100 insertions(+), 47 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt index 469d01ae87..c49a87b2ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt @@ -57,9 +57,10 @@ interface EmbeddedImeBridge { } /** - * Asks the page to re-announce its focused field (as an [ImeEvent.ReFocus]) — sent when a tab becomes the - * active one again. A warm tab keeps the page's DOM focus while it sits off-screen, so returning to it fires - * no `focusin` and the host would otherwise never learn there is a field to put the keyboard back on. + * Asks the page to re-announce its focused field (as an [ImeEvent.ReFocus]). A warm tab keeps the page's DOM + * focus while it sits off-screen, so no `focusin` ever fires for it again and this is the only way the host + * learns there is a field to put the keyboard back on. Sent when a tab becomes the active one again, and when + * an [ImeEvent.WantKeyboard] tap arrives for a field this host no longer mirrors. */ fun EmbeddedImeBridge.requestImeResync() = sendImeOp(JSONObject().put("type", "ime.resync").toString()) @@ -68,11 +69,8 @@ fun parseImeEvent(payload: String): ImeEvent? { val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null return when (o.optString("type")) { "ime.focus" -> parseFocus(o) - "ime.refocus" -> - ImeEvent.ReFocus( - focus = parseFocus(o), - userAsked = o.optBoolean("raise", false), - ) + "ime.wantkb" -> ImeEvent.WantKeyboard + "ime.refocus" -> ImeEvent.ReFocus(parseFocus(o)) "ime.blur" -> ImeEvent.Blur "ime.state" -> ImeEvent.State( @@ -118,14 +116,25 @@ sealed interface ImeEvent { ) : ImeEvent /** - * A field that is **already** focused re-announced itself: the user tapped inside it ([userAsked]), or the - * tab was re-activated and answered the host's resync. Distinct from [Focus] because page focus never - * changed — the host must not restart the input on a field it is already hosting (that would kill a live - * composing region mid-word); it only re-takes the keyboard when it isn't hosting the field anymore. + * The user tapped inside a field that is **already** focused in the page: put the keyboard back up. No + * focus event follows a tap on a field that never blurred, so this is the only signal that the user wants + * the keyboard back after dismissing it — or after a tab switch released the host's mirror. + * + * Deliberately payload-free (it fires on every tap in a field): a host that no longer mirrors the field + * answers it with an [requestImeResync] and takes the state from the [ReFocus] that comes back. + */ + data object WantKeyboard : ImeEvent + + /** + * The page's answer to [requestImeResync]: the editing state of a field that is already focused there. + * Distinct from [Focus] because page focus never changed — the host must not restart the input on a field + * it is already mirroring (that would kill a live composing region mid-word). + * + * Carries no geometry: measuring a caret rect forces a synchronous layout in the page, and the host has no + * use for it here (the `ime.carettap` that rides along with a tap carries fresh geometry). */ data class ReFocus( val focus: Focus, - val userAsked: Boolean, ) : ImeEvent /** The field lost focus — dismiss the keyboard. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index 93ee6131a1..50bc57161f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -357,7 +357,9 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // A warm tab keeps its page focus while parked, so returning to it fires no focus event: ask the // page to re-announce whatever field is still focused. Restores the mirror (so a tap can raise the // keyboard) and, when the user left mid-typing, brings the keyboard straight back. - var pendingRestore = boundId != null && EmbeddedTabHost.takeKeyboardRestore(boundId) + // Consumed only when there is a bridge to ask (the mark is one-shot, so spending it on a bind that + // can't send the resync would drop the restore this tab was owed). + var pendingRestore = imeBridge != null && boundId != null && EmbeddedTabHost.takeKeyboardRestore(boundId) imeView.bind(imeBridge) imeView.onRangeSelectionChanged = { sel.onFieldRangeToggle(it) } imeView.onEdited = { sel.onEdited() } @@ -373,14 +375,26 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { sel.scrolling = false sel.onFieldGeometry(event.geometry, event.text.isNotEmpty()) } + ImeEvent.WantKeyboard -> { + // Still mirroring this field (the keyboard was merely dismissed) → just put it back. + // Otherwise the mirror was released by a tab switch and holds another tab's buffer: + // ask for the state first and raise once it lands. + if (imeView.isMirroringPageField()) { + imeView.raiseKeyboard() + } else { + pendingRestore = true + imeBridge.requestImeResync() + } + } is ImeEvent.ReFocus -> { - // Raise the keyboard when the user asked for it (a tap in the field) or when this tab - // was left with the keyboard up; either way re-take the field so typing works again. - imeView.onPageReFocus(event.focus, event.userAsked || pendingRestore) + // Raise only if something asked for it — a tap that rang the doorbell above, or a tab + // left with the keyboard up. A plain tab return re-takes the field silently. + imeView.onPageReFocus(event.focus, pendingRestore) pendingRestore = false - // Only a tap re-arms the caret handle. A silent tab-return resync must not pop one - // up on its own — native shows nothing until the user touches the field. - if (event.userAsked) sel.onFieldGeometry(event.focus.geometry, event.focus.text.isNotEmpty()) + // Re-arm "the field has text" so a tap can show the insertion handle again (a tab + // switch resets it). Geometry stays null here, so this can't pop a handle up on its + // own — native shows nothing until the user touches the field. + sel.onFieldGeometry(null, event.focus.text.isNotEmpty()) } ImeEvent.Blur -> { imeView.onPageBlur() @@ -408,7 +422,12 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // tab resumes exactly the state it was left in. The page keeps its focus (and caret) either // way: blurring it here would fire the page's own blur handlers — validation, autocomplete // dismissal, submit-on-blur — for a switch the user never made inside the page. - if (boundId != null) EmbeddedTabHost.noteKeyboardOnLeave(boundId, keyboardUp.value) + // + // Only a keyboard THIS mirror holds counts: the tab's own chrome has host-side fields (the + // browser's address bar), and typing in one of those must not arm a restore for a page field. + if (boundId != null) { + EmbeddedTabHost.noteKeyboardOnLeave(boundId, keyboardUp.value && imeView.isMirroringPageField()) + } imeView.onPageBlur() imeView.bind(null) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt index 6efed253c5..bdb55e1aa0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt @@ -65,6 +65,13 @@ class RemoteImeView( // The last state we sent, so we never ship a no-op (avoids feedback churn with the page). private var lastSent: String? = null + // True while this view mirrors a live page field, i.e. between a page focus and the blur that releases it. + // Distinct from [hasFocus]: clearing focus on a View can hand it straight back (a lone focusable in the + // hierarchy re-takes it), and window focus comes and goes on its own. Only this flag says the buffer still + // belongs to the page's field — without it, a lingering focus would let a re-focus skip the re-seed and + // ship the PREVIOUS tab's text to the page on the first keystroke. + private var mirroring = false + private val imm get() = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager private val flush = Runnable { flushState() } @@ -153,15 +160,16 @@ class RemoteImeView( fun selectAllText(): Boolean = onTextContextMenuItem(android.R.id.selectAll) /** - * A page field focused: configure the keyboard, seed the buffer, and — unless [raiseKeyboard] is false — + * A page field focused: configure the keyboard, seed the buffer, and — unless [withKeyboard] is false — * raise the IME. A restored tab passes false: its field is focused again in this mirror (so a tap can put * the keyboard straight back) without a keyboard the user had dismissed popping up over the page. */ fun onPageFocus( focus: ImeEvent.Focus, - raiseKeyboard: Boolean = true, + withKeyboard: Boolean = true, ) { configureFor(focus) + mirroring = true // Focus the EditText BEFORE seeding text/selection. An EditText jumps its caret to the end when it // gains focus; if we seed first, that end-position then overrides the seed and gets shipped to the // page — so a tap mid-text lands the caret at the end of the field. Seeding AFTER focus makes the @@ -170,7 +178,7 @@ class RemoteImeView( requestFocus() imm.restartInput(this) applyRemote(focus.text, focus.selStart, focus.selEnd) - if (raiseKeyboard) showKeyboard() + if (withKeyboard) raiseKeyboard() } /** @@ -185,18 +193,26 @@ class RemoteImeView( */ fun onPageReFocus( focus: ImeEvent.Focus, - raiseKeyboard: Boolean, + withKeyboard: Boolean, ) { - if (hasFocus()) { - if (raiseKeyboard) showKeyboard() + if (isMirroringPageField()) { + if (withKeyboard) raiseKeyboard() } else { - onPageFocus(focus, raiseKeyboard) + onPageFocus(focus, withKeyboard) } } - /** Post the show so it runs after focus/attachment has settled (showSoftInput can no-op otherwise). */ + /** True while the keyboard this view holds belongs to a page field — see [mirroring]. */ + fun isMirroringPageField() = mirroring && hasFocus() + + /** + * Put the keyboard back on the field this view already mirrors — the user tapped it after dismissing the + * keyboard, which leaves the page's focus (and this mirror) untouched, so there is nothing to re-seed. + * + * Post the show so it runs after focus/attachment has settled (showSoftInput can no-op otherwise). + */ @Suppress("DEPRECATION") // InputMethodManager.SHOW_IMPLICIT is deprecated; no equivalent flag on the newer API. - private fun showKeyboard() { + fun raiseKeyboard() { post { if (hasFocus()) imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } @@ -239,6 +255,7 @@ class RemoteImeView( /** The page field blurred: drop the keyboard. */ fun onPageBlur() { + mirroring = false removeCallbacks(reportRangeLost) if (hadRange) { hadRange = false diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js index 8e52213e68..6afbca5e04 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shim.js +++ b/commons/src/commonMain/composeResources/files/napplet/shim.js @@ -378,14 +378,19 @@ return { type:'ime.focus', inputType: inputType, enterKeyHint: (n.enterKeyHint || ''), multiline: multiline, text: valOf(n), selStart: sel[0], selEnd: sel[1], geom: fieldGeom(n) }; } - // Same payload as `ime.focus`, but for a field that is ALREADY focused — the host uses it to (re-)take the - // keyboard without treating it as a fresh focus. `raise` marks the cases the user explicitly asked for the - // keyboard (a tap in the field); a resync reply leaves the decision to the host. - function refocusInfo(n, raise){ - var info = focusInfo(n); - info.type = 'ime.refocus'; - info.raise = !!raise; - return info; + // Like `ime.focus`, but for a field that is ALREADY focused: the answer to the host's `ime.resync`, which + // it asks for when it needs to (re-)take a field whose focus never moved in the page. + // + // Carries NO geometry, unlike focusInfo: fieldGeom's caret measurement mirrors the whole field into a + // hidden div and forces a synchronous layout (~3.5ms on a 40k-char textarea). The host only needs the + // editing state here; the `ime.carettap` that rides along with a tap carries fresh geometry. + function refocusInfo(n){ + var t = (n.tagName || '').toUpperCase(); + var sel = selOf(n); + return { type:'ime.refocus', + inputType: isCE(n) ? 'text' : (t === 'TEXTAREA' ? 'textarea' : (n.type || 'text').toLowerCase()), + enterKeyHint: (n.enterKeyHint || ''), multiline: isCE(n) || t === 'TEXTAREA', + text: valOf(n), selStart: sel[0], selEnd: sel[1] }; } // Last selection we either applied (applyState) or already reported, so the asynchronous // selectionchange our own setSel triggers doesn't echo back to the host as a fresh edit. @@ -476,12 +481,14 @@ if (!el) return; // A tap inside the ALREADY-focused editable fires no `focusin`, so the host — whose only keyboard-raising // signal used to be `ime.focus` — never learned that the user wants the keyboard back after dismissing it - // (or after a tab switch dropped it). Re-announce the field on every such tap: the host raises the - // keyboard and, if it isn't hosting this field anymore, re-seeds its mirror from this payload. - // Sent on the focusing tap too (which the host answers with a no-op, since it is already hosting the - // field): telling the two apart here would take a focusin-to-click timing guess, and a guess that comes - // in late swallows a real "give me the keyboard back" tap. - if (e.target === el || (el.contains && el.contains(e.target))) send(refocusInfo(el, true)); + // (or after a tab switch dropped it). Ring the doorbell on every such tap; the host puts the keyboard + // back, and asks for a resync first if it no longer mirrors this field. + // + // Deliberately payload-free: this fires on every tap in a field, including the tap that focused it (the + // host answers that one with a no-op — telling the two apart here would take a focusin-to-click timing + // guess, and a guess that lands late swallows a real "give me the keyboard back" tap). Attaching the + // editing state would put the field's whole text — 40KB for a long textarea — on the wire per tap. + if (e.target === el || (el.contains && el.contains(e.target))) send({ type:'ime.wantkb' }); if (isCE(el) || e.target !== el) return; var sel = selOf(el); if (sel[0] !== sel[1]) { @@ -726,9 +733,10 @@ document.addEventListener('scroll', onAnyScroll, true); // capture: any scroller, not just the document window.__nappletImeHandle = function(msg){ - // The tab came back on screen. Focus never left the page (the surface just moved off-screen), so no - // `focusin` will fire — re-announce the still-focused field so the host can re-take the keyboard. - if (msg.type === 'ime.resync') { if (el) send(refocusInfo(el, false)); return; } + // The host needs to (re-)take a field whose focus never left the page — the tab came back on screen, or + // a tap rang the doorbell above and the host has no mirror for it. No `focusin` will fire for a field + // that never blurred, so hand it the editing state here. + if (msg.type === 'ime.resync') { if (el) send(refocusInfo(el)); return; } if (msg.type === 'ime.set') applyState(msg); else if (msg.type === 'ime.action') enter(el); else if (msg.type === 'ime.pageextend') pageExtend(msg.edge, msg.x, msg.y); From 05a331068f81f053908d84b36d79bd841feba657 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 17:51:39 -0400 Subject: [PATCH 186/227] test(concord): pin that a banned member's typing heartbeat is dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The soft-ban audit's A4 shipped with a send-side guard and a receive-side filter, and the receive-side filter — the only one that binds a modified client — had no test. Bans first, so the assertion exercises the filter rather than an entry that was seated before the ban, and checks the filter is targeted rather than a blanket mute. Mutation-tested: removing the isBanned check in ConcordCommunitySession.ingestTyping fails it. Co-Authored-By: Claude Opus 5 (1M context) --- .../model/concord/ConcordBannedTypingTest.kt | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordBannedTypingTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordBannedTypingTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordBannedTypingTest.kt new file mode 100644 index 0000000000..a89e26789e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordBannedTypingTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The receive half of the soft-ban audit's A4: a banned member's typing heartbeat must not reach + * the "… is typing" row. The send half is a guard in the app's own action layer, which a malicious + * or modified client simply won't run — so this filter, on the receive side, is the only one that + * actually protects the room. It shipped without a test; this is it. + */ +class ConcordBannedTypingTest { + private val owner = NostrSignerInternal(KeyPair()) + private val troll = NostrSignerInternal(KeyPair()) + private val regular = NostrSignerInternal(KeyPair()) + + private fun entryFor(community: NewConcordCommunity) = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + @Test + fun dropsABannedMembersTypingHeartbeatAndKeepsEveryoneElses() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) + community.genesisWraps.forEach { session.ingest(it) } + + val channelId = community.generalChannelIdHex + val plane = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch) + val now = TimeUtils.now() + + // Ban first, so what follows tests the filter rather than an entry seated before the ban. + // The banlist edition folds through the Control Plane exactly as it would on the wire. + session.ingest( + ConcordModeration.ban( + actor = owner, + controlPlane = session.controlPlaneKeys(), + communityId = community.communityIdHex.hexToByteArray(), + member = troll.pubKey, + current = session.controlEditions(), + createdAt = now, + owner = community.ownerPubKey, + ), + ) + assertTrue( + session.state.value + ?.authority + ?.isBanned(troll.pubKey) == true, + "the ban must have folded before the heartbeats are judged", + ) + + // The banned member keeps broadcasting — a modified client ignores the send-side guard. + session.ingest(ConcordActions.buildChannelTyping(troll, plane, channelId, community.rootEpoch, now)) + assertEquals( + null, + session.typing.value[channelId]?.get(troll.pubKey.lowercase()), + "a banned member must never be seated in the typing row", + ) + + // The filter is targeted, not a blanket mute: an ordinary member still types normally. + session.ingest(ConcordActions.buildChannelTyping(regular, plane, channelId, community.rootEpoch, now)) + assertTrue( + session.typing.value[channelId]?.containsKey(regular.pubKey.lowercase()) == true, + "an unbanned member's typing heartbeat must still show", + ) + assertEquals( + null, + session.typing.value[channelId]?.get(troll.pubKey.lowercase()), + "seating one member must not drag the banned one in", + ) + } +} From 3153942bac5a007e65d95d7f4d28517f9102a2a6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 18:03:28 -0400 Subject: [PATCH 187/227] feat(cli): let amy adopt the Control Plane write key a Grant delivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A promotion to staff delivers the `control_root` inside the Grant edition itself (CORD-04 §3). Amethyst drains that on its Concord revision tick, but that logic lived only in `AccountConcordActions`, so `amy` could hold a rank it could never write under: the fold seated it as staff and every moderation verb still refused with `forbidden`. That is also why #3873's delivery path shipped without a CLI test — the harness could not accept a promotion. Extracts the decision into `commons` as `ConcordReceive`, pure and shared: - `deliveredControlRoot` — the whole fail-closed check (are we staff by our OWN fold, does a Grant carry a wrap, does it open under the pairwise key, name this epoch, and derive to the `control_pk` we already hold). - `withAdoptedRoot` — the entry rewrite a base rotation produces, banking the leaving epoch's address for the anti-rollback floor. - `isAuthorizedRotator` — the ban-aware rotator check. Amethyst now calls the shared versions (no behaviour change; its persist + publish and Guestbook re-announce stay put). amy adopts during the Control Plane drain every moderation command already performs, since it has no tick of its own, and returns the refreshed record so a freshly promoted staffer can pass the secret on in its own Grant. Adoption is local to amy's store on purpose: Amethyst republishes the kind-13302 list so a user's other devices follow, and doing that here would mean rebuilding and signing the whole list from the CLI. Verified end to end against a loopback geode: bob is refused before the promotion, alice promotes him, bob's stored `control_root` is blank, his next command adopts and persists it, and his BAN lands and is honored by alice's independent fold. A role edition he lacks MANAGE_ROLES for is still dropped on fold — possession remains a spam gate, never authority. Still Android-only: `recoverStrandedConcordCommunities`, which needs invite re-resolution over the network. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/model/AccountConcordActions.kt | 73 ++------- .../amethyst/cli/commands/ConcordCommands.kt | 42 +++++ .../cli/commands/ConcordModCommands.kt | 46 +++++- .../commons/actions/ConcordReceive.kt | 145 ++++++++++++++++++ 4 files changed, 237 insertions(+), 69 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index f659e38564..4caf607709 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession @@ -35,17 +36,12 @@ import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity -import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions -import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind -import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap -import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary -import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope @@ -932,35 +928,11 @@ class AccountConcordActions( newControlRoot: ByteArray? = null, ) { if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return - // The epoch we're leaving is banked with the address it was folded at, so its Control - // Plane stays subscribable for the anti-rollback floor (a split epoch's address can - // never be re-derived, only remembered — CORD-02 §2). - val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch } - val next = - ConcordCommunityListEntry( - id = entry.id, - owner = entry.owner, - ownerSalt = entry.ownerSalt, - root = newRoot.toHexKey(), - rootEpoch = newEpoch, - // A rotation that delivered no control material is a legacy, pre-split one - // (CORD-06 §3): the new epoch keeps folding at the legacy address, and the - // stale prior-epoch values must NOT be carried into it. - controlPk = newControlPk?.toHexKey(), - controlRoot = newControlRoot?.toHexKey(), - heldRoots = held, - privateChannels = entry.privateChannels, - relays = entry.relays, - name = entry.name, - addedAt = entry.addedAt, - // The invite_ref anchor must survive a rotation, or the *next* Refounding we're left - // out of would be unrecoverable. - inviteRef = entry.inviteRef, - excludedAtEpoch = entry.excludedAtEpoch, - // Unknown keys another client wrote (Armada's list is `[k: string]: unknown`) - // must survive our rotation write, or we delete their data on every rekey. - residue = entry.residue, - ) + // The rewrite itself — banking the leaving epoch's address for the anti-rollback floor, + // dropping stale control material on a legacy rotation, preserving invite_ref and residue — + // is shared with `amy` in [ConcordReceive.withAdoptedRoot]. Only the persist + publish and + // the Guestbook re-announce below are Android's. + val next = ConcordReceive.withAdoptedRoot(entry, newRoot, newEpoch, newControlPk, newControlRoot) account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(next)) announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) } @@ -1030,39 +1002,16 @@ class AccountConcordActions( */ internal suspend fun drainConcordStaffGrants() { if (!account.isWriteable()) return - val me = account.signer.pubKey.lowercase() for (session in account.concordSessions.sessions()) { val entry = session.entry - // Already staff at this epoch, or a legacy community with no split to join. - val heldControlPk = entry.controlPk - if (entry.controlRoot != null || heldControlPk == null) continue val state = session.state.value ?: continue - // Only a Grant our fold honors can deliver: an unauthorized edition hands us nothing. - if (!state.authority.isStaff(me)) continue - - val myGrantCoordinate = - ConcordKeyDerivation - .grantCoordinate(entry.id.hexToByteArray(), me.hexToByteArray()) - .toHexKey() - val delivered = - session - .controlEditions() - .filter { it.entityKind == ControlEntityKind.GRANT && it.entityIdHex == myGrantCoordinate } - // Newest first: a re-issued Grant (a lost key, a head superseded before we - // fetched it) carries the fresher wrap. - .sortedByDescending { it.version } - .firstNotNullOfOrNull { edition -> - val wrap = ConcordJson.decodeOrNull(edition.content)?.controlWrap ?: return@firstNotNullOfOrNull null - val opened = ControlRootWrap.openOrNull(wrap, account.signer, edition.author) ?: return@firstNotNullOfOrNull null - if (opened.epoch != entry.rootEpoch) return@firstNotNullOfOrNull null - // Fails closed: a secret that doesn't derive to the pk we hold is dropped, - // never adopted — we will not split ourselves off from the plane's readers. - if (!ControlRootWrap.derivesTo(opened.controlRoot, entry.id.hexToByteArray(), entry.rootEpoch, heldControlPk)) return@firstNotNullOfOrNull null - opened.controlRoot - } ?: continue + // The whole decision — are we staff, does a Grant carry a wrap, does it open, name our + // epoch, and derive to the control_pk we hold — is shared with `amy` in + // [ConcordReceive.deliveredControlRoot]. Only the persist + publish below is Android's. + val delivered = ConcordReceive.deliveredControlRoot(entry, session.controlEditions(), state.authority, account.signer) ?: continue account.sendMyPublicAndPrivateOutbox( - account.concordChannelList.follow(entry.withControlRoot(delivered.toHexKey())), + account.concordChannelList.follow(entry.withControlRoot(delivered)), ) } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index ff151176ef..689b0f18ee 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -28,7 +28,12 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordReceive +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -316,6 +321,43 @@ object ConcordCommands { controlRoot = sc.controlRoot.ifBlank { null }, ) + /** + * Adopts the `control_root` a staff-making Grant delivered to us (CORD-04 §3), persisting it to + * the local store and returning the now-writable keys — or null when nothing was delivered. + * + * The decision itself is [ConcordReceive.deliveredControlRoot], shared with Amethyst: it fails + * closed unless our own fold seats us as staff, the wrap opens under the granter↔member pairwise + * key, it names this epoch, and the secret derives to exactly the `control_pk` we already hold. + * + * Local-only on purpose: Amethyst republishes the kind-13302 list on adoption so a user's other + * devices follow, and doing that here would need amy to rebuild and sign the whole list. A CLI + * adoption therefore unblocks *this* account's writes; other devices adopt from their own fold. + */ + suspend fun adoptDeliveredControlRoot( + ctx: Context, + dataDir: DataDir, + sc: StoredCommunity, + editions: List, + ): Pair? { + val entry = + ConcordCommunityListEntry( + id = sc.communityId, + owner = sc.owner, + ownerSalt = sc.ownerSalt, + root = sc.root, + rootEpoch = sc.rootEpoch, + controlPk = sc.controlPk.ifBlank { null }, + controlRoot = sc.controlRoot.ifBlank { null }, + relays = sc.relays, + name = sc.name, + ) + val authority = AuthorityResolver.resolve(editions, sc.owner) + val delivered = ConcordReceive.deliveredControlRoot(entry, editions, authority, ctx.signer) ?: return null + val updated = sc.copy(controlRoot = delivered) + ConcordStore(dataDir.concordFile).upsert(updated) + return updated to controlPlaneKeysFor(updated) + } + fun notFound(handle: String): Int { Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`") return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 3633b24dbe..a2d364e8ec 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -51,7 +51,7 @@ object ConcordModCommands { val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) Context.open(dataDir).use { ctx -> ctx.prepare() - val (_, editions) = load(ctx, sc) + val (_, editions) = load(ctx, sc, dataDir) val state = ConcordCommunityState.fold(editions, sc.owner) Output.emit( mapOf( @@ -92,7 +92,7 @@ object ConcordModCommands { Context.open(dataDir).use { ctx -> ctx.prepare() - val (cp, editions) = load(ctx, sc) + val (cp, editions) = load(ctx, sc, dataDir) writeGuard(cp)?.let { return it } val roleId = RandomInstance.bytes(32) val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire()) @@ -119,7 +119,8 @@ object ConcordModCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val member = ctx.requireUserHex(userRef) - val (cp, editions) = load(ctx, sc) + val loaded = load(ctx, sc, dataDir) + val (cp, editions) = loaded writeGuard(cp)?.let { return it } // A Grant that first makes its member staff must carry the write secret in the same // edition (CORD-04 §3); ConcordModeration wraps it pairwise when the granted roles @@ -134,7 +135,10 @@ object ConcordModCommands { current = editions, createdAt = TimeUtils.now(), owner = sc.owner, - controlRoot = sc.controlRoot.ifBlank { null }?.hexToByteArray(), + controlRoot = + loaded.community.controlRoot + .ifBlank { null } + ?.hexToByteArray(), epoch = sc.rootEpoch, ) val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) @@ -170,7 +174,7 @@ object ConcordModCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val member = ctx.requireUserHex(userRef) - val (cp, editions) = load(ctx, sc) + val (cp, editions) = load(ctx, sc, dataDir) writeGuard(cp)?.let { return it } val cid = sc.communityId.hexToByteArray() val wrap = @@ -186,11 +190,28 @@ object ConcordModCommands { } } + /** + * The drained Control Plane: the community as stored *after* any adoption, its keys, and the + * editions to chain onto. [community] matters because adopting a delivered `control_root` + * rewrites the stored record — a caller that kept the pre-load copy would then fail to pass the + * secret on in its own Grant (CORD-04 §3). + */ + private class LoadedControl( + val community: StoredCommunity, + val keys: ControlPlaneKeys, + val editions: List, + ) { + operator fun component1() = keys + + operator fun component2() = editions + } + /** Drain the control plane and return its keys + current editions to chain onto. */ private suspend fun load( ctx: Context, sc: StoredCommunity, - ): Pair> { + dataDir: DataDir? = null, + ): LoadedControl { val cp = ConcordCommands.controlPlaneKeysFor(sc) val relays = ConcordCommands.relaysFor(ctx, sc) // Concord relays serve the plane's kind-1059 only to a connection AUTHed as the stream @@ -198,7 +219,18 @@ object ConcordModCommands { // that secret is staff-only (CORD-02 §2), and a member simply has nothing to register. ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } - return cp to ConcordActions.controlEditions(wraps, cp) + val editions = ConcordActions.controlEditions(wraps, cp) + + // A promotion to staff delivers the Control Plane write key inside the Grant itself + // (CORD-04 §3), so the fold that seats the role is also when the key arrives. Amethyst + // drains this on its revision tick; amy has no tick, so the fold a command already does is + // the moment to adopt — otherwise a CLI-promoted staffer holds a rank it can never write + // under. Same shared, fail-closed check both clients use. + if (dataDir != null && !cp.canWrite) { + val adopted = ConcordCommands.adoptDeliveredControlRoot(ctx, dataDir, sc, editions) + if (adopted != null) return LoadedControl(adopted.first, adopted.second, ConcordActions.controlEditions(wraps, adopted.second)) + } + return LoadedControl(sc, cp, editions) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt new file mode 100644 index 0000000000..b807ae24de --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap +import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +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 + +/** + * The **receive** half of Concord's key lifecycle, as pure functions: what a client must adopt + * when a Grant hands it the Control Plane write key (CORD-04 §3), and how an entry is rewritten + * when a base rotation moves the community to a new epoch (CORD-06). + * + * These lived only in Amethyst's `AccountConcordActions`, which meant a headless client (`amy`) + * could hold a rank it could never write under, and could not follow a Refounding at all. The + * logic is platform-agnostic — the only Android-shaped parts were the persistence and publish, + * which stay with the caller. Every function here decides *what* to adopt and returns it; the + * caller owns storing it and republishing the kind-13302 list. + * + * Everything fails closed: an undecryptable, mis-epoched or non-deriving delivery yields null, + * never a partially-adopted entry. + */ +object ConcordReceive { + /** + * The `control_root` a staff-making Grant delivered to the account behind [recipientSigner], + * or null when there is nothing to adopt (CORD-04 §3). + * + * Gated three ways, each of which fails closed: + * - only a Grant **our own fold honors** can deliver, so [authority] must already seat us as + * staff — a rogue cannot feed us a key by minting an edition nobody accepts; + * - the wrap must open under the granter↔member pairwise key, and name [entry]'s epoch, + * because compaction re-wraps a Grant head verbatim across Refoundings and a folded head + * can legitimately carry a wrap minted for a prior epoch; + * - the secret must derive to exactly the `control_pk` we already hold, or adopting it would + * split us off from the plane's readers. + * + * Returns null (not an error) when the entry already holds the secret, holds no `control_pk` + * to check against (a legacy pre-split community), or when we are not staff. + */ + suspend fun deliveredControlRoot( + entry: ConcordCommunityListEntry, + editions: List, + authority: AuthorityResolver, + recipientSigner: NostrSigner, + ): HexKey? { + val heldControlPk = entry.controlPk + if (entry.controlRoot != null || heldControlPk == null) return null + val me = recipientSigner.pubKey.lowercase() + if (!authority.isStaff(me)) return null + + val myGrantCoordinate = + ConcordKeyDerivation + .grantCoordinate(entry.id.hexToByteArray(), me.hexToByteArray()) + .toHexKey() + + return editions + .filter { it.entityKind == ControlEntityKind.GRANT && it.entityIdHex == myGrantCoordinate } + // Newest first: a re-issued Grant (a lost key, a head superseded before we fetched it) + // carries the fresher wrap. + .sortedByDescending { it.version } + .firstNotNullOfOrNull { edition -> + val wrap = ConcordJson.decodeOrNull(edition.content)?.controlWrap ?: return@firstNotNullOfOrNull null + val opened = ControlRootWrap.openOrNull(wrap, recipientSigner, edition.author) ?: return@firstNotNullOfOrNull null + if (opened.epoch != entry.rootEpoch) return@firstNotNullOfOrNull null + if (!ControlRootWrap.derivesTo(opened.controlRoot, entry.id.hexToByteArray(), entry.rootEpoch, heldControlPk)) return@firstNotNullOfOrNull null + opened.controlRoot.toHexKey() + } + } + + /** + * Whether [rotator] was allowed to launch the base rotation that [entry] is being moved by + * (CORD-06). `hasPermission`, never `effectivePermissions`: the latter ignores the banlist, so + * a banned BAN-holder could rotate the whole community out from under it. + */ + fun isAuthorizedRotator( + authority: AuthorityResolver, + rotator: HexKey, + ): Boolean = authority.isOwner(rotator) || authority.hasPermission(rotator, ConcordPermissions.BAN) + + /** + * The entry that results from adopting a base rotation to [newEpoch] — a pure rewrite, so the + * caller can diff, persist and publish it however its platform does. + * + * The epoch being left is banked in `heldRoots` **with the address it was folded at**, because + * a split epoch's Control address can never be re-derived, only remembered (CORD-02 §2) — that + * banked address is what keeps the anti-rollback floor rebuildable. A rotation that delivered + * no control material is a legacy pre-split one (CORD-06 §3): the new epoch folds at the legacy + * address, and the stale prior-epoch values must NOT be carried into it. `inviteRef` survives, + * or the *next* Refounding we are left out of becomes unrecoverable; `residue` survives, or we + * delete another client's unknown keys on every rekey. + */ + fun withAdoptedRoot( + entry: ConcordCommunityListEntry, + newRoot: ByteArray, + newEpoch: Long, + newControlPk: ByteArray? = null, + newControlRoot: ByteArray? = null, + ): ConcordCommunityListEntry = + ConcordCommunityListEntry( + id = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + root = newRoot.toHexKey(), + rootEpoch = newEpoch, + controlPk = newControlPk?.toHexKey(), + controlRoot = newControlRoot?.toHexKey(), + heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }, + privateChannels = entry.privateChannels, + relays = entry.relays, + name = entry.name, + addedAt = entry.addedAt, + inviteRef = entry.inviteRef, + excludedAtEpoch = entry.excludedAtEpoch, + residue = entry.residue, + ) +} From 4022a6a5da0fc08203c12bdc270e82fb49a33e65 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 18:37:05 -0400 Subject: [PATCH 188/227] feat(cli): add `amy concord recover` for stranded-recovery (CORD-05/06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the CLI's Concord receive path. A Refounding carries only `(newRoot, newEpoch, rotator)` and no recipient list, so a member simply left out of the rekey receives nothing and sits on the dead epoch forever while everyone else moves on. There is no message to miss, which is why the rekey drain cannot help: the only way back is the invite link the membership was joined through, since the community keeps re-minting its bundle at the same addressable coordinate. amy never stored that anchor, so recovery was impossible in principle. Adds `inviteRef` to the stored record, populated on `join` (bare, domain-agnostic) and carried through `import` — backstopped by what we already held, because a list entry without one must not clear ours or the NEXT exclusion becomes unrecoverable. Recovery is an explicit verb rather than Amethyst's timer sweep, so it stays deterministic and scriptable. Each community reports why it did or didn't move: `no_invite_ref`, `bad_invite_ref`, `no_live_bundle`, `banned`, `already_current`, `control_plane_not_folded`, or the epoch it advanced to. The ban gate is the part that matters (A2 in docs/concord-soft-ban-audit.md): a removed member keeps the link's unlock token forever, so without it this walks them straight back into the epoch they were rotated out of. It reads the banlist of the epoch being LEFT — the last plane we can still fold — and fails closed: a plane that will not fold yields no verdict and is skipped, never recovered. Verified against a loopback geode: a current member gets `already_current`, a community with no anchor gets `no_invite_ref`, and a member banned at the current epoch is refused with `banned`. The merge-forward itself is quartz's `ConcordStrandedRecovery` (already unit-tested); it is not exercised live here because amy cannot perform a Refounding to strand anyone with. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/cli/commands/ConcordCommands.kt | 154 ++++++++++++++++-- .../amethyst/cli/stores/ConcordStore.kt | 5 + 2 files changed, 146 insertions(+), 13 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 689b0f18ee..457be94472 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -31,8 +31,10 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -61,6 +63,9 @@ object ConcordCommands { | [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link | concord join URL redeem an invite link and save the community + | concord recover [COMMUNITY] re-resolve the joined-through invite link and + | follow a Refounding we were left out of + | (CORD-06); refuses if that epoch banned us | concord roles COMMUNITY list live roles + current banlist (CORD-04) | concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK) | concord grant COMMUNITY USER ROLE-ID grant a role to a member @@ -75,7 +80,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -87,6 +92,7 @@ object ConcordCommands { "read" to { rest -> ConcordChannelCommands.read(dataDir, rest) }, "invite" to { rest -> invite(dataDir, rest) }, "join" to { rest -> join(dataDir, rest) }, + "recover" to { rest -> recover(dataDir, rest) }, "roles" to { rest -> ConcordModCommands.roles(dataDir, rest) }, "role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) }, "grant" to { rest -> ConcordModCommands.grant(dataDir, rest) }, @@ -214,6 +220,9 @@ object ConcordCommands { generalChannelId = prior?.generalChannelId ?: "", relays = e.relays, heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") }, + // Survives every merge: losing the anchor makes the NEXT exclusion + // unrecoverable, so a list entry without one must not clear ours. + inviteRef = e.inviteRef ?: prior?.inviteRef ?: "", ), ) mapOf( @@ -289,6 +298,9 @@ object ConcordCommands { // community is still pre-split and folds at the legacy address. controlPk = bundle.controlPk ?: "", relays = bundle.relays, + // The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving + // this link is the only way back (CORD-05/06). Stored bare, domain-agnostic. + inviteRef = ConcordActions.bareInviteRef(url) ?: "", ), ) Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays)) @@ -339,18 +351,7 @@ object ConcordCommands { sc: StoredCommunity, editions: List, ): Pair? { - val entry = - ConcordCommunityListEntry( - id = sc.communityId, - owner = sc.owner, - ownerSalt = sc.ownerSalt, - root = sc.root, - rootEpoch = sc.rootEpoch, - controlPk = sc.controlPk.ifBlank { null }, - controlRoot = sc.controlRoot.ifBlank { null }, - relays = sc.relays, - name = sc.name, - ) + val entry = entryFor(sc) val authority = AuthorityResolver.resolve(editions, sc.owner) val delivered = ConcordReceive.deliveredControlRoot(entry, editions, authority, ctx.signer) ?: return null val updated = sc.copy(controlRoot = delivered) @@ -358,6 +359,133 @@ object ConcordCommands { return updated to controlPlaneKeysFor(updated) } + /** The quartz list entry a [StoredCommunity] describes — the shape every commons helper takes. */ + fun entryFor(sc: StoredCommunity) = + ConcordCommunityListEntry( + id = sc.communityId, + owner = sc.owner, + ownerSalt = sc.ownerSalt, + root = sc.root, + rootEpoch = sc.rootEpoch, + controlPk = sc.controlPk.ifBlank { null }, + controlRoot = sc.controlRoot.ifBlank { null }, + heldRoots = sc.heldRoots.map { HeldRoot(it.epoch, it.root, it.controlPk.ifBlank { null }) }, + relays = sc.relays, + name = sc.name, + inviteRef = sc.inviteRef.ifBlank { null }, + ) + + /** Folds [entry] back into the stored shape after a rotation is adopted. */ + fun storedFrom( + sc: StoredCommunity, + entry: ConcordCommunityListEntry, + ) = sc.copy( + root = entry.root, + rootEpoch = entry.rootEpoch, + controlPk = entry.controlPk ?: "", + controlRoot = entry.controlRoot ?: "", + heldRoots = entry.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") }, + relays = entry.relays, + name = entry.name.ifBlank { sc.name }, + inviteRef = entry.inviteRef ?: sc.inviteRef, + ) + + /** + * `concord recover [COMMUNITY]` — the stranded-recovery receive path (CORD-05/06 A2). + * + * A Refounding carries only `(newRoot, newEpoch, rotator)` and **no recipient list**, so a + * member simply left out of the rekey receives nothing and sits on the dead epoch forever while + * everyone else moves on. There is no message to miss, which is why the rekey drain cannot help. + * The way back is the invite link the membership was joined through: the community keeps + * re-minting its bundle at the same addressable coordinate, so a live bundle at a **strictly + * higher** epoch than ours proves we were left behind — and carries the new root. + * + * Amethyst sweeps this on a timer; amy makes it an explicit verb, so it stays deterministic and + * scriptable rather than a background loop. + * + * The ban gate is the point of care. A removed member keeps the link's unlock token forever, so + * without it this walks them straight back into the epoch they were rotated out of. It reads the + * banlist of the epoch we are **leaving** (the last Control Plane we can still fold) and **fails + * closed**: a community whose plane will not fold yields no verdict and is skipped, never + * recovered. + */ + private suspend fun recover( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positionalOrNull(0) + args.rejectUnknown() + val store = ConcordStore(dataDir.concordFile) + val targets = + if (handle != null) { + listOf(store.find(handle) ?: return notFound(handle)) + } else { + store.load() + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val results = mutableListOf>() + for (sc in targets) { + val inviteRef = sc.inviteRef.ifBlank { null } + if (inviteRef == null) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_invite_ref") + continue + } + val parsed = ConcordActions.parseInviteLink(inviteRef) + if (parsed == null) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "bad_invite_ref") + continue + } + val relays = (normalize(parsed.fragment.relays) + normalize(sc.relays)).ifEmpty { ctx.outboxRelays() } + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second } + // Only a LIVE bundle recovers: an expired or revoked link is not a rotation we missed. + val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite + if (bundle == null) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_live_bundle") + continue + } + + // Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict, + // no recovery — the gate fails closed rather than assuming "not banned". + val cp = controlPlaneKeysFor(sc) + ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) + val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } + val editions = ConcordActions.controlEditions(controlWraps, cp) + if (editions.isEmpty()) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "control_plane_not_folded") + continue + } + val bannedHere = AuthorityResolver.resolve(editions, sc.owner).isBanned(ctx.signer.pubKey) + + val merged = ConcordActions.recoverStranded(entryFor(sc), bundle, bannedHere) + if (merged == null) { + results += + mapOf( + "community_id" to sc.communityId, + "name" to sc.name, + "recovered" to false, + "reason" to if (bannedHere) "banned" else "already_current", + "root_epoch" to sc.rootEpoch, + ) + continue + } + store.upsert(storedFrom(sc, merged)) + results += + mapOf( + "community_id" to sc.communityId, + "name" to sc.name, + "recovered" to true, + "from_epoch" to sc.rootEpoch, + "root_epoch" to merged.rootEpoch, + ) + } + Output.emit(mapOf("communities" to results)) + return 0 + } + } + fun notFound(handle: String): Int { Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`") return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index 7ae731a877..d2de85a258 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -48,6 +48,11 @@ data class StoredCommunity( // Past access roots kept per epoch (CORD-06 Refounding rotates the root). Lets `read --epoch ` // re-derive a prior epoch's Chat Plane to reach pre-refounding history. Populated by `import`. val heldRoots: List = emptyList(), + // The bare `#` invite this membership was joined through — the stranded-recovery + // anchor (CORD-05/06). A Refounding carries no recipient list, so a member simply left out of the + // rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct + // invite or a community joined before amy stored it. + val inviteRef: String = "", ) /** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */ From b7f55d86971f0d74a8865e45f8712cbd0248bd04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 22:44:46 +0000 Subject: [PATCH 189/227] chore: add a runtime perf probe for the embedded vs full-screen WebView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded tab and the full-screen browser are the same WebView in the same `:napplet` process with byte-identical WebSettings, so a site whose JS feels slower in the embed is being slowed by the host, not by its configuration. `perf.html` measures which host effect it is: page visibility (a page Chromium treats as hidden gets ~1Hz timers and no rAF), raw CPU throughput (the renderer inherits its scheduling class from whichever process hosts the WebView — the embed's is a plain bound service, the full-screen one is top-app), forced-layout cost, rAF rate, long tasks, and input-delivery latency measured from the platform's own event timestamp. Open the same URL in both hosts and compare the summary line; the README says what each divergence points at. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS --- tools/ime-test/README.md | 42 ++++++++ tools/ime-test/perf.html | 206 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 tools/ime-test/perf.html diff --git a/tools/ime-test/README.md b/tools/ime-test/README.md index 816bd030aa..8eb2fb00fe 100644 --- a/tools/ime-test/README.md +++ b/tools/ime-test/README.md @@ -52,3 +52,45 @@ live only under `tools/`. low now that the surface no longer resizes on IME show). - `MAINTHREAD BLOCKED` / `LONGTASK` = something is stalling the WebView thread. - `HEARTBEAT` lines changing while idle = spontaneous focus/selection drift. + +## `perf.html` — why does the embed feel slower than the full-screen browser? + +`index.html` profiles the IME relay. `perf.html` answers a different question: +the embedded tab and the full-screen browser are the **same WebView in the same +`:napplet` process** with byte-identical `WebSettings`, so when a site's JS feels +slower in the embed, the cause is host-induced — and this page measures which +host effect it is. + +Serve the directory (above) and open **the same URL in both hosts**, then compare +the summary line at the bottom of the page: + +- **`vis=hidden`** — decisive. Chromium considers the embedded page hidden, so it + clamps timers to ~1Hz and suspends `requestAnimationFrame`. Everything the site + schedules lands late; it reads as "the JS got slow". Confirmed by + `timer50` (a 50ms interval firing at 500-1000ms) and `raf` (0 fps). +- **`vis=visible` but `cpu` is 2-4× the full-screen number** — the process is + running on the little cores. The site's JS runs in the WebView *renderer* + process, whose scheduling class is inherited from its host: `:napplet` is + `top-app` when it fronts the full-screen activity, but only a bound service + (`BIND_AUTO_CREATE`, no `BIND_IMPORTANT`) when it serves the embed. Cross-check + off-device with: + + ```bash + adb shell dumpsys activity processes | grep -E 'napplet|sandboxed' + adb shell "cat /proc/$(adb shell pidof com.vitorpamplona.amethyst:napplet)/cgroup" + ``` + + Expect `/top-app` with the full-screen browser open and `/foreground` (or lower) + with an embed tab open. +- **`cpu` matches but `inputDelivery` is much higher** — the gap is input routing + into the embedded window, not compute. `inputDelivery` is the time between the + platform stamping the touch and JS receiving it. +- **`layout` much higher in the embed** — layout/paint is the bottleneck (check + logcat for WebView software-rendering warnings; a non-hardware-accelerated + `SurfaceControlViewHost` window would put Chromium on the software path). +- **`focus=false` in the embed is expected** and is not itself a throttle: the + host window owns the keyboard, which is the whole reason `RemoteImeView` exists. + +`longtasks` counts main-thread blocks over 50ms while the page was measuring — +high counts in the embed with a matching `cpu` number point at something else in +the process competing (e.g. parked warm tabs that are never paused). diff --git a/tools/ime-test/perf.html b/tools/ime-test/perf.html new file mode 100644 index 0000000000..b1efbb4d78 --- /dev/null +++ b/tools/ime-test/perf.html @@ -0,0 +1,206 @@ + + + + + +Embed vs direct — runtime perf probe + + + +

Runtime perf probe

+

Open this same URL twice — once as an embedded tab, once in the full-screen browser — and compare. Both are the same WebView in the same process; any gap is host-induced.

+ + + +
+ + + + + + + + From 0aa3adfc07d26daa9047ec13cde57685a0ca4dbd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 18:48:28 -0400 Subject: [PATCH 190/227] =?UTF-8?q?feat(cli):=20add=20`amy=20concord=20ref?= =?UTF-8?q?ound`=20+=20`rekey`=20=E2=80=94=20the=20rotation=20half=20of=20?= =?UTF-8?q?CORD-06?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `refound` is the hard removal a ban cannot give: a ban only strips standing, while the removed member keeps every key they ever held. Rotating the `community_root` — and, since CORD-02 §2, a fresh `control_root` beside it so a demoted staffer's retained secret dies with the epoch — is what actually closes the room. The compacted Control Plane is re-sealed at the new epoch and each retained member gets a rekey blob. Authority mirrors Amethyst exactly: `hasPermission`, never `effectivePermissions`, so a banned BAN-holder cannot launch one; the owner is never a valid target; and removal takes the same rank rule as a ban (CORD-04 §3) — an admin cannot Refound a peer admin out. The recipient set reaches past the roster to the Guestbook AND the authors of every channel message we can decrypt, because a member who only ever posted holds no role and files no Guestbook motion — building the set without them silently expels them. It is still a floor, not a census. `rekey` is the receive half, and without it `refound` was actively harmful from the CLI: a retained member's blob sat on the relay unopened, so a Refounding launched from amy stranded every other amy member. It authorizes the rotator against the roster of the epoch being LEFT and fails closed. Verified end to end against a loopback geode — the full cycle, which was not previously expressible from the CLI at all: alice refound --remove → epoch 0 → 1, recipients=2 (bob is kept because he POSTED, holding no role — the author harvest) bob rekey → epoch 0 → 1, same root + control_pk bob sends, alice reads it at the new epoch alice roles → the removed member is banned Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/cli/commands/ConcordCommands.kt | 71 +++++++- .../cli/commands/ConcordModCommands.kt | 171 ++++++++++++++++++ 2 files changed, 241 insertions(+), 1 deletion(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 457be94472..1e299b4191 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -63,6 +63,8 @@ object ConcordCommands { | [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link | concord join URL redeem an invite link and save the community + | concord rekey [COMMUNITY] follow a Refounding we were re-keyed for: + | open our blob and adopt the new epoch | concord recover [COMMUNITY] re-resolve the joined-through invite link and | follow a Refounding we were left out of | (CORD-06); refuses if that epoch banned us @@ -71,6 +73,9 @@ object ConcordCommands { | concord grant COMMUNITY USER ROLE-ID grant a role to a member | concord ban COMMUNITY USER ban a member | concord unban COMMUNITY USER unban a member + | concord refound COMMUNITY --remove U[,U] CORD-06 Refounding: rotate the root (and the + | control_root) so removed members lose every + | key — the hard removal a ban cannot give """.trimMargin() suspend fun dispatch( @@ -80,7 +85,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -93,11 +98,13 @@ object ConcordCommands { "invite" to { rest -> invite(dataDir, rest) }, "join" to { rest -> join(dataDir, rest) }, "recover" to { rest -> recover(dataDir, rest) }, + "rekey" to { rest -> rekey(dataDir, rest) }, "roles" to { rest -> ConcordModCommands.roles(dataDir, rest) }, "role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) }, "grant" to { rest -> ConcordModCommands.grant(dataDir, rest) }, "ban" to { rest -> ConcordModCommands.ban(dataDir, rest) }, "unban" to { rest -> ConcordModCommands.unban(dataDir, rest) }, + "refound" to { rest -> ConcordModCommands.refound(dataDir, rest) }, ), ) @@ -486,6 +493,68 @@ object ConcordCommands { } } + /** + * `concord rekey [COMMUNITY]` — follow a Refounding we WERE re-keyed for (CORD-06). + * + * The normal counterpart to [recover]: a retained member gets a per-recipient blob on the next + * epoch's base-rekey plane, and opening it yields the new root. Amethyst drains this on its + * revision tick; amy has no tick, so it is a verb. Without it a Refounding launched from the CLI + * strands every other CLI member even though their blob is sitting on the relay. + * + * The rotator is authorized against the roster of the epoch being **left** — `hasPermission`, + * never `effectivePermissions`, so a banned BAN-holder cannot rotate us (CORD-06). Fails closed: + * a plane that will not fold yields no verdict and the community is skipped. + */ + private suspend fun rekey( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positionalOrNull(0) + args.rejectUnknown() + val store = ConcordStore(dataDir.concordFile) + val targets = if (handle != null) listOf(store.find(handle) ?: return notFound(handle)) else store.load() + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val results = mutableListOf>() + for (sc in targets) { + val relays = relaysFor(ctx, sc) + val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + val received = + ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch) + if (received == null) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "no_blob_for_us", "root_epoch" to sc.rootEpoch) + continue + } + if (received.newEpoch <= sc.rootEpoch) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch) + continue + } + // Authorize the rotator against the epoch we are LEAVING — the last plane we can fold. + val cp = controlPlaneKeysFor(sc) + ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) + val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } + val editions = ConcordActions.controlEditions(controlWraps, cp) + if (editions.isEmpty()) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded") + continue + } + if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.owner), received.rotator)) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "unauthorized_rotator", "rotator" to received.rotator) + continue + } + val adopted = ConcordReceive.withAdoptedRoot(entryFor(sc), received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) + store.upsert(storedFrom(sc, adopted)) + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to received.newEpoch, "rotator" to received.rotator) + } + Output.emit(mapOf("communities" to results)) + return 0 + } + } + fun notFound(handle: String): Int { Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`") return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index a2d364e8ec..a9e8b0d2d8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition @@ -206,6 +207,176 @@ object ConcordModCommands { operator fun component2() = editions } + /** + * `concord refound COMMUNITY --remove USER[,USER…]` — a CORD-06 Refounding: the hard removal. + * + * A ban only strips standing; the removed member keeps every key they ever held, so the room is + * only truly closed to them by rotating the `community_root` (and, since CORD-02 §2, a fresh + * `control_root` beside it, so a demoted staffer's retained secret dies with the epoch). The + * compacted Control Plane is re-sealed at the new epoch and each retained member gets a rekey + * blob; nobody else can follow. + * + * Authority mirrors Amethyst exactly: `hasPermission`, never `effectivePermissions`, so a banned + * BAN-holder cannot launch one; the owner is never a valid target; and removal takes the same + * rank rule as a ban (CORD-04 §3) — an admin cannot Refound a peer admin out. + * + * **The recipient set is a floor, not a census.** It is the roster ∪ Guestbook ∪ the authors of + * every channel message we can decrypt ∪ ourselves, minus the removed and already-banned — the + * same union Amethyst builds, because a member who only ever posted holds no role and leaves no + * Guestbook motion, and omitting them silently expels them. A member with no trace at all still + * cannot be re-keyed; `concord recover` is how they get back. + */ + suspend fun refound( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val removeArg = args.flag("remove") ?: return Output.error("bad_args", "refound --remove USER[,USER…]").let { 2 } + args.rejectUnknown() + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val removed = + removeArg + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { ctx.requireUserHex(it).lowercase() } + .toSet() + if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user") + + val loaded = load(ctx, sc, dataDir) + val (cp, editions) = loaded + val state = ConcordCommunityState.fold(editions, sc.owner) + val authority = state.authority + val me = ctx.signer.pubKey + + if (!ConcordReceive.isAuthorizedRotator(authority, me)) { + return Output.error("forbidden", "this account cannot refound: a Refounding takes BAN (or ownership), and a banned holder is refused (CORD-06)") + } + if (removed.any { authority.isOwner(it) }) { + return Output.error("forbidden", "the owner is never a valid removal target (CORD-04 §3)") + } + // An admin cannot Refound a peer admin out any more than they could ban one. + if (!authority.isOwner(me) && removed.any { !authority.canActOn(me, it, ConcordPermissions.BAN) }) { + return Output.error("forbidden", "you do not outrank every member you are removing (CORD-04 §3, equal cannot act on equal)") + } + // A Refounding writes the current plane (the pre-rotation bans) and the new one, so on a + // split epoch it takes the current control_root (CORD-02 §2). + writeGuard(cp)?.let { return it } + + val relays = ConcordCommands.relaysFor(ctx, sc) + + // 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the + // new epoch — carries the ban. Each edition chains onto the updated banlist head. + var chain = editions + for (target in removed) { + val banWrap = ConcordModeration.ban(ctx.signer, cp, sc.communityId.hexToByteArray(), target, chain, TimeUtils.now(), owner = sc.owner) + ctx.publish(banWrap, relays) + chain = chain + (ConcordActions.controlEditions(listOf(banWrap), cp)) + } + + // 2. Everyone we are keeping. See the note above on why this reaches past the roster. + val recipients = + (rosterOf(authority) + guestbookMembersOf(ctx, sc) + channelAuthorsOf(ctx, sc, state) + me) + .mapTo(HashSet()) { it.lowercase() } + .apply { + removeAll(removed) + removeAll(authority.bannedMembers().map { it.lowercase() }.toSet()) + }.toList() + + // 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff + // get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one). + val newRoot = RandomInstance.bytes(32) + val newControlRoot = RandomInstance.bytes(32) + val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } + val build = + ConcordActions.buildRefounding( + rotatorSigner = ctx.signer, + communityId = sc.communityId, + priorRoot = sc.root.hexToByteArray(), + newRoot = newRoot, + newControlRoot = newControlRoot, + rootEpoch = sc.rootEpoch, + priorControlWraps = controlWraps, + priorControlKeys = cp, + recipientsXOnly = recipients, + staffXOnly = authority.staffMembers(), + createdAt = TimeUtils.now(), + ownerPubKey = sc.owner, + ) + + // 4. The compacted plane (the new epoch's state) then the blobs (the key that opens it). + build.controlWraps.forEach { ctx.publish(it, relays) } + build.rekeyWraps.forEach { ctx.publish(it, relays) } + + // 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the + // epoch we are leaving for the anti-rollback floor. + val adopted = + ConcordReceive.withAdoptedRoot( + ConcordCommands.entryFor(loaded.community), + newRoot, + build.newEpoch, + build.newControlKeys.address.hexToByteArray(), + newControlRoot, + ) + ConcordStore(dataDir.concordFile).upsert(ConcordCommands.storedFrom(loaded.community, adopted)) + + Output.emit( + mapOf( + "community_id" to sc.communityId, + "removed" to removed.toList(), + "from_epoch" to sc.rootEpoch, + "root_epoch" to build.newEpoch, + "recipients" to recipients.size, + "control_wraps" to build.controlWraps.size, + "rekey_wraps" to build.rekeyWraps.size, + ), + ) + return 0 + } + } + + /** Owner + everyone holding a role — owner-rooted, so it cannot be padded from outside. */ + private fun rosterOf(authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver): Set = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() } + + /** Live Guestbook membership at this epoch (joins minus later leaves, CORD-02 §5). */ + private suspend fun guestbookMembersOf( + ctx: Context, + sc: StoredCommunity, + ): Set = + runCatching { + val gb = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + val relays = ConcordCommands.relaysFor(ctx, sc) + ctx.registerConcordStreamKeys(relays, listOf(gb.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(gb.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + ConcordActions.guestbookMembers(wraps, gb).mapTo(HashSet()) { it.lowercase() } + }.getOrDefault(emptySet()) + + /** + * Authors of every channel message we can decrypt. Most members never send a Guestbook motion, + * so without this a Refounding silently expels everyone who had only ever posted. + */ + private suspend fun channelAuthorsOf( + ctx: Context, + sc: StoredCommunity, + state: ConcordCommunityState, + ): Set { + val out = HashSet() + val relays = ConcordCommands.relaysFor(ctx, sc) + for ((channelIdHex, _) in state.channels) { + runCatching { + val key = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelIdHex.hexToByteArray(), sc.rootEpoch) + ctx.registerConcordStreamKeys(relays, listOf(key.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(key.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + ConcordActions.channelMessages(wraps, key, channelIdHex, sc.rootEpoch).mapTo(out) { it.author.lowercase() } + } + } + return out + } + /** Drain the control plane and return its keys + current editions to chain onto. */ private suspend fun load( ctx: Context, From 409339b375d34666be96a8d215f413efe52decd3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 19:06:39 -0400 Subject: [PATCH 191/227] feat(concord): re-mint invite links on Refounding so stranded recovery fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the liveness half of A2. Recovery's whole premise is that the community keeps re-minting its bundle at the SAME addressable coordinate, so the link a stranded member already holds starts pointing at the new epoch. Nothing did: `ConcordInviteBundle.mintLink` generates a fresh KeyPair and token per call, and no client persisted `linkSignerPrivKey`. Every mint was a new coordinate, so `recover` could only ever return `already_current` — the mechanism was dead code, and an owner evicted by a rogue admin had no way back. The kind-33301 bundle is addressable and authored by the link signer, so re-signing at that coordinate with the same token replaces what is there and every holder of that link keeps working. Exposes that as `ConcordActions.remintBundleAt`, persists the link signer + token in amy's store at mint time, and has `concord refound` refresh every link it minted for the new epoch. Re-minting every live link is safe precisely because the security half is already in: `refound` bans the removed members on the way out, and `recover` reads the banlist of the epoch being LEFT, so a removed member's own recovery is refused even though their link now resolves. That gate stops being belt-and-braces here and becomes load-bearing — which is what the audit predicted for any client that re-mints (Armada does). Verified end to end against a loopback geode, both directions: bob joins by link, holds no role, never posts (unfindable by a rotation) alice refound --remove → recipients=1, invites_refreshed=1 bob rekey → no_blob_for_us (genuinely stranded) bob recover → recovered, epoch 0 → 1 ← first time this has ever fired bob reads the community at the new epoch alice refound --remove bob → epoch 1 → 2, invites_refreshed=1 bob recover → refused, reason "banned", still at epoch 1 Still open for the shipping client: Amethyst persists no link signer, so A2 liveness remains open on Android. Doing it there means deciding where the secret lives in the kind-13302 list, which Armada also reads — a wire-schema call, not a code one. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/cli/commands/ConcordCommands.kt | 15 +++++++ .../cli/commands/ConcordModCommands.kt | 40 ++++++++++++++++++- .../amethyst/cli/stores/ConcordStore.kt | 16 ++++++++ .../commons/actions/ConcordActions.kt | 23 +++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 1e299b4191..3e06aa5b72 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot +import com.vitorpamplona.amethyst.cli.stores.StoredMintedInvite import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -265,6 +266,20 @@ object ConcordCommands { val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it } + // Keep the link signer + token so a later Refounding can refresh THIS coordinate rather + // than orphaning the link at a dead epoch — the liveness half of stranded recovery (A2). + ConcordStore(dataDir.concordFile).upsert( + sc.copy( + mintedInvites = + sc.mintedInvites + + StoredMintedInvite( + linkSignerPrivKey = minted.linkSignerPrivKey.toHexKey(), + token = minted.token.toHexKey(), + createdAt = TimeUtils.now(), + ), + ), + ) + Output.emit( mapOf( "url" to minted.url, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index a9e8b0d2d8..342ae2501d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -322,7 +322,44 @@ object ConcordModCommands { build.newControlKeys.address.hexToByteArray(), newControlRoot, ) - ConcordStore(dataDir.concordFile).upsert(ConcordCommands.storedFrom(loaded.community, adopted)) + val stored = ConcordCommands.storedFrom(loaded.community, adopted) + ConcordStore(dataDir.concordFile).upsert(stored) + + // 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new + // epoch. This is the liveness half of stranded recovery (A2): a member this Refounding + // left out has no rekey blob and no message to miss, so re-resolving their link is the + // only way back — and it only works if the bundle moves with the community instead of + // being orphaned at a dead epoch. Minting a fresh link would not help them; the link + // they hold is the one that must move. + // + // Safe for every link because recovery is ban-gated at the epoch being left, and step 1 + // banned everyone being removed — so a removed member's own `recover` is refused even + // though their link now resolves. + val refreshedInvite = + ConcordActions.inviteFor( + stored.communityId, + stored.owner, + stored.ownerSalt, + stored.root, + stored.rootEpoch, + stored.name, + stored.relays, + stored.controlPk.ifBlank { null }, + ) + var refreshed = 0 + for (link in stored.mintedInvites) { + runCatching { + val event = + ConcordActions.remintBundleAt( + linkSignerPrivKey = link.linkSignerPrivKey.hexToByteArray(), + token = link.token.hexToByteArray(), + invite = refreshedInvite, + createdAt = TimeUtils.now(), + ) + ctx.publish(event, relays) + refreshed++ + } + } Output.emit( mapOf( @@ -333,6 +370,7 @@ object ConcordModCommands { "recipients" to recipients.size, "control_wraps" to build.controlWraps.size, "rekey_wraps" to build.rekeyWraps.size, + "invites_refreshed" to refreshed, ), ) return 0 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index d2de85a258..e08bfcc8f8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -53,6 +53,22 @@ data class StoredCommunity( // rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct // invite or a community joined before amy stored it. val inviteRef: String = "", + // Invite links WE minted for this community, kept so a Refounding can re-publish each bundle at + // its own coordinate for the new epoch. Without this the link a member joined through points at + // a dead epoch forever and stranded recovery can never fire (A2). Holds link-signer secrets, so + // it sits beside `root`/`controlRoot` in the same already-secret file. + val mintedInvites: List = emptyList(), +) + +/** + * One invite link this account minted: enough to re-sign at its addressable coordinate later. The + * coordinate is the link signer's pubkey, so keeping the private key is what lets a Refounding + * refresh the link (and, in future, revoke it) instead of orphaning it. + */ +data class StoredMintedInvite( + val linkSignerPrivKey: String = "", + val token: String = "", + val createdAt: Long = 0, ) /** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index e70fe18364..6263c65add 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -436,6 +436,29 @@ object ConcordActions { relays: List? = null, ): MintedInviteLink = ConcordInviteBundle.mintLink(base, invite, createdAt, relays) + /** + * Re-publishes a bundle at an **existing** link's coordinate, carrying [invite] refreshed for the + * current epoch (CORD-05 §1). The kind-33301 bundle is addressable and authored by the link + * signer, so re-signing with the same [linkSignerPrivKey] and re-encrypting under the same + * [token] replaces what is there — every holder of that link keeps working, now pointing at the + * new root. + * + * This is what makes stranded recovery live: a member a Refounding left out has no rekey blob and + * no message to miss, and re-resolving their link is the only way back — which requires the + * community to re-mint at the *same* coordinate rather than issuing a fresh link. Minting a new + * link leaves the old one pointing at a dead epoch forever. + * + * Safe to call for every live link because recovery is ban-gated at the epoch being left + * (CORD-06, A2): a member the Refounding removed was banned on the way out, so their own + * `recover` is refused even though their link now resolves. + */ + fun remintBundleAt( + linkSignerPrivKey: ByteArray, + token: ByteArray, + invite: CommunityInvite, + createdAt: Long, + ): Event = ConcordInviteBundle.build(linkSignerPrivKey, token, invite, createdAt) + /** Parses a shareable invite URL into its pointer + private fragment. */ fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url) From 3b7ffcd06c13ee7405ae3f1552e419775f702b0e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 23:38:18 +0000 Subject: [PATCH 192/227] Make a paged walk terminate: floor the cursor at 0, stop when a relay ignores it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fetchAllPages` could not end against a relay that does not honour `until`. Found in production on purplepag.es, which holds twelve `kind 10002` events stamped `created_at = 0` and treats `until <= 0` as *no* `until` — so the page below them comes back with its five hundred NEWEST events. None of those matches the filter's own `until`, so the page delivers nothing, which read as "the boundary second is too dense to page", stepped one second lower, and asked the identical unanswerable question again. Measured against the live relay: ~5.5 pages a second, 500 events fetched and discarded on each, an EOSE on every single page, `until` marching one second further negative every time, for as long as the process ran. A cold walk pulled 1,490,010 real events in ~10.8 minutes and then never returned, so the caller's coverage was never recorded and the next boot re-walked all of it. Not a rate limit: ~1,000 consecutive pages drew no NOTICE, no CLOSED and no throttling. Two guards, both at the points where the cursor moves: - The cursor floors at zero. `created_at` is unsigned, so nothing can exist below epoch 0: a cursor that would step under it has reached the bottom of the time axis and the walk is DRAINED. `until = 0` is still asked — it is a legal query and the boundary re-fetch for epoch-stamped events — only going BELOW it ends the walk. This also keeps a negative `until` off the wire, which relays disagree violently about: measured across five, one CLOSEs the subscription with a parse error, three answer a NOTICE and then never EOSE, and one drops the bound and serves its newest events. The floor is applied on the advance path too, not just the step: `pageMinTs` is an event's own `created_at`, so one relay serving a negative timestamp is enough to drive the cursor under zero, and clamping rather than stopping would not help — such an event never equals the boundary, so it dodges the dedup and returns on every page. - A relay that ignores the cursor is UNPAGEABLE. When a page delivers nothing and every event it received was NEWER than the `until` it asked for, the relay is not paging at all and stepping one second lower just repeats the question. `aboveBoundary == received` is what tells this apart from a genuinely dense boundary second, whose events are AT the boundary rather than above it. UNPAGEABLE is deliberate and conservative: it proves nothing about what the relay holds, so no coverage claim can be built on a page the relay never really answered. This second guard is the structural fix. Giving the filter a `since` does not substitute for it: the step path decrements `until` without regard to `since`, so a cursor-ignoring relay still walks from the window floor down to 0 — up to ~1.5 billion pages. A `since` only helps when the relay honours it, and then only because the empty page arrives as a drain. Three scripted tests cover both guards, and CursorTerminationProbe dials the five relays that found this (opt-in, `-PprodRelayBench=1`, asserts nothing). Against the live relays after the change: purplepag.es ends UNPAGEABLE in one page and 2.4s with the cursor never going under 0, where it previously ran 244 pages to `until = -243` without ending; the other four still DRAIN unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HPSzniNdvJxkhRsCe1QcyT --- .../NostrClientFetchAllPagesExt.kt | 73 ++++++++ .../NostrClientFetchAllPagesDrainTest.kt | 111 ++++++++++++ .../relay/prodbench/CursorTerminationProbe.kt | 167 ++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/CursorTerminationProbe.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index 203311e8e4..e5e98116f8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -134,6 +134,28 @@ data class PagedFetchResult( * cap, so a larger value is clamped to the same page). Stepping past at least keeps * the download progressing to older events instead of stalling forever. * + * **Two guards keep that step from becoming a walk that never ends**, both learned + * from a relay in production rather than from reasoning: + * + * - **The cursor floors at zero.** `created_at` is an unsigned timestamp, so nothing + * can exist below epoch 0: a cursor that would step under it has reached the bottom + * of the time axis and the walk is [PagedFetchResult.End.DRAINED]. `until = 0` + * itself is still asked — it is a legal query, and the boundary re-fetch for events + * stamped at the epoch — it is only going *below* it that ends the walk. This also + * keeps a negative `until` off the wire, which relays disagree violently about: + * measured across five, one CLOSEs the subscription with a parse error, three + * answer a `NOTICE` and then never EOSE, and one drops the bound and serves its + * NEWEST events. + * - **A relay that ignores the cursor is [PagedFetchResult.End.UNPAGEABLE].** If a + * page delivered nothing and every event it received was NEWER than the `until` it + * asked for, the relay is not paging at all, and stepping one second lower just + * asks the same unanswered question again. That is exactly how the first guard's + * relay behaves — it treats `until <= 0` as no `until` — and without this the walk + * ran ~5.5 pages a second, 500 events fetched and discarded on each, EOSE on every + * one, for as long as the process lived. UNPAGEABLE is deliberate and conservative: + * it proves nothing about what the relay holds, so no coverage claim can be built + * on a page the relay never really answered. + * * A `search` ([Filter.search]) filter is the exception: NIP-50 results are ranked by * relevance, not `created_at`, so paging one by a `until` cursor is meaningless — it * would silently turn a top-N search into a time-walk, and never terminate against a @@ -259,6 +281,14 @@ suspend fun INostrClient.fetchAllPages( val boundary = until var received = 0 var delivered = 0 + + /** + * Events that came back NEWER than the `until` this page asked for — which an + * honest relay never sends. Counted because it is the only way to tell a relay + * that ignored the cursor apart from a boundary second too dense to page: both + * deliver nothing, and only one of them can be fixed by stepping past. + */ + var aboveBoundary = 0 var pageMinTs = Long.MAX_VALUE val idsAtPageMin = HashSet() @@ -289,6 +319,9 @@ suspend fun INostrClient.fetchAllPages( // early) or an unsafely published `idsAtPageMin`. try { received++ + // Before the dedup return, so it is counted for every event + // the page received, not just the ones that reach the match. + if (boundary != null && event.createdAt > boundary) aboveBoundary++ // Drop a boundary-second event we already delivered on an // earlier page (the inclusive re-fetch returns it again). if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return @@ -414,6 +447,35 @@ suspend fun INostrClient.fetchAllPages( // are resolved by stepping strictly past it. `boundary` is null only on // the first page, which has no dedup and so can't be all-duplicate. val step = boundary ?: break // first page, all-duplicate: impossible, and `end` stays UNPAGEABLE + + // The relay is not honouring `until`: every event it sent was NEWER than + // the cursor this page asked for. Stepping past cannot help — the next + // page repeats the same ask one second lower and gets the same answer, + // forever. Measured on a live relay (purplepag.es, which treats + // `until <= 0` as no `until` and answers with its newest page): ~5.5 + // pages a second, 500 events fetched and discarded on each, `until` + // marching one second further negative every time, an EOSE on every + // single page, for as long as the process ran. This is the ONE reading + // that ends it, and it is safely conservative — UNPAGEABLE proves + // nothing about what the relay holds, so no coverage claim is built on + // a page the relay never actually answered. + if (aboveBoundary == received) { + end = PagedFetchResult.End.UNPAGEABLE + break + } + + // Below the boundary there is nothing left to ask for: `created_at` is an + // unsigned timestamp, so no event can exist under epoch 0 and a cursor + // stepping past it has reached the bottom of the time axis. Ending here + // rather than sending `until = -1` also keeps a value off the wire that + // relays disagree violently about — measured across five: one CLOSEs the + // subscription with a parse error, three answer a NOTICE and then never + // EOSE (so every page burns a whole idle timeout), one drops the bound + // and serves its newest events. + if (step <= 0L) { + end = PagedFetchResult.End.DRAINED + break + } until = step - 1 seenAtBoundary = HashSet() continue @@ -432,6 +494,17 @@ suspend fun INostrClient.fetchAllPages( // termination both rely on `until` never increasing. Honest relays only // return events at-or-below `until`, so this is a no-op for them. val nextUntil = if (boundary != null) minOf(pageMinTs, boundary) else pageMinTs + + // The same floor as the step above, on the other way the cursor moves. It is + // reachable here too, and not only through a bug: `pageMinTs` is an event's + // own `created_at`, so one relay serving a negative timestamp is enough to + // put the cursor under zero. Clamping to 0 instead of stopping would not + // help — such an event never equals the boundary, so it dodges the dedup and + // comes back on every page, pinning the walk there for good. + if (nextUntil < 0L) { + end = PagedFetchResult.End.DRAINED + break + } if (boundary != null && nextUntil == boundary) { seenAtBoundary.addAll(idsAtPageMin) } else { diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt index f2de98b505..bc949a4826 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt @@ -224,4 +224,115 @@ class NostrClientFetchAllPagesDrainTest { assertEquals(PagedFetchResult.End.LIMIT_REACHED, result.end, "a fulfilled limit is the caller stopping, not the corpus ending") assertFalse(result.drained) } + + // ---- termination: the walk must END, whatever the relay does ------------- + + @Test + fun aRelayThatIgnoresTheCursorEndsTheWalkInsteadOfSteppingForever() = + runBlocking { + // The production bug, scripted. purplepag.es holds events stamped + // `created_at = 0` and treats `until <= 0` as NO `until`, so the page + // below them comes back with its NEWEST events instead. None of those + // matches the filter's own `until`, so the page delivers nothing — + // which used to read as "the boundary second is too dense", step one + // second lower, and ask the identical unanswerable question again. + // Measured against the live relay: ~5.5 pages a second, 500 events + // discarded on each, an EOSE on every one, for as long as the process + // ran. `aboveBoundary == received` is what tells the two apart. + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(1000), false, relay, null) + client.listener!!.onEose(relay, null) + + // Page two asks for `until = 1000` and gets events from the top + // of the corpus — the answer to a query nobody made. + client.awaitPage(2) + client.listener!!.onEvent(event(9000), false, relay, null) + client.listener!!.onEvent(event(8000), false, relay, null) + client.listener!!.onEose(relay, null) + } + + val result = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + ) { } + feeder.join() + + assertEquals(2, result.downloaded, "only the two events the relay actually answered for") + assertEquals(2, client.subscribeCount, "and it stops on the FIRST page the relay refused to page") + assertEquals(PagedFetchResult.End.UNPAGEABLE, result.end, "a relay ignoring `until` is not paging, and cannot be stepped past") + assertFalse(result.drained, "which proves nothing about what it holds, so no coverage may be claimed") + } + + @Test + fun aCursorSteppingUnderTheEpochDrainsInsteadOfGoingNegative() = + runBlocking { + // `created_at` is unsigned, so nothing exists below epoch 0. A boundary + // second AT the epoch that only ever returns duplicates has reached the + // bottom of the time axis: the walk is done, and `until = -1` must never + // reach a relay — one of the five indexers CLOSEs the subscription over + // it, three answer a NOTICE and then never EOSE. + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(0), false, relay, null) + client.listener!!.onEose(relay, null) + + // Page two re-asks the boundary inclusively and gets back only + // the event page one already delivered: nothing new, and nowhere + // left below to step to. + client.awaitPage(2) + client.listener!!.onEvent(event(0), false, relay, null) + client.listener!!.onEose(relay, null) + } + + val result = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + ) { } + feeder.join() + + assertEquals(1, result.downloaded, "the epoch event, delivered once") + assertEquals(2, client.subscribeCount, "no third page: there is nothing under zero to ask for") + assertEquals(PagedFetchResult.End.DRAINED, result.end, "the bottom of the time axis is an end, not a stall") + assertTrue(result.drained) + } + + @Test + fun anEventStampedBeforeTheEpochCannotPinTheWalk() = + runBlocking { + // `pageMinTs` is an event's own `created_at`, so one relay serving a + // negative timestamp drives the cursor under zero on the ADVANCE path + // rather than the step path. Clamping to 0 would not save it: such an + // event never equals the boundary, so it dodges the dedup and comes + // back on every page, pinning the walk at 0 for good. + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(-5), false, relay, null) + client.listener!!.onEose(relay, null) + } + + val result = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + ) { } + feeder.join() + + assertEquals(2, result.downloaded, "both events are still delivered — they were received") + assertEquals(1, client.subscribeCount, "but there is no second page to ask") + assertEquals(PagedFetchResult.End.DRAINED, result.end, "below the epoch there is nothing left to walk") + } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/CursorTerminationProbe.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/CursorTerminationProbe.kt new file mode 100644 index 0000000000..a0efae418e --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/CursorTerminationProbe.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.prodbench + +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import java.time.Duration +import kotlin.test.Test + +/** + * The live half of the paging termination guards, against the relay that found + * them. [NostrClientFetchAllPagesDrainTest] scripts this behaviour, so it pins our + * INTERPRETATION of a relay; only dialling one can say whether the interpretation + * matches anything real. + * + * ## What it walks, and why that relay + * + * purplepag.es holds twelve `kind 10002` events stamped `created_at = 0` and treats + * `until <= 0` as *no* `until`, answering with its five hundred NEWEST events. A + * cursor walk therefore reaches zero, gets a page it never asked for, delivers none + * of it, and — before the guards — stepped one second lower and asked again. Measured + * against the live relay: ~5.5 pages a second, 500 events fetched and discarded on + * each, an EOSE on *every* page, `until` marching one second further negative every + * time, for as long as the process ran. A cold walk pulled 1,490,010 real events in + * ~10.8 minutes and then never returned. + * + * The ceiling is set just above the epoch-stamped events rather than `now` on + * purpose: this relay serves ~2,300 kind 0/10002 events a second and holds years of + * them, so starting at the top would spend a quarter of an hour on history that is + * not what this measures. One page from [TRAP_CEILING] already carries them. + * + * ## Reading it + * + * `lowest until` is the whole tell. A walk that ends leaves it at a real timestamp; a + * walk that cannot end leaves it below zero. With the guards in place the expected + * report is `UNPAGEABLE` with the cursor never going under `0`. + * + * OFF by default and not a gate: it dials the public internet, so it is neither + * hermetic nor reproducible, and a relay being down is not a code regression. It + * asserts nothing for that reason — it REPORTS, and a human reads it. + * + * ``` + * ./gradlew :quartz:jvmTest --tests "*.CursorTerminationProbe" -PprodRelayBench=1 -i + * ``` + */ +class CursorTerminationProbe { + @Test + fun reportWhetherAPagedWalkTerminates() { + if (System.getenv("PROD_RELAY_BENCH") == null && System.getProperty("prodRelayBench") == null) { + println("reportWhetherAPagedWalkTerminates skipped. Run with -PprodRelayBench=1 to enable.") + return + } + val okhttp = + OkHttpClient + .Builder() + .connectTimeout(Duration.ofSeconds(20)) + .pingInterval(Duration.ofSeconds(120)) + .build() + val scope = CoroutineScope(SupervisorJob()) + val client = NostrClient(BasicOkHttpWebSocket.Builder { okhttp }, scope) + + println("=".repeat(78)) + println("Does a paged walk TERMINATE? kinds [0, 10002], from $TRAP_CEILING down") + println("=".repeat(78)) + try { + for (url in RELAYS) { + val relay = RelayUrlNormalizer.normalize(url) + var events = 0 + var pages = 0 + var lowest = Long.MAX_VALUE + val startedAt = System.currentTimeMillis() + val outcome = + runCatching { + runBlocking { + // A hard ceiling, which `fetchAllPages` deliberately does + // not have: its own doc says a walk is bounded by a + // `limit` or by cancelling the caller, and this is the + // caller cancelling. Without it a relay with no guard + // hangs the probe — which is exactly what it is here to + // detect, so it must be detected rather than suffered. + withTimeoutOrNull(TERMINATION_MS) { + client.fetchAllPages( + relay, + listOf(Filter(kinds = listOf(0, 10002), until = TRAP_CEILING)), + idleTimeoutMs = 20_000L, + onNewPage = { until -> + pages++ + if (until < lowest) lowest = until + }, + ) { events++ } + } + } + } + val took = System.currentTimeMillis() - startedAt + val verdict = + outcome.fold( + onSuccess = { r -> + when (r) { + null -> "NEVER ENDED in ${TERMINATION_MS / 1000}s — THE GUARD IS NOT WORKING" + else -> "${r.end} (${r.downloaded} event(s), drained=${r.drained})" + } + }, + onFailure = { "threw ${it::class.simpleName}: ${it.message}" }, + ) + val reached = if (lowest == Long.MAX_VALUE) "no page after the first" else "$lowest" + println(" %-26s %-52s".format(url.removePrefix("wss://"), verdict)) + println(" %-26s %d page(s), %d event(s), %dms, lowest until=%s".format("", pages, events, took, reached)) + } + } finally { + runCatching { client.disconnect() } + scope.cancel() + } + println("=".repeat(78)) + } + + companion object { + /** + * purplepag.es is the one that found this. The other four are controls: they + * hold nothing at all below `1.5e9`, so they drain in a single page and prove + * the guards did not change an ordinary walk. + */ + private val RELAYS = + listOf( + "wss://purplepag.es", + "wss://user.kindpag.es", + "wss://directory.yabu.me", + "wss://profiles.nostr1.com", + "wss://indexer.coracle.social", + ) + + /** Just above the `created_at = 0` events, so one page reaches the cursor that matters. */ + private const val TRAP_CEILING = 1_600_000_000L + + /** + * Not an idle timeout — the relay answers, with an EOSE, the entire time. + * This is how long a walk gets to prove it can END. + */ + private const val TERMINATION_MS = 45_000L + } +} From 7ca4fbab337357f09afbcc0c8acdb9315f7dccb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 23:48:56 +0000 Subject: [PATCH 193/227] Cover the dense-second step-past the new guard sits in front of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step-past path had no test, and it is the one thing the ignored-cursor guard could plausibly break: both cases reach the same `delivered == 0` branch. They are told apart by WHERE the events landed — a dense boundary second returns them AT the boundary, so `aboveBoundary` stays 0 while `received` is 1 and the guard holds its fire; only a relay answering ABOVE the boundary is not paging at all. Scripted end to end: a second the relay's page cap can only ever return the head of, the step strictly past it, and the empty EOSEd page below. Also proves the documented cost is still paid rather than silently changed — the unreachable tail of that second is lost, and `downloaded` says so. `event()` grows a nonce so two events can share one `created_at`; the id was derived from the timestamp alone, which collapsed them into one event and made a dense second impossible to script. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HPSzniNdvJxkhRsCe1QcyT --- .../NostrClientFetchAllPagesDrainTest.kt | 71 ++++++++++++++++--- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt index bc949a4826..69b088dc40 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFetchAllPagesDrainTest.kt @@ -75,16 +75,23 @@ class NostrClientFetchAllPagesDrainTest { private val relay = RelayUrlNormalizer.normalize("wss://drain.example.com") - private fun event(createdAt: Long) = - Event( - id = createdAt.toString(16).padStart(64, '0'), - pubKey = "f".repeat(64), - createdAt = createdAt, - kind = 1, - tags = emptyArray(), - content = "e$createdAt", - sig = "0".repeat(128), - ) + /** + * [nonce] distinguishes two events sharing one `created_at`, which the id would + * otherwise collapse into the same event — and a boundary second holding more + * than one is the whole subject of the dense-second test below. + */ + private fun event( + createdAt: Long, + nonce: String = "", + ) = Event( + id = (createdAt.toString(16) + nonce).padStart(64, '0'), + pubKey = "f".repeat(64), + createdAt = createdAt, + kind = 1, + tags = emptyArray(), + content = "e$createdAt$nonce", + sig = "0".repeat(128), + ) @Test fun anEmptyPageConfirmedByEoseDrains() = @@ -227,6 +234,50 @@ class NostrClientFetchAllPagesDrainTest { // ---- termination: the walk must END, whatever the relay does ------------- + @Test + fun aBoundarySecondDenserThanAPageIsStillSteppedPast() = + runBlocking { + // The step-past path itself, which had no test and which the + // ignored-cursor guard now sits in front of. The two look identical + // from `delivered == 0` and must NOT be treated alike: a dense second + // returns events AT the boundary, so `aboveBoundary` stays 0 while + // `received` is 1, the guard holds its fire, and the walk steps past + // exactly as before. Only a relay answering ABOVE the boundary — which + // is not paging at all — trips it. + val client = ScriptedClient() + val feeder = + launch { + client.awaitPage(1) + client.listener!!.onEvent(event(2000), false, relay, null) + client.listener!!.onEvent(event(1000, "a"), false, relay, null) + client.listener!!.onEose(relay, null) + + // Page two re-asks second 1000 inclusively. The relay's page cap + // hands back the same head of that second — event "b" living + // there too can never be reached. Nothing new: stuck. + client.awaitPage(2) + client.listener!!.onEvent(event(1000, "a"), false, relay, null) + client.listener!!.onEose(relay, null) + + // So the walk steps strictly past to 999 and finds the corpus + // ends there. + client.awaitPage(3) + client.listener!!.onEose(relay, null) + } + + val result = + client.fetchAllPages( + relay = relay, + filters = listOf(Filter(kinds = listOf(1))), + idleTimeoutMs = 2_000, + ) { } + feeder.join() + + assertEquals(2, result.downloaded, "the duplicate is dropped, the dense second's tail is the documented loss") + assertEquals(3, client.subscribeCount, "it stepped past the stuck second instead of stopping on it") + assertEquals(PagedFetchResult.End.DRAINED, result.end, "and reached a genuinely empty, EOSEd page below it") + } + @Test fun aRelayThatIgnoresTheCursorEndsTheWalkInsteadOfSteppingForever() = runBlocking { From 293ffd0bc57daa134cea5ddac1efe0c788a62b45 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 19:50:56 -0400 Subject: [PATCH 194/227] feat(concord): implement the CORD-05 Invite List (kind 13303), wire-compatible with Armada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit kept link secrets in amy's local store, which made link refresh work but only for that one client. The spec already defines where they belong, and Armada implements it, so this replaces the local field with the real cross-client document. Kind 13303, replaceable, NIP-44-encrypted to self — the creator's private bookkeeping: { "entries": [ { "token", "signer_sk", "community_id", "url", "label?", "created_at", "expires_at?" } ], "tombstones": [ { "token", "community_id" } ] } `token` is both the link's unlock secret and the merge key; `signer_sk` is what lets any of the creator's clients re-sign at that link's addressable coordinate. Armada types both the entry and the tombstone as `[k: string]: unknown`, so unknown keys are contract: the codec preserves entry-, tombstone- and document-level residue, and re-encoding never deletes another client's data. Merge is by token, read-merge-write rather than overwrite — the list is replaceable and per-creator, so two devices minting concurrently would otherwise destroy each other's `signer_sk`, which is unrecoverable. A token tombstoned on either side stays dropped, so a stale device cannot resurrect a retired link. Registers 13303 in EventFactory (without it the kind deserializes as a plain Event and every typed read fails), and points amy's mint and Refounding refresh at the list instead of its own store. Semantics were taken from the spec and confirmed against Armada's observable behaviour — read for semantics only, never copied: Armada is AGPLv3 and Amethyst is MIT. Verified against a loopback geode: `concord invite` publishes an encrypted, untagged 13303; a Refounding reads it back, refreshes the live links, and a member with no role who never posted — unfindable by any rotation — recovers epoch 0 → 1 through the link he already held. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/cli/commands/ConcordCommands.kt | 71 +++++- .../cli/commands/ConcordModCommands.kt | 11 +- .../amethyst/cli/stores/ConcordStore.kt | 16 -- .../cord05Invites/ConcordInviteList.kt | 209 ++++++++++++++++++ .../cord05Invites/ConcordInviteListEvent.kt | 80 +++++++ .../quartz/utils/EventFactory.kt | 2 + .../cord05Invites/ConcordInviteListTest.kt | 133 +++++++++++ 7 files changed, 491 insertions(+), 31 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 3e06aa5b72..76e7a8d59b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -27,7 +27,6 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot -import com.vitorpamplona.amethyst.cli.stores.StoredMintedInvite import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -35,6 +34,10 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -266,18 +269,25 @@ object ConcordCommands { val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it } - // Keep the link signer + token so a later Refounding can refresh THIS coordinate rather - // than orphaning the link at a dead epoch — the liveness half of stranded recovery (A2). - ConcordStore(dataDir.concordFile).upsert( - sc.copy( - mintedInvites = - sc.mintedInvites + - StoredMintedInvite( - linkSignerPrivKey = minted.linkSignerPrivKey.toHexKey(), - token = minted.token.toHexKey(), - createdAt = TimeUtils.now(), + // Record the link in the CORD-05 Invite List (kind 13303) so any of this creator's + // clients — Amethyst, Armada — can later refresh THIS coordinate instead of orphaning + // the link at a dead epoch. That list is the liveness half of stranded recovery (A2). + publishInviteList( + ctx, + extraRelays = relaysFor(ctx, sc), + patch = + ConcordInviteListDocument( + entries = + listOf( + ConcordInviteListEntry( + token = minted.token.toHexKey(), + signerSk = minted.linkSignerPrivKey.toHexKey(), + communityId = sc.communityId, + url = minted.url, + createdAt = TimeUtils.now(), + ), ), - ), + ), ) Output.emit( @@ -570,6 +580,43 @@ object ConcordCommands { } } + /** + * This account's CORD-05 Invite List (kind 13303) — the creator's private, self-encrypted record + * of every link they minted, so a rotation can refresh those links instead of orphaning them. + * Empty when none was ever published. + */ + suspend fun readInviteList( + ctx: Context, + extraRelays: Set = emptySet(), + ): ConcordInviteListDocument { + val relays = ctx.outboxRelays() + extraRelays + if (relays.isEmpty()) return ConcordInviteListDocument.EMPTY + val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(ctx.signer.pubKey)) + val newest = + ctx + .drain(relays.associateWith { listOf(filter) }) + .map { it.second } + .maxByOrNull { it.createdAt } + return (newest as? ConcordInviteListEvent)?.decrypt(ctx.signer) ?: ConcordInviteListDocument.EMPTY + } + + /** + * Merges [patch] into the published list and republishes it. Read-merge-write rather than + * overwrite: the list is replaceable and per-creator, so two devices minting concurrently would + * otherwise delete each other's links (and their `signer_sk`, which is unrecoverable). + */ + suspend fun publishInviteList( + ctx: Context, + patch: ConcordInviteListDocument, + extraRelays: Set = emptySet(), + ) { + val relays = ctx.outboxRelays() + extraRelays + if (relays.isEmpty()) return + val merged = ConcordInviteList.merge(readInviteList(ctx, extraRelays), patch) + val event = ConcordInviteListEvent.create(ctx.signer, merged, TimeUtils.now()) + ctx.publish(event, relays) + } + fun notFound(handle: String): Int { Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`") return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 342ae2501d..23ec2a47ef 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -346,15 +346,20 @@ object ConcordModCommands { stored.relays, stored.controlPk.ifBlank { null }, ) + val now = TimeUtils.now() var refreshed = 0 - for (link in stored.mintedInvites) { + for (link in ConcordCommands.readInviteList(ctx, relays).entries) { + if (link.communityId != stored.communityId) continue + // An elapsed link can no longer be joined, so re-posting it would only resurrect a + // dead URL at a live epoch (CORD-05). + if (link.isExpired(now)) continue runCatching { val event = ConcordActions.remintBundleAt( - linkSignerPrivKey = link.linkSignerPrivKey.hexToByteArray(), + linkSignerPrivKey = link.signerSk.hexToByteArray(), token = link.token.hexToByteArray(), invite = refreshedInvite, - createdAt = TimeUtils.now(), + createdAt = now, ) ctx.publish(event, relays) refreshed++ diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index e08bfcc8f8..d2de85a258 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -53,22 +53,6 @@ data class StoredCommunity( // rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct // invite or a community joined before amy stored it. val inviteRef: String = "", - // Invite links WE minted for this community, kept so a Refounding can re-publish each bundle at - // its own coordinate for the new epoch. Without this the link a member joined through points at - // a dead epoch forever and stranded recovery can never fire (A2). Holds link-signer secrets, so - // it sits beside `root`/`controlRoot` in the same already-secret file. - val mintedInvites: List = emptyList(), -) - -/** - * One invite link this account minted: enough to re-sign at its addressable coordinate later. The - * coordinate is the link signer's pubkey, so keeping the private key is what lets a Refounding - * refresh the link (and, in future, revoke it) instead of orphaning it. - */ -data class StoredMintedInvite( - val linkSignerPrivKey: String = "", - val token: String = "", - val createdAt: Long = 0, ) /** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt new file mode 100644 index 0000000000..3eea2095c3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.jsonObject + +private val NoExtras: JsonObject = JsonObject(emptyMap()) + +/** + * One minted invite link, as the creator's own bookkeeping (CORD-05, kind 13303). + * + * [token] is both the link's unlock secret and the **merge key** across devices, and [signerSk] is + * the link signer's private key — which is what makes a link *refreshable*. The kind-33301 bundle is + * addressable and authored by that signer, so re-posting under it moves the link to the current + * epoch without changing the URL anyone already holds. Lose the secret and the link is orphaned at a + * dead epoch forever, which is what made stranded recovery unreachable in practice. + * + * [residue] carries wire keys this build does not model. Armada types both the entry and the + * tombstone as `[k: string]: unknown`, so unknown keys are part of the contract: dropping them on a + * re-encode deletes another client's data. + */ +class ConcordInviteListEntry( + val token: String, + val signerSk: String, + val communityId: String, + val url: String, + val label: String? = null, + val createdAt: Long = 0, + val expiresAt: Long? = null, + val residue: JsonObject = NoExtras, +) { + /** True when this link can no longer be joined, so it must not be refreshed (CORD-05). */ + fun isExpired(nowSecs: Long): Boolean = expiresAt != null && expiresAt <= nowSecs +} + +/** A retired link: the creator's record that [token] is gone, kept so a merge cannot resurrect it. */ +class ConcordInviteListTombstone( + val token: String, + val communityId: String, + val residue: JsonObject = NoExtras, +) + +/** The decoded kind-13303 document: live [entries], [tombstones], and document-level [residue]. */ +class ConcordInviteListDocument( + val entries: List = emptyList(), + val tombstones: List = emptyList(), + val residue: JsonObject = NoExtras, +) { + companion object { + val EMPTY = ConcordInviteListDocument() + } +} + +/** + * Codec + merge for the CORD-05 Invite List (kind 13303), the creator's private, NIP-44 self- + * encrypted bookkeeping of the links they minted. Wire-compatible with Armada's `invite.ts`: + * + * ```jsonc + * { "entries": [ { "token", "signer_sk", "community_id", "url", "label?", "created_at", "expires_at?" } ], + * "tombstones": [ { "token", "community_id" } ] } + * ``` + */ +object ConcordInviteList { + private const val EXTRAS = "__extras" + + /** Wraps a generated serializer so unknown keys survive a decode → modify → encode. */ + private open class ExtrasPreserving( + delegate: KSerializer, + ) : JsonTransformingSerializer(delegate) { + @OptIn(ExperimentalSerializationApi::class) + private val known = delegate.descriptor.elementNames.toSet() - EXTRAS + + override fun transformDeserialize(element: JsonElement): JsonElement { + val obj = element as? JsonObject ?: return element + val extras = obj.filterKeys { it !in known } + if (extras.isEmpty()) return obj + return JsonObject(obj.filterKeys { it in known } + (EXTRAS to JsonObject(extras))) + } + + override fun transformSerialize(element: JsonElement): JsonElement { + val obj = element as? JsonObject ?: return element + val extras = obj[EXTRAS]?.jsonObject ?: return obj + return JsonObject(extras + (obj - EXTRAS)) + } + } + + @Serializable + private class WireEntry( + val token: String = "", + @SerialName("signer_sk") val signerSk: String = "", + @SerialName("community_id") val communityId: String = "", + val url: String = "", + val label: String? = null, + @SerialName("created_at") val createdAt: Long = 0, + @SerialName("expires_at") val expiresAt: Long? = null, + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, + ) + + @Serializable + private class WireTombstone( + val token: String = "", + @SerialName("community_id") val communityId: String = "", + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, + ) + + private object WireEntrySerializer : ExtrasPreserving(WireEntry.serializer()) + + private object WireTombstoneSerializer : ExtrasPreserving(WireTombstone.serializer()) + + @Serializable + private class WireDocument( + val entries: List< + @Serializable(WireEntrySerializer::class) + WireEntry, + > = emptyList(), + val tombstones: List< + @Serializable(WireTombstoneSerializer::class) + WireTombstone, + > = emptyList(), + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, + ) + + private object WireDocumentSerializer : ExtrasPreserving(WireDocument.serializer()) + + /** + * Decodes the plaintext document. A malformed document yields [ConcordInviteListDocument.EMPTY] + * rather than throwing — but note the sharp edge this shape shares with the community list: one + * unparseable entry aborts the whole array, so every field defaults instead of being required. + */ + fun decode(json: String): ConcordInviteListDocument = + try { + val doc = ConcordJson.instance.decodeFromString(WireDocumentSerializer, json) + ConcordInviteListDocument( + entries = + doc.entries.map { + ConcordInviteListEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.extras) + }, + tombstones = doc.tombstones.map { ConcordInviteListTombstone(it.token, it.communityId, it.extras) }, + residue = doc.extras, + ) + } catch (_: Exception) { + ConcordInviteListDocument.EMPTY + } + + fun encode(doc: ConcordInviteListDocument): String = + ConcordJson.instance.encodeToString( + WireDocumentSerializer, + WireDocument( + entries = + doc.entries.map { + WireEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.residue) + }, + tombstones = doc.tombstones.map { WireTombstone(it.token, it.communityId, it.residue) }, + extras = doc.residue, + ), + ) + + /** + * Merges [patch] onto [base], keyed by `token` — the spec's own merge key. A token present in + * either side's tombstones is dropped from the result and kept tombstoned, so a retired link + * cannot be resurrected by a device that still has it cached. [patch] wins field-by-field on a + * token both sides carry, which is what makes "read remote, apply my change, publish" converge. + */ + fun merge( + base: ConcordInviteListDocument, + patch: ConcordInviteListDocument, + ): ConcordInviteListDocument { + val tombstones = LinkedHashMap() + for (t in base.tombstones + patch.tombstones) tombstones[t.token] = t + + val entries = LinkedHashMap() + for (e in base.entries + patch.entries) { + if (e.token in tombstones) continue + entries[e.token] = e + } + return ConcordInviteListDocument( + entries = entries.values.toList(), + tombstones = tombstones.values.toList(), + residue = JsonObject(base.residue + patch.residue), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt new file mode 100644 index 0000000000..f4bbd80f1c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The CORD-05 **Invite List** (kind 13303): the creator's private, NIP-44 self-encrypted record of + * every link they minted — `token` (the unlock secret and merge key) and `signer_sk` (the link + * signer's private key) per entry. + * + * It exists so a link can be *refreshed*: the kind-33301 bundle is addressable and authored by the + * link signer, so re-posting under it moves the link to the current epoch behind the same URL (e.g. + * after a Rekey). Without the list a client cannot re-sign at that coordinate, every rotation + * orphans every outstanding link, and stranded recovery — whose whole premise is re-resolving the + * link you joined through — can never fire. + * + * Replaceable and per-creator: the coordinate is (kind, creator pubkey, ""), so a creator's devices + * converge on one list. Merge by `token` ([ConcordInviteList.merge]) rather than overwriting, or two + * devices minting concurrently lose each other's links. + */ +@Immutable +class ConcordInviteListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * Decrypts the whole document with [signer] — entries, tombstones and the document residue. + * Use this (never a partial read) whenever the result will be re-encoded, or another client's + * unknown keys are dropped on the next publish. + */ + suspend fun decrypt(signer: NostrSigner): ConcordInviteListDocument = + try { + ConcordInviteList.decode(signer.nip44Decrypt(content, signer.pubKey)) + } catch (_: Exception) { + ConcordInviteListDocument.EMPTY + } + + companion object { + const val KIND = 13303 + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, "") + + suspend fun create( + signer: NostrSigner, + document: ConcordInviteListDocument, + createdAt: Long = TimeUtils.now(), + ): ConcordInviteListEvent { + val content = signer.nip44Encrypt(ConcordInviteList.encode(document), signer.pubKey) + return signer.sign(createdAt, KIND, emptyArray(), content) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 622fa0b889..3955952fdd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -101,6 +101,7 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent @@ -804,6 +805,7 @@ class EventFactory { RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) ConcordCommunityListEvent.KIND -> ConcordCommunityListEvent(id, pubKey, createdAt, tags, content, sig) ControlEditionEvent.KIND -> ControlEditionEvent(id, pubKey, createdAt, tags, content, sig) + ConcordInviteListEvent.KIND -> ConcordInviteListEvent(id, pubKey, createdAt, tags, content, sig) ConcordInviteBundleEvent.KIND -> ConcordInviteBundleEvent(id, pubKey, createdAt, tags, content, sig) SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig) SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt new file mode 100644 index 0000000000..e488aeccb5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Wire conformance for the CORD-05 Invite List (kind 13303). The whole point of this document is + * cross-client: a link minted in Armada must be refreshable from Amethyst and back, so the field + * names and the merge key are contract, not preference. + */ +class ConcordInviteListTest { + // The spec's own example document, verbatim in shape. + private val specJson = + """ + { "entries": [ + { "token": "aa11", + "signer_sk": "bb22", + "community_id": "cc33", + "url": "https://vector.chat/invite/naddr1abc#frag", + "label": "Reddit", + "created_at": 1719800000, + "expires_at": 1722400000 } ], + "tombstones": [ { "token": "dd44", "community_id": "cc33" } ] } + """.trimIndent() + + @Test + fun readsTheSpecDocumentIntoTypedEntries() { + val doc = ConcordInviteList.decode(specJson) + + assertEquals(1, doc.entries.size) + val e = doc.entries.first() + assertEquals("aa11", e.token) + assertEquals("bb22", e.signerSk) + assertEquals("cc33", e.communityId) + assertEquals("https://vector.chat/invite/naddr1abc#frag", e.url) + assertEquals("Reddit", e.label) + assertEquals(1719800000L, e.createdAt) + assertEquals(1722400000L, e.expiresAt) + + assertEquals(1, doc.tombstones.size) + assertEquals("dd44", doc.tombstones.first().token) + assertEquals("cc33", doc.tombstones.first().communityId) + } + + @Test + fun emitsTheSnakeCaseKeysAnotherClientReads() { + val json = ConcordInviteList.encode(ConcordInviteList.decode(specJson)) + // Field names are the interop contract — a camelCase slip silently orphans every link. + for (key in listOf("\"token\"", "\"signer_sk\"", "\"community_id\"", "\"url\"", "\"created_at\"", "\"expires_at\"", "\"entries\"", "\"tombstones\"")) { + assertTrue(json.contains(key), "missing wire key $key") + } + } + + @Test + fun keepsUnknownKeysAcrossADecodeEncodeCycle() { + // Armada types the entry and tombstone as `[k: string]: unknown`, so dropping a key we do + // not model deletes another client's data on our next publish. + val withExtras = + """ + { "entries": [ { "token": "aa11", "signer_sk": "bb22", "community_id": "cc33", + "url": "u", "created_at": 1, "future_field": {"a":1} } ], + "tombstones": [ { "token": "dd44", "community_id": "cc33", "why": "revoked" } ], + "doc_level_unknown": 7 } + """.trimIndent() + + val round = ConcordInviteList.encode(ConcordInviteList.decode(withExtras)) + + assertTrue(round.contains("future_field"), "entry-level unknown key dropped") + assertTrue(round.contains("doc_level_unknown"), "document-level unknown key dropped") + assertTrue(round.contains("\"why\""), "tombstone unknown key dropped") + } + + @Test + fun mergesByTokenAndLetsTombstonesWin() { + val base = + ConcordInviteListDocument( + entries = + listOf( + ConcordInviteListEntry("t1", "sk1", "c", "url1", createdAt = 1), + ConcordInviteListEntry("t2", "sk2", "c", "url2", createdAt = 2), + ), + ) + // Another device minted t3 and retired t1. + val patch = + ConcordInviteListDocument( + entries = listOf(ConcordInviteListEntry("t3", "sk3", "c", "url3", createdAt = 3)), + tombstones = listOf(ConcordInviteListTombstone("t1", "c")), + ) + + val merged = ConcordInviteList.merge(base, patch) + val tokens = merged.entries.map { it.token }.toSet() + + assertEquals(setOf("t2", "t3"), tokens, "merge is keyed by token; a tombstoned link is dropped") + assertTrue(merged.tombstones.any { it.token == "t1" }, "the tombstone must persist or a stale device resurrects the link") + } + + @Test + fun aMalformedDocumentYieldsEmptyRatherThanThrowing() { + assertEquals(0, ConcordInviteList.decode("not json").entries.size) + assertEquals(0, ConcordInviteList.decode("{\"entries\":\"wrong type\"}").entries.size) + } + + @Test + fun anExpiredLinkIsNotRefreshable() { + val live = ConcordInviteListEntry("t", "sk", "c", "u", expiresAt = 100) + val forever = ConcordInviteListEntry("t", "sk", "c", "u", expiresAt = null) + + assertTrue(live.isExpired(nowSecs = 101), "an elapsed link can no longer be joined") + assertTrue(!live.isExpired(nowSecs = 99)) + assertTrue(!forever.isExpired(nowSecs = Long.MAX_VALUE), "no expiry means it never elapses") + } +} From 66d27772623c258911edada18760896d9a186875 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 21:48:19 -0400 Subject: [PATCH 195/227] feat(concord): wire the Invite List into Amethyst; fix the QR quiet zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android half of the CORD-05 Invite List (kind 13303). Minting records the link's `token` + `signer_sk` in the shared, self-encrypted list, and a Refounding re-posts every live link it finds there at that link's own coordinate, carrying the new epoch. Read-merge-write, never overwrite: two of the user's devices minting concurrently would otherwise delete each other's `signer_sk`, which is unrecoverable. Expired links are skipped — re-posting one would only resurrect a dead URL at a live epoch. Verified on a Galaxy Tab A7 Lite against a loopback geode, driving the real UI: - tapping Invite publishes a kind-13303 authored by the device - Remove member → the Refounding publishes 5 control wraps + 1 rekey blob; `amy` follows it (epoch 0 → 1), and the removed member gets `no_blob_for_us` and stays behind - with a device-minted link in the list, the next Refounding logs "refreshed 1 invite link(s) to epoch 2" and the bundle at that link's coordinate is REPLACED in place rather than orphaned Also fixes the invite QR rendering as a postage stamp. QrCodeDrawer's quiet zone was a fixed 100px per side, which does not scale: at the dialog's 220dp box that ate ~45% of the canvas, so a long payload drew tiny inside a large white card. Expressed as the QR spec's 4-module zone it stays proportional, and the code now fills whatever box it is given at every call site. Note: OpenCV cannot decode this drawer's stylized modules either before or after the change, so scannability was not machine-verified — the change only shrinks excess quiet zone to the spec minimum and enlarges the modules, but a camera check before release is worthwhile. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/model/AccountConcordActions.kt | 107 ++++++++++++++++++ .../ui/note/share/ShareNoteAsQrScreen.kt | 2 +- .../ui/screen/loggedIn/qrcode/QrCodeDrawer.kt | 32 ++++-- 3 files changed, 131 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index 4caf607709..543206fc94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -40,6 +40,10 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys @@ -164,6 +168,83 @@ class AccountConcordActions( return community.communityIdHex } + // ---- CORD-05 Invite List (kind 13303) ------------------------------------- + + /** + * This account's Invite List: the creator's private, self-encrypted record of every link they + * minted (`token` + `signer_sk` per entry). Empty when none was ever published. + * + * Fetched rather than read from [LocalCache] because nothing subscribes to 13303 — it is + * bookkeeping the user never sees, needed only at mint and at rotation. + */ + private suspend fun readConcordInviteList(relays: Set): ConcordInviteListDocument { + if (relays.isEmpty()) return ConcordInviteListDocument.EMPTY + val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(account.signer.pubKey)) + val newest = + account.client + .fetchAll(filters = relays.associateWith { listOf(filter) }) + .maxByOrNull { it.createdAt } + return (newest as? ConcordInviteListEvent)?.decrypt(account.signer) ?: ConcordInviteListDocument.EMPTY + } + + /** + * Merges [patch] into the published Invite List and republishes it. Read-merge-write, never + * overwrite: the list is replaceable and per-creator, so two of the user's devices minting + * concurrently would otherwise delete each other's `signer_sk` — and that secret is + * unrecoverable, orphaning the link at whatever epoch it was last refreshed to. + */ + private suspend fun publishConcordInviteList( + patch: ConcordInviteListDocument, + relays: Set, + ) { + val publishTo = relays.ifEmpty { account.outboxRelays.flow.value } + if (publishTo.isEmpty()) return + val merged = ConcordInviteList.merge(readConcordInviteList(publishTo), patch) + account.client.publish(ConcordInviteListEvent.create(account.signer, merged, TimeUtils.now()), publishTo) + } + + /** + * Re-posts every live link this account minted for [entry]'s community at its own coordinate, + * carrying the CURRENT epoch (CORD-05). The kind-33301 bundle is addressable and authored by the + * link signer, so this moves the link behind the same URL instead of orphaning it at a dead + * epoch — which is the whole premise stranded recovery rests on. + * + * Safe to call for every live link: recovery is ban-gated at the epoch being left, and a + * Refounding bans the members it removes on the way out, so a removed member's own recovery is + * refused even though their link now resolves. + */ + private suspend fun refreshConcordInviteLinks(entry: ConcordCommunityListEntry): Int { + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + if (relays.isEmpty()) return 0 + val now = TimeUtils.now() + val refreshed = + ConcordActions.inviteFor( + communityIdHex = entry.id, + ownerPubKey = entry.owner, + ownerSaltHex = entry.ownerSalt, + communityRootHex = entry.root, + rootEpoch = entry.rootEpoch, + name = entry.name, + relays = entry.relays, + controlPk = entry.controlPk, + ) + var count = 0 + for (link in readConcordInviteList(relays).entries) { + if (link.communityId != entry.id) continue + // An elapsed link can no longer be joined, so re-posting it would only resurrect a dead + // URL at a live epoch. + if (link.isExpired(now)) continue + runCatching { + account.client.publish( + ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), link.token.hexToByteArray(), refreshed, now), + relays, + ) + count++ + }.onFailure { Log.w("Concord", "invite refresh failed for ${entry.id}", it) } + } + return count + } + /** * Mint a shareable invite link for a joined community and publish its * kind-33301 public bundle to the community relays. Returns the `…/invite/…` @@ -209,6 +290,24 @@ class AccountConcordActions( val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo) + + // Record the link so a later Refounding can refresh THIS coordinate rather than orphaning it + // (CORD-05, kind 13303). Shared with amy and Armada, so any of the creator's clients can. + publishConcordInviteList( + ConcordInviteListDocument( + entries = + listOf( + ConcordInviteListEntry( + token = minted.token.toHexKey(), + signerSk = minted.linkSignerPrivKey.toHexKey(), + communityId = entry.id, + url = minted.url, + createdAt = TimeUtils.now(), + ), + ), + ), + publishTo, + ) return minted.url } @@ -862,6 +961,14 @@ class AccountConcordActions( // 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and // re-folds the compacted Control Plane (with the ban), dropping the removed members. adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot) + + // 6. Move every link we minted to the new epoch. Without this the Refounding orphans them, + // and a member it left out — no rekey blob, no message to miss — has no way back at all. + val moved = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } + ?.let { refreshConcordInviteLinks(it) } ?: 0 + Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" } return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt index 5bbf7bfdfa..03c78556de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt @@ -61,7 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes -// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a +// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_QUIET_ZONE_MODULES in QrCodeDrawer.kt) is a // fixed pixel count subtracted from raw size.width, so its share of the tile grows as density // falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable // resolution on low-density screens. Deriving the size from the available column width keeps diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt index d4e624d926..a31a83b21c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt @@ -49,7 +49,15 @@ import com.google.zxing.qrcode.encoder.Encoder import com.google.zxing.qrcode.encoder.QRCode import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -const val QR_MARGIN_PX = 100f +/** + * The quiet zone around the code, in **modules** — the QR spec's minimum of 4. + * + * It was a fixed 100px per side, which does not scale: at a small draw size those 200px ate most of + * the canvas, so a long payload (a Concord invite link, an nprofile) rendered as a postage stamp + * floating in white. Expressed in modules the zone stays proportional, so the code fills whatever + * box it is given at every size while remaining scannable. + */ +const val QR_QUIET_ZONE_MODULES = 4f @Preview @Composable @@ -78,13 +86,16 @@ fun QrCodeDrawer( ) { Canvas(modifier = Modifier.fillMaxSize()) { // Calculate the height and width of each column/row - val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height - val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width + // Solve for the module size with the quiet zone measured in modules, so the whole code + // (zone included) is exactly as wide as the canvas. + val rowHeight = size.height / (qrCode.matrix.height + QR_QUIET_ZONE_MODULES * 2f) + val columnWidth = size.width / (qrCode.matrix.width + QR_QUIET_ZONE_MODULES * 2f) val radius = CornerRadius(20f) // Draw all of the finder patterns required by the QR spec. Calculate the ratio // of the number of rows/columns to the width and height drawQrCodeFinders( + quietZonePx = columnWidth * QR_QUIET_ZONE_MODULES, sideLength = size.width, finderPatternSize = Size( @@ -97,6 +108,7 @@ fun QrCodeDrawer( // Draw data bits (encoded data part) drawAllQrCodeDataBits( + quietZonePx = columnWidth * QR_QUIET_ZONE_MODULES, bytes = qrCode.matrix, size = Size( @@ -119,7 +131,7 @@ private fun createQrCode(contents: String): QRCode { ErrorCorrectionLevel.Q, mapOf( EncodeHintType.CHARACTER_SET to "UTF-8", - EncodeHintType.MARGIN to QR_MARGIN_PX, + EncodeHintType.MARGIN to QR_QUIET_ZONE_MODULES, EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q, ), ) @@ -132,6 +144,7 @@ fun newPath(withPath: Path.() -> Unit) = } fun DrawScope.drawAllQrCodeDataBits( + quietZonePx: Float, bytes: ByteMatrix, size: Size, color: Color, @@ -182,8 +195,8 @@ fun DrawScope.drawAllQrCodeDataBits( Rect( offset = Offset( - x = QR_MARGIN_PX + x * size.width, - y = QR_MARGIN_PX + y * size.height, + x = quietZonePx + x * size.width, + y = quietZonePx + y * size.height, ), size = newSize, ), @@ -212,6 +225,7 @@ private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS = 0.5f * @param finderPatternSize [Size] of each finder patten, based on the QR code spec */ internal fun DrawScope.drawQrCodeFinders( + quietZonePx: Float, sideLength: Float, finderPatternSize: Size, cornerRadius: CornerRadius, @@ -219,11 +233,11 @@ internal fun DrawScope.drawQrCodeFinders( ) { setOf( // Draw top left finder pattern. - Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX), + Offset(x = quietZonePx, y = quietZonePx), // Draw top right finder pattern. - Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX), + Offset(x = sideLength - (quietZonePx + finderPatternSize.width), y = quietZonePx), // Draw bottom finder pattern. - Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height)), + Offset(x = quietZonePx, y = sideLength - (quietZonePx + finderPatternSize.height)), ).forEach { offset -> drawQrCodeFinder( topLeft = offset, From eb8c812690ebb5cc528df593415040aac7d7943e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 23:39:54 -0400 Subject: [PATCH 196/227] fix(concord): close ten review findings in the CORD-05 invite path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A high-effort review of the invite work found ten correctness bugs, nine confirmed and one plausible. All are fixed here; the on-device pass on a tablet proved the three that are observable through the UI. The load-bearing one: the kind-13303 Invite List is replaceable, and both clients merged a patch onto a base that silently degraded to EMPTY whenever the read failed — an unanswered relay or a bunker signer declining one decrypt was enough. Republishing that destroys every `signer_sk` it could not read, and those secrets cannot be regenerated, so every outstanding link is orphaned at a dead epoch. `decode` is now `decodeOrNull` and `decrypt` returns null, so "I could not read it" is distinguishable from "it is empty", and the write aborts rather than overwriting. The rest: - `join` is now ban-gated on both clients. A Refounding re-mints every outstanding link onto the new root, and an ex-member keeps the URL and its token forever, so the rotation meant to expel them handed them the new keys instead. Fails closed on an unreadable plane. - Android's Refounding re-read the entry from `liveCommunities` straight after adopting the new root, but that flow decrypts asynchronously, so every link was re-minted onto the epoch just left. `adoptConcordRoot` now returns the entry it wrote. - amy's refound folded the fresh bans locally and then never used them, re-draining from relays instead; a relay slow to echo them back would produce a new epoch whose roster never banned anyone. - Link refresh rebuilt the bundle from scratch, stripping expiry, channel grants, icon and label; it now moves the link's own current bundle and changes only the epoch's key material. - Refresh also re-posted over revocation tombstones, silently un-revoking a retired link. - The 13303 coordinate is (13303, me, "") — one list per account — but was read and written on per-community relays, forking it into divergent versions that newest-wins then collapsed. Now account-outbox only. - Minting returned the URL even when recording the link failed, handing out a link that could never be refreshed. It now fails closed. - amy's refound had no equivalent of Amethyst's recipient cap, leaving the attacker-writable half of the union unbounded. - amy's store dropped the banked epoch's `controlRoot` on the round-trip, losing the staff write key that rebuilds the anti-rollback floor. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/model/AccountConcordActions.kt | 186 +++++++++++------- .../amethyst/model/ConcordInviteResult.kt | 9 + .../concord/ConcordInviteScreen.kt | 2 + amethyst/src/main/res/values/strings.xml | 1 + .../amethyst/cli/commands/ConcordCommands.kt | 99 +++++++--- .../cli/commands/ConcordModCommands.kt | 96 ++++++--- .../amethyst/cli/stores/ConcordStore.kt | 6 + .../concord/cord05Invites/CommunityInvite.kt | 2 +- .../cord05Invites/ConcordInviteList.kt | 24 ++- .../cord05Invites/ConcordInviteListEvent.kt | 16 +- .../cord05Invites/ConcordInviteListTest.kt | 39 +++- 11 files changed, 339 insertions(+), 141 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index 543206fc94..daad3aa9c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -171,74 +171,97 @@ class AccountConcordActions( // ---- CORD-05 Invite List (kind 13303) ------------------------------------- /** - * This account's Invite List: the creator's private, self-encrypted record of every link they - * minted (`token` + `signer_sk` per entry). Empty when none was ever published. + * This account's Invite List (kind 13303): the creator's private, self-encrypted record of every + * link they minted (`token` + `signer_sk` per entry). * - * Fetched rather than read from [LocalCache] because nothing subscribes to 13303 — it is + * Returns **null** when the list could not be read — no relay answered, or the signer refused + * the decrypt — and an empty document only when the account genuinely has no list yet. Callers + * must not conflate the two: republishing an "empty" list over this replaceable coordinate + * destroys every `signer_sk` it failed to read, and those secrets cannot be regenerated. + * + * Read on the account's OUTBOX relays, never a community's: the coordinate is + * (13303, me, "") — one list for the whole account — so scoping it per community would fork it + * into divergent versions that the newest-wins rule then silently collapses. + * + * Fetched rather than read from [LocalCache] because nothing subscribes to 13303: it is * bookkeeping the user never sees, needed only at mint and at rotation. */ - private suspend fun readConcordInviteList(relays: Set): ConcordInviteListDocument { - if (relays.isEmpty()) return ConcordInviteListDocument.EMPTY + private suspend fun readConcordInviteList(): ConcordInviteListDocument? { + val relays = account.outboxRelays.flow.value + if (relays.isEmpty()) return null val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(account.signer.pubKey)) val newest = account.client .fetchAll(filters = relays.associateWith { listOf(filter) }) .maxByOrNull { it.createdAt } - return (newest as? ConcordInviteListEvent)?.decrypt(account.signer) ?: ConcordInviteListDocument.EMPTY + ?: return ConcordInviteListDocument.EMPTY // nothing published yet — safe to start one + return (newest as? ConcordInviteListEvent)?.decrypt(account.signer) } /** - * Merges [patch] into the published Invite List and republishes it. Read-merge-write, never - * overwrite: the list is replaceable and per-creator, so two of the user's devices minting - * concurrently would otherwise delete each other's `signer_sk` — and that secret is - * unrecoverable, orphaning the link at whatever epoch it was last refreshed to. + * Merges [patch] into the published Invite List and republishes it, returning whether it landed. + * + * Read-merge-write, and **aborts rather than overwriting** when the read fails: the list is + * replaceable, so publishing a patch-only document over an unread list deletes every other + * link's `signer_sk` — unrecoverable, and it strands every holder of those links at the next + * rotation. A momentarily unreachable relay or a bunker signer that declines one decrypt is + * enough to trigger that, which is exactly how the kind-13302 community list was once emptied. */ - private suspend fun publishConcordInviteList( - patch: ConcordInviteListDocument, - relays: Set, - ) { - val publishTo = relays.ifEmpty { account.outboxRelays.flow.value } - if (publishTo.isEmpty()) return - val merged = ConcordInviteList.merge(readConcordInviteList(publishTo), patch) - account.client.publish(ConcordInviteListEvent.create(account.signer, merged, TimeUtils.now()), publishTo) + private suspend fun publishConcordInviteList(patch: ConcordInviteListDocument): Boolean { + val publishTo = account.outboxRelays.flow.value + if (publishTo.isEmpty()) return false + val base = + readConcordInviteList() ?: run { + Log.w("Concord") { "Refusing to write the invite list: could not read the current one (would drop other links' signer_sk)" } + return false + } + return runCatching { + account.client.publish(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo) + true + }.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false) } /** * Re-posts every live link this account minted for [entry]'s community at its own coordinate, - * carrying the CURRENT epoch (CORD-05). The kind-33301 bundle is addressable and authored by the + * carrying [entry]'s epoch (CORD-05). The kind-33301 bundle is addressable and authored by the * link signer, so this moves the link behind the same URL instead of orphaning it at a dead * epoch — which is the whole premise stranded recovery rests on. * - * Safe to call for every live link: recovery is ban-gated at the epoch being left, and a - * Refounding bans the members it removes on the way out, so a removed member's own recovery is - * refused even though their link now resolves. + * [entry] MUST be the post-rotation entry, passed in rather than re-read: the joined-list flow + * decrypts asynchronously, so reading it straight after adopting a new root yields the OLD + * epoch and would re-mint every link onto the epoch we just left. + * + * Each link is refreshed from its own CURRENT bundle, not rebuilt from scratch, so per-link + * fields the bundle carries — expiry, channel grants, icon, label — survive the rotation. A + * coordinate whose newest event is a revocation tombstone is left alone: re-posting a live + * bundle over it would silently un-revoke the link. */ private suspend fun refreshConcordInviteLinks(entry: ConcordCommunityListEntry): Int { val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } if (relays.isEmpty()) return 0 + val list = readConcordInviteList() ?: return 0 + val tombstoned = list.tombstones.mapTo(HashSet()) { it.token } val now = TimeUtils.now() - val refreshed = - ConcordActions.inviteFor( - communityIdHex = entry.id, - ownerPubKey = entry.owner, - ownerSaltHex = entry.ownerSalt, - communityRootHex = entry.root, - rootEpoch = entry.rootEpoch, - name = entry.name, - relays = entry.relays, - controlPk = entry.controlPk, - ) var count = 0 - for (link in readConcordInviteList(relays).entries) { + for (link in list.entries) { if (link.communityId != entry.id) continue - // An elapsed link can no longer be joined, so re-posting it would only resurrect a dead - // URL at a live epoch. - if (link.isExpired(now)) continue + // An elapsed or retired link can no longer be joined; re-posting it would only resurrect + // a dead URL at a live epoch. + if (link.isExpired(now) || link.token in tombstoned) continue runCatching { - account.client.publish( - ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), link.token.hexToByteArray(), refreshed, now), - relays, - ) + val token = link.token.hexToByteArray() + val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }) + // Honour a revocation published at this coordinate, and carry the live bundle's own + // fields forward — only the epoch's key material changes. + val current = ConcordActions.classifyInvite(wraps, token) as? InviteBundleStatus.Live ?: return@runCatching + val moved = + current.invite.copy( + communityRoot = entry.root, + rootEpoch = entry.rootEpoch, + controlPk = entry.controlPk, + relays = entry.relays, + ) + account.client.publish(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays) count++ }.onFailure { Log.w("Concord", "invite refresh failed for ${entry.id}", it) } } @@ -289,25 +312,30 @@ class AccountConcordActions( val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays) val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } - if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo) - - // Record the link so a later Refounding can refresh THIS coordinate rather than orphaning it - // (CORD-05, kind 13303). Shared with amy and Armada, so any of the creator's clients can. - publishConcordInviteList( - ConcordInviteListDocument( - entries = - listOf( - ConcordInviteListEntry( - token = minted.token.toHexKey(), - signerSk = minted.linkSignerPrivKey.toHexKey(), - communityId = entry.id, - url = minted.url, - createdAt = TimeUtils.now(), + // Record the link BEFORE handing the URL out (CORD-05, kind 13303). A link whose `signer_sk` + // was never stored can never be refreshed, so the next Refounding orphans it and everyone + // holding it is stranded — with nothing to have warned them. Failing the mint is the honest + // outcome; a stored entry for a link nobody received is harmless by comparison. + if (!publishConcordInviteList( + ConcordInviteListDocument( + entries = + listOf( + ConcordInviteListEntry( + token = minted.token.toHexKey(), + signerSk = minted.linkSignerPrivKey.toHexKey(), + communityId = entry.id, + url = minted.url, + createdAt = TimeUtils.now(), + ), ), - ), - ), - publishTo, - ) + ), + ) + ) { + Log.w("Concord") { "Invite not minted for ${entry.id}: its link signer could not be recorded, so the link could never be refreshed" } + return null + } + + if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo) return minted.url } @@ -373,6 +401,32 @@ class AccountConcordActions( return ConcordInviteResult.Joined(bundle.communityId) } + // Refuse a link that readmits us after we were removed. A Refounding re-mints every + // outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its + // unlock token forever — so without this the rotation meant to expel them hands them the new + // keys instead. `recoverStrandedConcordCommunities` has always been ban-gated; this is the + // other door into the same room. + // + // Fails CLOSED on an unreadable plane: the banlist is only knowable once the bundle yields + // the root, and no verdict means no join. + val joinKeys = + ConcordActions.controlPlaneKeys( + communityRoot = bundle.communityRoot.hexToByteArray(), + communityId = bundle.communityId.hexToByteArray(), + rootEpoch = bundle.rootEpoch, + controlPk = bundle.controlPk, + ) + val joinRelays = bundle.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { relays } + val joinEditions = + ConcordActions.controlEditions( + account.client.fetchAll(filters = joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }), + joinKeys, + ) + if (joinEditions.isEmpty()) return ConcordInviteResult.NotReachable + if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(account.signer.pubKey)) { + return ConcordInviteResult.Banned + } + val entry = ConcordCommunityListEntry( id = bundle.communityId, @@ -960,14 +1014,13 @@ class AccountConcordActions( // 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and // re-folds the compacted Control Plane (with the ban), dropping the removed members. - adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot) + val adopted = adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot) // 6. Move every link we minted to the new epoch. Without this the Refounding orphans them, // and a member it left out — no rekey blob, no message to miss — has no way back at all. - val moved = - account.concordChannelList.liveCommunities.value - .firstOrNull { it.id == communityId } - ?.let { refreshConcordInviteLinks(it) } ?: 0 + // Uses the entry adoption just wrote: `liveCommunities` decrypts asynchronously, so + // reading it here would hand us the epoch we just left and re-mint every link onto it. + val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0 Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" } return true } @@ -1033,8 +1086,8 @@ class AccountConcordActions( newEpoch: Long, newControlPk: ByteArray? = null, newControlRoot: ByteArray? = null, - ) { - if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return + ): ConcordCommunityListEntry? { + if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return null // The rewrite itself — banking the leaving epoch's address for the anti-rollback floor, // dropping stale control material on a legacy rotation, preserving invite_ref and residue — // is shared with `amy` in [ConcordReceive.withAdoptedRoot]. Only the persist + publish and @@ -1042,6 +1095,7 @@ class AccountConcordActions( val next = ConcordReceive.withAdoptedRoot(entry, newRoot, newEpoch, newControlPk, newControlRoot) account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(next)) announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) + return next } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt index 6b4d2599cb..8f502e358b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt @@ -54,6 +54,15 @@ sealed interface ConcordInviteResult { */ data object Expired : ConcordInviteResult + /** + * The link opens, but this community's roster has banned us (CORD-04). + * + * A Refounding re-mints every outstanding link onto the new root, and a removed member keeps the + * URL and its unlock token forever — so honouring the link alone would hand the new keys to the + * very account the rotation expelled. + */ + data object Banned : ConcordInviteResult + /** * The bundle event was found but could not be opened with the link's token — * typically because it was minted by a newer/incompatible Concord client whose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index 2788dee7ee..d56c800791 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -124,6 +124,8 @@ fun ConcordInviteScreen( RedeemState.Failed(R.string.concord_invite_failed_incompatible, canRetry = false) is ConcordInviteResult.Revoked -> RedeemState.Failed(R.string.concord_invite_failed_revoked, canRetry = false) + is ConcordInviteResult.Banned -> + RedeemState.Failed(R.string.concord_invite_failed_banned, canRetry = false) is ConcordInviteResult.Expired -> RedeemState.Failed(R.string.concord_invite_failed_expired, canRetry = false) is ConcordInviteResult.NotReachable -> diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f3e96dffdf..20e32ea0b7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -321,6 +321,7 @@ This invite link is invalid or can\'t be opened with this account. This invite link can\'t be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link. This invite link has been revoked and can no longer be used. Ask for a new one. + This community has removed you. The link still works, but its member list does not admit you. This invite link has expired and can no longer be used. Ask for a fresh link. Community name is only revealed after you join Joining connects to this invite\'s relays, publishes a join announcement signed by your account, and adds the community to your list. Nothing is sent until you tap Join. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 76e7a8d59b..f2890e7775 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -230,7 +230,7 @@ object ConcordCommands { controlRoot = e.controlRoot ?: priorSameEpoch?.controlRoot ?: "", generalChannelId = prior?.generalChannelId ?: "", relays = e.relays, - heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") }, + heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "", it.controlRoot ?: "") }, // Survives every merge: losing the anchor makes the NEXT exclusion // unrecoverable, so a list entry without one must not clear ours. inviteRef = e.inviteRef ?: prior?.inviteRef ?: "", @@ -266,16 +266,13 @@ object ConcordCommands { // (CORD-05 §1); omitted for a legacy community, which has none to carry. val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null }) val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays) - val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)) - RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it } - - // Record the link in the CORD-05 Invite List (kind 13303) so any of this creator's - // clients — Amethyst, Armada — can later refresh THIS coordinate instead of orphaning - // the link at a dead epoch. That list is the liveness half of stranded recovery (A2). - publishInviteList( - ctx, - extraRelays = relaysFor(ctx, sc), - patch = + // Record the link BEFORE publishing the bundle (CORD-05, kind 13303): a link whose + // `signer_sk` was never stored can never be refreshed, so the next Refounding orphans + // it and every holder is stranded. Better to mint nothing than to hand out a link that + // is already doomed. + val recorded = + publishInviteList( + ctx, ConcordInviteListDocument( entries = listOf( @@ -288,7 +285,16 @@ object ConcordCommands { ), ), ), - ) + ) + if (!recorded) { + return Output.error( + "invite_unrecordable", + "could not record the link signer in your invite list (kind 13303), so this link could never be refreshed after a Refounding — not minting it", + ) + } + + val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)) + RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it } Output.emit( mapOf( @@ -318,6 +324,34 @@ object ConcordCommands { wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } ?: return Output.error("not_found", "no valid bundle for this link").let { 1 } + // Refuse a link that readmits us after we were removed. A Refounding re-mints every + // outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its + // unlock token forever — so without this check the rotation that was supposed to expel + // them hands them the new keys instead. `recover` has always been ban-gated; `join` is + // the other door into the same room. + // + // Fails CLOSED on an unreadable plane: no verdict, no join. The banlist is only knowable + // after the bundle yields the root, which is why the check lives here rather than before. + val joinKeys = + ConcordActions.controlPlaneKeys( + communityRoot = bundle.communityRoot.hexToByteArray(), + communityId = bundle.communityId.hexToByteArray(), + rootEpoch = bundle.rootEpoch, + controlPk = bundle.controlPk, + ) + val joinRelays = normalize(bundle.relays).ifEmpty { relays } + val joinEditions = + ConcordActions.controlEditions( + ctx.drain(joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, pendingOnAuthRequired = true).map { it.second }, + joinKeys, + ) + if (joinEditions.isEmpty()) { + return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join") + } + if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(ctx.signer.pubKey)) { + return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)") + } + ConcordStore(dataDir.concordFile).upsert( StoredCommunity( name = bundle.name, @@ -401,7 +435,7 @@ object ConcordCommands { rootEpoch = sc.rootEpoch, controlPk = sc.controlPk.ifBlank { null }, controlRoot = sc.controlRoot.ifBlank { null }, - heldRoots = sc.heldRoots.map { HeldRoot(it.epoch, it.root, it.controlPk.ifBlank { null }) }, + heldRoots = sc.heldRoots.map { HeldRoot(it.epoch, it.root, it.controlPk.ifBlank { null }, it.controlRoot.ifBlank { null }) }, relays = sc.relays, name = sc.name, inviteRef = sc.inviteRef.ifBlank { null }, @@ -416,7 +450,7 @@ object ConcordCommands { rootEpoch = entry.rootEpoch, controlPk = entry.controlPk ?: "", controlRoot = entry.controlRoot ?: "", - heldRoots = entry.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") }, + heldRoots = entry.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "", it.controlRoot ?: "") }, relays = entry.relays, name = entry.name.ifBlank { sc.name }, inviteRef = entry.inviteRef ?: sc.inviteRef, @@ -585,36 +619,39 @@ object ConcordCommands { * of every link they minted, so a rotation can refresh those links instead of orphaning them. * Empty when none was ever published. */ - suspend fun readInviteList( - ctx: Context, - extraRelays: Set = emptySet(), - ): ConcordInviteListDocument { - val relays = ctx.outboxRelays() + extraRelays - if (relays.isEmpty()) return ConcordInviteListDocument.EMPTY + suspend fun readInviteList(ctx: Context): ConcordInviteListDocument? { + val relays = ctx.outboxRelays() + if (relays.isEmpty()) return null val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(ctx.signer.pubKey)) val newest = ctx .drain(relays.associateWith { listOf(filter) }) .map { it.second } .maxByOrNull { it.createdAt } - return (newest as? ConcordInviteListEvent)?.decrypt(ctx.signer) ?: ConcordInviteListDocument.EMPTY + ?: return ConcordInviteListDocument.EMPTY // nothing published yet — safe to start one + return (newest as? ConcordInviteListEvent)?.decrypt(ctx.signer) } /** - * Merges [patch] into the published list and republishes it. Read-merge-write rather than - * overwrite: the list is replaceable and per-creator, so two devices minting concurrently would - * otherwise delete each other's links (and their `signer_sk`, which is unrecoverable). + * Merges [patch] into the published list and republishes it, returning whether it landed. + * + * Read-merge-write, and **aborts rather than overwriting** when the read fails: kind 13303 is + * replaceable, so writing a patch-only document over a list we could not read deletes every + * other link's `signer_sk`. Those secrets cannot be regenerated, and losing one orphans its + * link at the next rotation, stranding everyone holding that URL. + * + * Account-scoped, like the coordinate itself — (13303, me, "") is one list for every community, + * so reading or writing it on a single community's relays would fork it. */ suspend fun publishInviteList( ctx: Context, patch: ConcordInviteListDocument, - extraRelays: Set = emptySet(), - ) { - val relays = ctx.outboxRelays() + extraRelays - if (relays.isEmpty()) return - val merged = ConcordInviteList.merge(readInviteList(ctx, extraRelays), patch) - val event = ConcordInviteListEvent.create(ctx.signer, merged, TimeUtils.now()) - ctx.publish(event, relays) + ): Boolean { + val relays = ctx.outboxRelays() + if (relays.isEmpty()) return false + val base = readInviteList(ctx) ?: return false + val event = ConcordInviteListEvent.create(ctx.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()) + return ctx.publish(event, relays).values.any { it.accepted } } fun notFound(handle: String): Int { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 23ec2a47ef..9b52786df6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -33,7 +33,9 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.RandomInstance @@ -272,26 +274,37 @@ object ConcordModCommands { // 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the // new epoch — carries the ban. Each edition chains onto the updated banlist head. var chain = editions + val banWraps = mutableListOf() for (target in removed) { val banWrap = ConcordModeration.ban(ctx.signer, cp, sc.communityId.hexToByteArray(), target, chain, TimeUtils.now(), owner = sc.owner) - ctx.publish(banWrap, relays) + val ack = ctx.publish(banWrap, relays) + if (ack.values.none { it.accepted }) { + return Output.error("ban_not_published", "the pre-rotation ban for $target was not accepted by any relay; refusing to refound with a banlist that would not survive") + } + banWraps += banWrap chain = chain + (ConcordActions.controlEditions(listOf(banWrap), cp)) } // 2. Everyone we are keeping. See the note above on why this reaches past the roster. - val recipients = + val candidates = (rosterOf(authority) + guestbookMembersOf(ctx, sc) + channelAuthorsOf(ctx, sc, state) + me) .mapTo(HashSet()) { it.lowercase() } .apply { removeAll(removed) removeAll(authority.bannedMembers().map { it.lowercase() }.toSet()) - }.toList() + } + val recipients = boundRecipients(candidates, authority) // 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff // get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one). val newRoot = RandomInstance.bytes(32) val newControlRoot = RandomInstance.bytes(32) - val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } + // Compact from what we KNOW the plane holds: the wraps we drained plus the bans we just + // published. Re-draining alone would race the relay's indexing, and a relay that has not + // yet echoed the ban back (or that ACKed and stored nothing) would produce a new epoch + // whose roster never banned the member we are removing. + val drained = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } + val controlWraps = (drained + banWraps).distinctBy { it.id } val build = ConcordActions.buildRefounding( rotatorSigner = ctx.signer, @@ -335,33 +348,30 @@ object ConcordModCommands { // Safe for every link because recovery is ban-gated at the epoch being left, and step 1 // banned everyone being removed — so a removed member's own `recover` is refused even // though their link now resolves. - val refreshedInvite = - ConcordActions.inviteFor( - stored.communityId, - stored.owner, - stored.ownerSalt, - stored.root, - stored.rootEpoch, - stored.name, - stored.relays, - stored.controlPk.ifBlank { null }, - ) val now = TimeUtils.now() var refreshed = 0 - for (link in ConcordCommands.readInviteList(ctx, relays).entries) { + val list = ConcordCommands.readInviteList(ctx) + val tombstoned = list?.tombstones?.mapTo(HashSet()) { it.token } ?: emptySet() + for (link in list?.entries.orEmpty()) { if (link.communityId != stored.communityId) continue - // An elapsed link can no longer be joined, so re-posting it would only resurrect a - // dead URL at a live epoch (CORD-05). - if (link.isExpired(now)) continue + // An elapsed or retired link can no longer be joined, so re-posting it would only + // resurrect a dead URL at a live epoch (CORD-05). + if (link.isExpired(now) || link.token in tombstoned) continue runCatching { - val event = - ConcordActions.remintBundleAt( - linkSignerPrivKey = link.signerSk.hexToByteArray(), - token = link.token.hexToByteArray(), - invite = refreshedInvite, - createdAt = now, + val token = link.token.hexToByteArray() + // Refresh from the link's CURRENT bundle so its own fields — expiry, channel + // grants, icon, label — survive the rotation, and so a coordinate whose newest + // event is a revocation tombstone is left revoked instead of being re-opened. + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }).map { it.second } + val live = ConcordActions.classifyInvite(wraps, token) as? InviteBundleStatus.Live ?: return@runCatching + val moved = + live.invite.copy( + communityRoot = stored.root, + rootEpoch = stored.rootEpoch, + controlPk = stored.controlPk.ifBlank { null }, + relays = stored.relays, ) - ctx.publish(event, relays) + ctx.publish(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays) refreshed++ } } @@ -382,6 +392,40 @@ object ConcordModCommands { } } + /** + * How many recipients one Refounding will re-key, mirroring Amethyst's own cap. + * + * Two thirds of the recipient union — Guestbook joins and observed channel authors — are + * attacker-writable: any key can announce a join or post once. Without a bound, padding those + * sets inflates the cost of the only hard removal Concord has until rotating becomes + * impractical, so the attack raises the price of its own remedy (B4 in the soft-ban audit). + */ + private const val MAX_REFOUNDING_RECIPIENTS = 5_000 + + /** + * Caps [candidates], keeping the members whose standing is owner-rooted and therefore cannot be + * padded from outside. Anything dropped is reported rather than silently truncated — a dropped + * member is stranded on the dead epoch and their only way back is `concord recover`. + */ + private fun boundRecipients( + candidates: Set, + authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver, + ): List { + if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList() + val vouched = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() } + val kept = LinkedHashSet() + candidates.filterTo(kept) { it in vouched } + for (candidate in candidates) { + if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break + kept.add(candidate) + } + val dropped = candidates.size - kept.size + if (dropped > 0) { + System.err.println("[concord] refounding recipient set trimmed to ${kept.size} of ${candidates.size}: $dropped member(s) will be stranded on the prior epoch") + } + return kept.toList() + } + /** Owner + everyone holding a role — owner-rooted, so it cannot be padded from outside. */ private fun rosterOf(authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver): Set = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index d2de85a258..c36b1ac915 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -61,6 +61,12 @@ data class StoredHeldRoot( val root: String = "", /** That epoch's Control Plane address; blank for a legacy, pre-split epoch (CORD-02 §5). */ val controlPk: String = "", + /** + * That epoch's staff write key, banked only if we held it. A relay that gates the prior epoch's + * Control Plane on NIP-42 AUTH as the stream key will not serve those wraps without it — and + * those wraps are what rebuild the anti-rollback floor. + */ + val controlRoot: String = "", ) /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt index d652c70143..632a7f9e03 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt @@ -52,7 +52,7 @@ class InviteChannel( * into a kind-33301 bundle (link invites) or a NIP-59 giftwrap (direct invites). */ @Serializable -class CommunityInvite( +data class CommunityInvite( @SerialName("community_id") val communityId: String, val owner: String, @SerialName("owner_salt") val ownerSalt: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt index 3eea2095c3..5b930eb261 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt @@ -21,6 +21,10 @@ package com.vitorpamplona.quartz.concord.cord05Invites import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +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.crypto.KeyPair import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName @@ -58,6 +62,12 @@ class ConcordInviteListEntry( ) { /** True when this link can no longer be joined, so it must not be refreshed (CORD-05). */ fun isExpired(nowSecs: Long): Boolean = expiresAt != null && expiresAt <= nowSecs + + /** + * The link signer's pubkey — the addressable coordinate the bundle lives at, derived from the + * secret we kept. Refreshing or retiring a link means writing at exactly this author. + */ + fun signerPubKeyHex(): HexKey = KeyPair(privKey = signerSk.hexToByteArray()).pubKey.toHexKey() } /** A retired link: the creator's record that [token] is gone, kept so a merge cannot resurrect it. */ @@ -150,11 +160,15 @@ object ConcordInviteList { private object WireDocumentSerializer : ExtrasPreserving(WireDocument.serializer()) /** - * Decodes the plaintext document. A malformed document yields [ConcordInviteListDocument.EMPTY] - * rather than throwing — but note the sharp edge this shape shares with the community list: one - * unparseable entry aborts the whole array, so every field defaults instead of being required. + * Decodes the plaintext document, or **null** when it cannot be parsed. + * + * Null rather than an empty document on purpose: this list is replaceable, so a caller that + * treats "I could not read it" as "it is empty" and republishes destroys every `signer_sk` it + * did not manage to read — secrets that cannot be regenerated, orphaning every outstanding + * invite at a dead epoch. Callers MUST distinguish the two (see [ConcordInviteList.merge]'s + * callers). Each field still defaults, so one odd entry does not abort the whole array. */ - fun decode(json: String): ConcordInviteListDocument = + fun decodeOrNull(json: String): ConcordInviteListDocument? = try { val doc = ConcordJson.instance.decodeFromString(WireDocumentSerializer, json) ConcordInviteListDocument( @@ -166,7 +180,7 @@ object ConcordInviteList { residue = doc.extras, ) } catch (_: Exception) { - ConcordInviteListDocument.EMPTY + null } fun encode(doc: ConcordInviteListDocument): String = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt index f4bbd80f1c..3816f60a6f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt @@ -52,15 +52,19 @@ class ConcordInviteListEvent( sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { /** - * Decrypts the whole document with [signer] — entries, tombstones and the document residue. - * Use this (never a partial read) whenever the result will be re-encoded, or another client's - * unknown keys are dropped on the next publish. + * Decrypts the whole document with [signer] — entries, tombstones and the document residue — or + * **null** if it cannot be decrypted or parsed. + * + * Null, never empty: a caller that reads a decrypt failure as "no links yet" and republishes + * wipes every `signer_sk` on this replaceable coordinate. A bunker signer that momentarily + * refuses is enough to trigger it. Use this (never a partial read) whenever the result will be + * re-encoded, or another client's unknown keys are dropped on the next publish. */ - suspend fun decrypt(signer: NostrSigner): ConcordInviteListDocument = + suspend fun decrypt(signer: NostrSigner): ConcordInviteListDocument? = try { - ConcordInviteList.decode(signer.nip44Decrypt(content, signer.pubKey)) + ConcordInviteList.decodeOrNull(signer.nip44Decrypt(content, signer.pubKey)) } catch (_: Exception) { - ConcordInviteListDocument.EMPTY + null } companion object { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt index e488aeccb5..7a3904bdbc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt @@ -46,7 +46,7 @@ class ConcordInviteListTest { @Test fun readsTheSpecDocumentIntoTypedEntries() { - val doc = ConcordInviteList.decode(specJson) + val doc = ConcordInviteList.decodeOrNull(specJson)!! assertEquals(1, doc.entries.size) val e = doc.entries.first() @@ -65,7 +65,7 @@ class ConcordInviteListTest { @Test fun emitsTheSnakeCaseKeysAnotherClientReads() { - val json = ConcordInviteList.encode(ConcordInviteList.decode(specJson)) + val json = ConcordInviteList.encode(ConcordInviteList.decodeOrNull(specJson)!!) // Field names are the interop contract — a camelCase slip silently orphans every link. for (key in listOf("\"token\"", "\"signer_sk\"", "\"community_id\"", "\"url\"", "\"created_at\"", "\"expires_at\"", "\"entries\"", "\"tombstones\"")) { assertTrue(json.contains(key), "missing wire key $key") @@ -84,7 +84,7 @@ class ConcordInviteListTest { "doc_level_unknown": 7 } """.trimIndent() - val round = ConcordInviteList.encode(ConcordInviteList.decode(withExtras)) + val round = ConcordInviteList.encode(ConcordInviteList.decodeOrNull(withExtras)!!) assertTrue(round.contains("future_field"), "entry-level unknown key dropped") assertTrue(round.contains("doc_level_unknown"), "document-level unknown key dropped") @@ -116,9 +116,36 @@ class ConcordInviteListTest { } @Test - fun aMalformedDocumentYieldsEmptyRatherThanThrowing() { - assertEquals(0, ConcordInviteList.decode("not json").entries.size) - assertEquals(0, ConcordInviteList.decode("{\"entries\":\"wrong type\"}").entries.size) + fun aMalformedDocumentYieldsNullSoCallersCannotOverwriteWithIt() { + // Null, not empty: a caller that republishes an "empty" list over this replaceable + // coordinate destroys every signer_sk it failed to read. + assertEquals(null, ConcordInviteList.decodeOrNull("not json")) + assertEquals(null, ConcordInviteList.decodeOrNull("{\"entries\":\"wrong type\"}")) + } + + @Test + fun anUnreadableListIsDistinguishableFromAnEmptyOne() { + // The whole point of the null: a caller must be able to tell "I could not read it" from + // "there is nothing in it". Publishing a merge onto the latter is fine; onto the former it + // destroys every signer_sk on this replaceable coordinate. + assertEquals(null, ConcordInviteList.decodeOrNull("")) + + val empty = ConcordInviteList.decodeOrNull("""{"entries":[],"tombstones":[]}""") + assertEquals(0, empty!!.entries.size, "a genuinely empty list decodes, it does not fail") + + // And a merge onto an empty base keeps the patch, so starting a first list still works. + val patch = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t", "sk", "c", "u"))) + assertEquals(listOf("t"), ConcordInviteList.merge(empty, patch).entries.map { it.token }) + } + + @Test + fun theSignerPubKeyIsTheCoordinateTheBundleLivesAt() { + // Refreshing or revoking a link means writing at exactly this author, so it must derive from + // the secret we kept rather than being stored (and drifting) separately. + val sk = "11".repeat(32) + val entry = ConcordInviteListEntry("t", sk, "c", "u") + assertEquals(64, entry.signerPubKeyHex().length) + assertEquals(entry.signerPubKeyHex(), ConcordInviteListEntry("t2", sk, "c", "u2").signerPubKeyHex()) } @Test From a1f980babd3d19f2447f12af521e422fab16e2e2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 23:40:30 -0400 Subject: [PATCH 197/227] fix(concord): make the invite failure messages visible in the dark theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while proving the new ban gate on a tablet: the refusal reached the view hierarchy but rendered as black pixels on the black background, so the screen looked blank and the user was told nothing. `ConcordInviteScreen`'s Column sits on the bare window background with no Surface above it, so `LocalContentColor` is still Material 3's default black. This predates the invite work and silently affects every state the screen can end in — invalid, incompatible, revoked, expired, unreachable — plus the "Redeeming invite…" progress label, which is why only the spinner was ever visible while a join was in flight. Verified on device: the message now renders. Co-Authored-By: Claude Opus 5 (1M context) --- .../chats/publicChannels/concord/ConcordInviteScreen.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index d56c800791..7b19436713 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -163,6 +163,10 @@ fun ConcordInviteScreen( Text( stringRes(R.string.concord_redeeming_invite), modifier = Modifier.padding(top = 16.dp), + // Explicit: this Column sits on the bare window background with no Surface + // above it, so LocalContentColor is still the M3 default black — which renders + // every one of these labels invisible in the dark theme. + color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) } @@ -172,6 +176,7 @@ fun ConcordInviteScreen( Text( stringRes(failed.messageRes), style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) if (failed.canRetry) { From fc3f181184f4a034db604762fa7921f62d03d079 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Aug 2026 00:30:09 -0400 Subject: [PATCH 198/227] =?UTF-8?q?feat(cli):=20add=20`amy=20concord=20rev?= =?UTF-8?q?oke`=20=E2=80=94=20retire=20an=20invite=20link=20(CORD-05)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reading half of revocation already existed: `classify` has always resolved a `vsk=9` tombstone to `Revoked`, and Amethyst's join honours it. Nothing anywhere could *produce* one, so a leaked link could only be outrun by a Refounding — rotating the whole community to retire one URL. `ConcordInviteBundle.buildRevocation` emits the grave the spec describes and Armada's `buildRevocationEvent` already publishes: kind 33301 at the link's own `["d",""]` coordinate, empty content, `["vsk","9"]`, signed by the `link_signer` secret. Empty content is the interop contract, not an omission — there is nothing to encrypt when the point is that no bundle key opens anything. `amy concord revoke COMMUNITY TOKEN|URL` takes either the shareable URL a creator actually has to hand or the bare token. It publishes the wire tombstone FIRST and records the kind-13303 tombstone second, which is the inverse of minting and deliberate: the list entry holds the only copy of the `signer_sk` the publish needs, and a merge drops a tombstoned token's entry terminally. Recording first and then failing to publish would leave the link live with its signer gone and no way left to retire it. A failed list write is recoverable by comparison and is reported rather than swallowed. Also fixes a revocation bypass in amy's own `join`, found while testing this: it opened the first wrap that decrypted instead of classifying the coordinate, so a relay still serving a stale copy alongside the grave would have handed out a revoked link. It now resolves per CORD-05 §2 like Amethyst does, and can say which of revoked/expired/unreadable/absent it hit instead of reporting everything as `not_found`. Verified end to end against a local relay: revoking flips the coordinate to vsk=9 with empty content, the link is refused from that moment on, a second revoke reports `already_revoked`, and a Refounding afterwards moves the surviving link while leaving the grave alone — the first real proof of the tombstone-skip in the refresh path, which until now had only unit coverage. Co-Authored-By: Claude Opus 5 (1M context) --- cli/README.md | 1 + cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 1 + .../amethyst/cli/commands/ConcordCommands.kt | 107 +++++++++++++++++- .../commons/actions/ConcordActions.kt | 15 +++ .../cord05Invites/ConcordInviteBundle.kt | 16 +++ .../bundle/ConcordInviteBundleEvent.kt | 17 +++ .../ConcordInviteClassifyTest.kt | 35 ++++++ 8 files changed, 190 insertions(+), 4 deletions(-) diff --git a/cli/README.md b/cli/README.md index d3f0591f78..76ec8f2844 100644 --- a/cli/README.md +++ b/cli/README.md @@ -669,6 +669,7 @@ also carried on-relay as an encrypted kind:13302. | `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). | | `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane. | | `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link. | +| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. | | `amy concord join URL` | Redeem an invite link and save the community. | | `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). | | `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`). | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index add1d87d06..590964f6d4 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -67,7 +67,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | NIP-65 outbox model queries | ✅ | `OutboxCommand` — `amy outbox USER [--refresh]`, cache-first. | | CLINK offers + debits (`amy offer` / `amy debit`) | ✅ | `OfferCommands` + `DebitCommands` — pointer decode, NIP-05 discover, kind:21001/21002 round-trips, `offer pay --with NDEBIT` end-to-end settlement. `--timeout` is SECONDS. | | Geochat (Bitchat geohash, ephemeral kind:20000) | ✅ | `GeochatCommands` — listen/send/keys with per-geohash throwaway identity + geo-nearest relay routing; doubles as the Bitchat interop harness. | -| Concord Channels (encrypted communities) | ✅ | `ConcordCommands` — 13 sub-verbs (create/list/import/channels/send/read/invite/join/roles/role/grant/ban/unban) over shared `commons` `ConcordActions`; secrets in `concord.json`. | +| Concord Channels (encrypted communities) | ✅ | `ConcordCommands` — 17 sub-verbs (create/list/import/channels/send/read/invite/revoke/join/recover/rekey/roles/role/grant/ban/unban/refound) over shared `commons` `ConcordActions`; secrets in `concord.json`. | | NIP-5A nsites + NIP-5D napplets | ✅ | `NsiteCommands` + `NappletCommands` — fetch/publish/serve/list with sha256 + aggregate-hash verification and `requires` capability reporting. | | Podcasting 2.0 / podstr (`amy podcast20`) | ✅ | `Podcast20Commands` — kind:30078 metadata, 30054 episodes, 30055 trailers, list. | | Follows-of-follows (`amy fof get/list/sync`) | ✅ | `FofCommand` — single-hop social proof from the local store (`wot` kept as deprecation alias). | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 5b14b8da61..0ddc3f5762 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -869,6 +869,7 @@ private fun printUsage() { | concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id) | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord revoke COMMUNITY TOKEN|URL retire a link you minted (vsk=9 tombstone) | concord join URL redeem an invite link and save the community | |Local event store (shared, under `/shared/`): diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index f2890e7775..77047c4e5b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -66,6 +67,9 @@ object ConcordCommands { | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages (default 50); | [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord revoke COMMUNITY TOKEN|URL retire a link you minted: publishes a vsk=9 + | tombstone at its coordinate, then tombstones + | it in your invite list so it stays retired | concord join URL redeem an invite link and save the community | concord rekey [COMMUNITY] follow a Refounding we were re-keyed for: | open our blob and adopt the new epoch @@ -89,7 +93,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -100,6 +104,7 @@ object ConcordCommands { "send" to { rest -> ConcordChannelCommands.send(dataDir, rest) }, "read" to { rest -> ConcordChannelCommands.read(dataDir, rest) }, "invite" to { rest -> invite(dataDir, rest) }, + "revoke" to { rest -> revoke(dataDir, rest) }, "join" to { rest -> join(dataDir, rest) }, "recover" to { rest -> recover(dataDir, rest) }, "rekey" to { rest -> rekey(dataDir, rest) }, @@ -307,6 +312,92 @@ object ConcordCommands { } } + /** + * `amy concord revoke ` — retires one link this account minted. + * + * Two records have to agree for a link to be gone, and they fail differently, so the order is + * deliberate. The wire tombstone (`vsk=9` at the link's own coordinate) is what actually stops + * a join, and publishing it needs the `signer_sk` that only the kind-13303 Invite List holds. + * The list tombstone is bookkeeping: it stops a later Refounding from re-minting the link. + * + * So the wire goes first and the list second. The reverse order would delete the entry — a + * merge drops a tombstoned token's entry terminally — and if the publish then failed, the link + * would stay live with its `signer_sk` gone and no way left to retire it. A failed list write + * is recoverable by comparison: the link is already dead on the wire, and the refresh path + * re-mints only a coordinate that still resolves Live, so it will not resurrect this one. + */ + private suspend fun revoke( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val link = args.positional(1, "token|url") + args.rejectUnknown() + + // Accept either the shareable URL (what a creator actually has to hand) or the bare token. + val token = + ConcordActions + .parseInviteLink(link) + ?.fragment + ?.token + ?.toHexKey() ?: link.lowercase() + if (!TOKEN_HEX.matches(token)) { + return Output.error("bad_args", "expected an invite URL or a 32-hex-character link token, got '$link'").let { 2 } + } + + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle) + Context.open(dataDir).use { ctx -> + ctx.prepare() + + val list = + readInviteList(ctx) + ?: return Output.error("invite_list_unreadable", "could not read your invite list (kind 13303), so the link signer needed to revoke is unknown — refusing to guess") + + val entry = list.entries.firstOrNull { it.token == token } + if (entry == null) { + return if (list.tombstones.any { it.token == token }) { + Output.error("already_revoked", "this link was already revoked; its signer_sk is gone from the list, so there is nothing left to re-publish") + } else { + Output.error("not_found", "no link with token $token in your invite list — only the account that minted a link can revoke it") + } + } + if (entry.communityId != sc.communityId) { + return Output.error("wrong_community", "that link belongs to community ${entry.communityId}, not '$handle' (${sc.communityId})") + } + + val tombstone = ConcordActions.revokeBundleAt(entry.signerSk.hexToByteArray(), TimeUtils.now()) + val ack = ctx.publish(tombstone, relaysFor(ctx, sc)) + RawEventSupport.publishGuard(ack, tombstone.id)?.let { return it } + + val recorded = + publishInviteList( + ctx, + ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = sc.communityId))), + ) + if (!recorded) { + System.err.println( + "[concord] the link is revoked on the wire but the tombstone could not be recorded in your invite list (kind 13303); re-run this command once your outbox relays are reachable", + ) + } + + Output.emit( + mapOf( + "revoked" to true, + "token" to token, + "community_id" to sc.communityId, + "link_signer" to entry.signerPubKeyHex(), + "tombstone_event_id" to tombstone.id, + "tombstoned_in_list" to recorded, + ) + RawEventSupport.ackFields(ack), + ) + return 0 + } + } + + /** A link token is 16 bytes on the wire, so 32 hex characters once stored in the list. */ + private val TOKEN_HEX = Regex("^[0-9a-f]{32}$") + private suspend fun join( dataDir: DataDir, rest: Array, @@ -320,9 +411,19 @@ object ConcordCommands { ctx.prepare() val relays = (normalize(parsed.fragment.relays) + ctx.bootstrapRelays()) val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second } + // Resolve the coordinate per CORD-05 §2 rather than opening whatever happens to decrypt: + // the newest event wins, so a vsk=9 tombstone retires the link even when a stale but + // still-openable copy is also present. Opening the first wrap that decrypts would let a + // relay that kept the old version hand out a link its creator revoked — and it cannot + // tell the user which of "revoked", "expired" or "gone" they are looking at. val bundle = - wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } - ?: return Output.error("not_found", "no valid bundle for this link").let { 1 } + when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { + is InviteBundleStatus.Live -> status.invite + is InviteBundleStatus.Expired -> return Output.error("expired", "this invite link has expired and can no longer be joined") + InviteBundleStatus.Revoked -> return Output.error("revoked", "this invite link was revoked by its creator") + InviteBundleStatus.Unreadable -> return Output.error("incompatible", "something is published at this link's coordinate, but it is not a bundle this client can open") + InviteBundleStatus.Absent -> return Output.error("not_found", "no bundle for this link on any of its relays") + } // Refuse a link that readmits us after we were removed. A Refounding re-mints every // outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 6263c65add..dfb51935d7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -459,6 +459,21 @@ object ConcordActions { createdAt: Long, ): Event = ConcordInviteBundle.build(linkSignerPrivKey, token, invite, createdAt) + /** + * Retires an existing link by publishing a `vsk=9` revocation tombstone at its coordinate + * (CORD-05 §2). Once this lands, every client resolving that URL gets + * [com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus.Revoked] instead of keys. + * + * Publish this *before* recording the tombstone in the kind-13303 Invite List — the list entry + * carries the only copy of the `signer_sk` this call needs, and the list merge drops a + * tombstoned token's entry for good. Recording first and failing to publish would leave the link + * live on the wire with no way left to retire it. + */ + fun revokeBundleAt( + linkSignerPrivKey: ByteArray, + createdAt: Long, + ): Event = ConcordInviteBundle.buildRevocation(linkSignerPrivKey, createdAt) + /** Parses a shareable invite URL into its pointer + private fragment. */ fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt index ee528efb5b..4f36ea409d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -109,6 +109,22 @@ object ConcordInviteBundle { return signer.sign(ConcordInviteBundleEvent.build(content, createdAt)) } + /** + * Builds the kind-33301 revocation tombstone that retires the link owned by [linkSignerPrivKey] + * (CORD-05 §2). It re-posts the link's own coordinate with empty content and `vsk=9`, so the + * newest event there is a grave rather than keys and [classify] resolves the link + * [InviteBundleStatus.Revoked] for everyone who resolves it afterwards. + * + * Only the creator can do this: the coordinate is addressable and authored by the link signer, + * so retiring a link requires the `link_signer` secret — which lives in the creator's kind-13303 + * Invite List and nowhere else. Losing that secret makes a link permanently un-revokable, which + * is why the list is written before a link is ever handed out. + */ + fun buildRevocation( + linkSignerPrivKey: ByteArray, + createdAt: Long, + ): Event = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)).sign(ConcordInviteBundleEvent.buildRevocation(createdAt)) + /** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */ fun parse( event: Event, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt index 8e17c755fb..b2302cdf68 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt @@ -68,5 +68,22 @@ class ConcordInviteBundleEvent( addUnique(VskTag.assemble(ControlEntityKind.INVITE_LIVE)) initializer() } + + /** + * Builds the revocation tombstone that retires a link: the **same** `["d",""]` coordinate, + * empty content, and `["vsk","9"]` ([ControlEntityKind.INVITE_REVOKED]). + * + * Empty content is the interop contract, not an omission — the spec's "a fetcher finds the + * grave instead of keys", and byte-for-byte what Armada's `buildRevocationEvent` emits. + * There is nothing to encrypt: the point is that no bundle key opens anything here. + */ + fun buildRevocation( + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag("") + addUnique(VskTag.assemble(ControlEntityKind.INVITE_REVOKED)) + initializer() + } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt index 95329c3b98..b0ac0bf1a9 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -152,6 +153,40 @@ class ConcordInviteClassifyTest { assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16))) } + @Test + fun buildRevocationEmitsTheWireShapeArmadaEmits() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + + val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L) + + // The interop contract, byte for byte: kind 33301 at the SAME addressable coordinate + // (same author, same empty d tag), empty content, vsk=9. Anything else here and a + // non-Amethyst client keeps serving a link its creator believes is dead. + assertEquals(ConcordInviteBundleEvent.KIND, grave.kind) + assertEquals(minted.linkSignerPubKey, grave.pubKey, "a tombstone at a different author retires nothing") + assertEquals("", grave.content, "the grave carries no keys — nothing to encrypt") + assertEquals(listOf(listOf("d", ""), listOf("vsk", "9")), grave.tags.map { it.toList() }) + assertTrue(grave.verify(), "must be signed by the link signer the creator kept") + } + + @Test + fun aBuiltRevocationRetiresItsOwnLink() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + + // End to end: what the creator publishes is what every redeemer then resolves. + val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L) + assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.token)) + + // And a re-mint that lands AFTER the grave un-revokes the link, which is exactly why the + // refresh path must skip a coordinate it did not resolve Live first. + val remint = ConcordInviteBundle.build(minted.linkSignerPrivKey, minted.token, inviteFor(community), createdAt = 3L) + assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.token) is InviteBundleStatus.Live) + } + @Test fun realRelayopBundleIsUnreadable() { // The actual kind-33301 event behind the reported relayop.xyz/invite link (vsk=8), plus the From d27930fe761725896e8a7e8459f67da4f82b3429 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Aug 2026 00:57:23 -0400 Subject: [PATCH 199/227] feat(concord): manage and revoke your invite links from Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revoking existed only in `amy` after the last commit, so the app could hand out a link it could never take back. This adds the Android half. `Invite links…` in a community's overflow menu opens a screen listing every link this account minted for it, read from the creator's own kind-13303 Invite List, each row offering Copy and Revoke. It shows only *our* links, because a link's `signer_sk` is what authors its coordinate and only the minting account ever held it — another admin's links are invisible here and un-revokable from here. That is the protocol, not a gap in the screen. Two deliberate choices: The entry point is NOT gated on CREATE_INVITE, unlike minting. Revoking acts on a key we hold rather than on the community, and gating it on the bit would mean a demoted admin could no longer retire the links they had already handed out — exactly when that matters most. An unreadable list is its own state, never an empty one. Telling a creator who came to kill a leaked link that they have no links would be a lie in the one direction that costs them something. `revokeConcordInvite` publishes the wire tombstone first and records the kind-13303 tombstone second, for the same reason the CLI does: the entry holds the only copy of the `signer_sk` the publish needs, and a merge drops a tombstoned token's entry terminally. A failed list write is reported as success because the link is already dead on the wire. Verified on a tablet against a local relay, cross-client with amy: the screen lists the two links the device minted (and not the one alice minted), the confirm dialog revokes exactly one coordinate — flipping it to vsk=9 with empty content while its siblings stay vsk=6 — the row disappears on reload, and a link revoked from the UI is then refused by `amy concord join` with `revoked` while the surviving link still joins. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/model/AccountConcordActions.kt | 69 +++++ .../amethyst/ui/navigation/AppNavigation.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../concord/ConcordChannelListScreen.kt | 11 + .../concord/ConcordInviteLinksScreen.kt | 273 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 11 + 6 files changed, 377 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index daad3aa9c7..35816e52ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys @@ -339,6 +340,74 @@ class AccountConcordActions( return minted.url } + /** + * Every link this account minted for [communityId] that is still live, newest first — the + * backing list for the invite-links screen. + * + * Null means the list could not be read (no relay answered, or the signer refused the decrypt), + * which the UI must show as an error rather than as "you have no links": telling a creator their + * leaked link doesn't exist is worse than telling them we couldn't check. + * + * Retired tokens are filtered out here rather than rendered as dead rows — [ConcordInviteList] + * already drops a tombstoned entry on merge, so a tombstoned entry only appears in the window + * between our revoke and the next merge. + */ + suspend fun listConcordInviteLinks(communityId: String): List? { + val list = readConcordInviteList() ?: return null + val tombstoned = list.tombstones.mapTo(HashSet()) { it.token } + return list.entries + .filter { it.communityId == communityId && it.token !in tombstoned } + .sortedByDescending { it.createdAt } + } + + /** + * Retires the link [token] (CORD-05 §2): publishes a `vsk=9` tombstone at its coordinate, then + * records the retirement in the kind-13303 list. Returns false if the link could not be retired. + * + * No community permission is checked, deliberately. The coordinate is authored by the link + * signer, whose secret only the creator holds, so revoking is an act on your own key rather than + * on the community — and gating it on CREATE_INVITE would mean a demoted admin could no longer + * retire the links they had already handed out, which is precisely when they most need to. + * + * The wire tombstone goes first and the list second. That is the inverse of minting and it is + * deliberate: the entry holds the only copy of the `signer_sk` this needs, and a merge drops a + * tombstoned token's entry terminally, so recording first and then failing to publish would + * leave the link live with its signer gone and no way left to retire it. A failed list write is + * recoverable — the link is already dead on the wire, and the refresh path re-mints only a + * coordinate that still resolves Live. + */ + suspend fun revokeConcordInvite( + communityId: String, + token: String, + ): Boolean { + if (!account.isWriteable()) return false + val entry = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } ?: return false + val link = + readConcordInviteList()?.entries?.firstOrNull { it.token == token && it.communityId == communityId } + ?: run { + Log.w("Concord") { "Cannot revoke $token: it is not in this account's invite list, so its link signer is unknown" } + return false + } + + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + if (relays.isEmpty()) return false + val published = + runCatching { + account.client.publish(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays) + true + }.onFailure { Log.w("Concord", "invite revocation failed for $communityId", it) }.getOrDefault(false) + if (!published) return false + + if (!publishConcordInviteList(ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = communityId))))) { + // The link is already dead on the wire, so this is bookkeeping we can retry rather than a + // failed revocation. Reported as success for exactly that reason. + Log.w("Concord") { "Revoked $token on the wire but could not tombstone it in the invite list; a later revoke will record it" } + } + return true + } + /** Drop a joined Concord community from the private kind-13302 list by its id. */ suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.unfollow(communityId)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index ee1ace2d8d..157a12d191 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -138,6 +138,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteLinksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen @@ -747,6 +748,14 @@ fun BuildNavigation( ) } + composableFromEndArgs { + ConcordInviteLinksScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { ConcordEditScreen( communityId = it.communityId, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b1afa1c5b9..c267a23c73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -830,6 +830,10 @@ sealed class Route { val communityId: String, ) : Route() + @Serializable data class ConcordInviteLinks( + val communityId: String, + ) : Route() + @Serializable object ConcordCreate : Route() // Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index d33dfee5e2..57dffc1084 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -294,6 +294,17 @@ fun ConcordChannelListScreen( SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options)) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + // Deliberately not gated on CREATE_INVITE, unlike minting: the links listed + // there are this account's own, authored by link-signer keys only we hold. + // Gating on the bit would mean a demoted admin could no longer retire the + // links they had already handed out — exactly when that matters most. + DropdownMenuItem( + text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_links_action)) }, + onClick = { + menuOpen = false + nav.nav(Route.ConcordInviteLinks(communityId)) + }, + ) DropdownMenuItem( text = { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt new file mode 100644 index 0000000000..ef10b8c2bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** What the screen is currently showing. The unreadable case is deliberately not "empty" — see below. */ +private sealed interface LinksState { + data object Loading : LinksState + + data class Loaded( + val links: List, + ) : LinksState + + /** + * The kind-13303 list could not be read. Distinct from an empty list on purpose: rendering + * "no links yet" here would tell a creator that the link they came to kill does not exist. + */ + data object Unreadable : LinksState +} + +/** + * Every invite link this account minted for one community, with the ability to retire one + * (CORD-05 §2). + * + * The list is the creator's own kind-13303 Invite List, which is where a link's `signer_sk` lives — + * so this shows only links *this account* minted, from any of its devices. Another admin's links are + * invisible here and un-revokable from here, because the secret that authors their coordinate was + * never ours. That is a property of the protocol, not a gap in the screen. + * + * Fetched on entry rather than collected from a flow: nothing subscribes to kind 13303 (it is + * bookkeeping the user never sees), so there is no cache to observe. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordInviteLinksScreen( + communityId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + val scope = rememberCoroutineScope() + val clipboard = LocalClipboard.current + + var state by remember(communityId) { mutableStateOf(LinksState.Loading) } + var reloads by remember(communityId) { mutableIntStateOf(0) } + var confirming by remember { mutableStateOf(null) } + var revoking by remember { mutableStateOf(false) } + + LaunchedEffect(communityId, reloads) { + state = LinksState.Loading + state = account.concord.listConcordInviteLinks(communityId)?.let { LinksState.Loaded(it) } ?: LinksState.Unreadable + } + + val communityName = + remember(account, communityId) { + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } + ?.name + .orEmpty() + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(stringRes(R.string.concord_invite_links_title), fontWeight = FontWeight.Bold) + if (communityName.isNotBlank()) { + Text(communityName, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } + }, + ) + }, + ) { padding -> + when (val current = state) { + is LinksState.Loading -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + is LinksState.Unreadable -> CenteredMessage(padding, stringRes(R.string.concord_invite_links_unreadable)) + + is LinksState.Loaded -> + if (current.links.isEmpty()) { + CenteredMessage(padding, stringRes(R.string.concord_invite_links_empty)) + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(current.links, key = { it.token }) { link -> + InviteLinkRow( + link = link, + enabled = !revoking, + onCopy = { scope.launch { clipboard.setText(link.url) } }, + onRevoke = { confirming = link }, + ) + HorizontalDivider() + } + } + } + } + } + + confirming?.let { link -> + AlertDialog( + onDismissRequest = { if (!revoking) confirming = null }, + title = { Text(stringRes(R.string.concord_invite_revoke_title)) }, + text = { Text(stringRes(R.string.concord_invite_revoke_explainer)) }, + confirmButton = { + TextButton( + enabled = !revoking, + onClick = { + revoking = true + scope.launch { + try { + val ok = account.concord.revokeConcordInvite(communityId, link.token) + accountViewModel.toastManager.toast( + R.string.concord_invite_links_title, + if (ok) R.string.concord_invite_revoked_ok else R.string.concord_invite_revoked_failed, + ) + // Re-read either way: on success the link is gone from the list, and on + // failure the list is the only thing that can say whether it changed. + reloads++ + } finally { + revoking = false + confirming = null + } + } + }, + ) { + Text(stringRes(R.string.concord_invite_revoke_confirm), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(enabled = !revoking, onClick = { confirming = null }) { + Text(stringRes(R.string.cancel)) + } + }, + ) + } +} + +@Composable +private fun CenteredMessage( + padding: PaddingValues, + message: String, +) { + Box(Modifier.fillMaxSize().padding(padding).padding(24.dp), contentAlignment = Alignment.Center) { + Text( + message, + // This Box sits on the bare window background, so LocalContentColor is still the M3 + // default black — see the sibling invite screen, where that made the text invisible. + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@Composable +private fun InviteLinkRow( + link: ConcordInviteListEntry, + enabled: Boolean, + onCopy: () -> Unit, + onRevoke: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.weight(1f).padding(end = 8.dp)) { + // The token prefix is what tells two links to the same community apart; their URLs share + // a long prefix, so they are useless as labels until well past where the row wraps. + Text(link.token.take(8), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge) + Text( + stringRes(R.string.concord_invite_links_created, DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(link.createdAt * 1000))), + style = MaterialTheme.typography.bodySmall, + ) + Text(link.url, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + IconButton(enabled = enabled, onClick = { menuOpen = true }) { + SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringRes(R.string.copy_to_clipboard)) }, + onClick = { + menuOpen = false + onCopy() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.concord_invite_revoke_action), color = MaterialTheme.colorScheme.error) }, + onClick = { + menuOpen = false + onRevoke() + }, + ) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 20e32ea0b7..9489821e4e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -322,6 +322,17 @@ This invite link can\'t be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link. This invite link has been revoked and can no longer be used. Ask for a new one. This community has removed you. The link still works, but its member list does not admit you. + Invite links + Invite links… + Created %1$s + You haven\'t created any invite links for this community yet. Links other admins created are managed on their own devices. + Your invite links couldn\'t be loaded, so none can be revoked right now. Check your connection and try again. + Revoke link + Revoke this link? + Anyone still holding this link will no longer be able to join. People who already joined with it stay in the community. This can\'t be undone. + Revoke + Invite link revoked. + The link couldn\'t be revoked. Check your connection and try again. This invite link has expired and can no longer be used. Ask for a fresh link. Community name is only revealed after you join Joining connects to this invite\'s relays, publishes a join announcement signed by your account, and adds the community to your list. Nothing is sent until you tap Join. From 6e5f8c0fa076ca9d3f9439b79b4fc0db5a61bc79 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 6 Aug 2026 09:28:05 +0300 Subject: [PATCH 200/227] docs: upstream reconciliation plan for shared metadata loading (v2) Redo the commons extraction of the per-user + per-note finders on top of current upstream/main, which re-architected the subsystem (AccountScopedQuery attribution + SubPurpose/ExplainedFilter tagging). Key finding: attribution needs only a pubkey, so the narrow UserFinderAccount seam survives and the finder query states can drop AccountScopedQuery entirely. Co-Authored-By: Claude Opus 4.8 --- ...ta-loading-upstream-reconciliation-plan.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 desktopApp/plans/2026-08-06-shared-metadata-loading-upstream-reconciliation-plan.md diff --git a/desktopApp/plans/2026-08-06-shared-metadata-loading-upstream-reconciliation-plan.md b/desktopApp/plans/2026-08-06-shared-metadata-loading-upstream-reconciliation-plan.md new file mode 100644 index 0000000000..e30caa24c7 --- /dev/null +++ b/desktopApp/plans/2026-08-06-shared-metadata-loading-upstream-reconciliation-plan.md @@ -0,0 +1,110 @@ +# Shared Metadata Loading — Upstream Reconciliation Plan +*Redo the commons extraction of the per-user metadata finder and per-note event finder on top of current `upstream/main`, abandoning the stale rebase of `worktree-plan-shared-metadata-loading` (`682c6000f5`).* + +## 0. Executive summary of what changed under us + +Upstream re-architected the same subsystem but **also completed the model-sharing prerequisite we depended on**: + +- `amethyst.model.User`, `UserContext`, `Note` are now **typealiases** to `commons.model.*` (`amethyst/model/User.kt`). `SincePerRelayMap`, `MutableTime`, `EOSERelayList` are typealiases to `commons.relays.*` (`amethyst/service/relays/EOSE.kt`). Finder bodies are already type-compatible with commonMain. +- `LocalCache` is an `object` implementing `commons.model.cache.ICacheProvider` (exposes `getUserIfExists`, `getNoteIfExists`, `getOrCreateUser`, `relayHints`, …). The finders call exactly these. +- **Two new cross-cutting mechanisms**: + 1. `AccountScopedQuery { val account: amethyst.model.Account }` — implemented by `UserFinderQueryState`/`EventFinderQueryState` so base managers can attribute subscriptions to an account. + 2. `ExplainedFilter`/`SubPurpose` (already in commons) — every emitted filter is tagged with its purpose. + +**Most important finding:** the base **commons** managers never read `.account`. Attribution lives in *amethyst-side* managers (`PerUserEoseManager`, `PerUniqueIdEoseManager`, …) and each reads exactly `(key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex` — **only a pubkey hex**. AND the four finder sub-assemblers extend the **commons** base managers, not the amethyst attribution managers — they attribute *inline* by computing `soleAccountPubKey` and passing `accountPubKeys` into their `ExplainedFilter`s. So the finders do not depend on the amethyst attribution managers; they need only a pubkey. + +## A. Map of upstream's current subsystem + +### User side — `amethyst/.../reqCommand/user/` +- `UserFinderFilterAssembler.kt` — `UserFinderQueryState(user, override val account: Account) : AccountScopedQuery`; groups `UserOutboxFinderSubAssembler`, `UserWatcherSubAssembler`, `UserReportsSubAssembler`, `UserCardsSubAssembler`. +- `UserFinderFilterAssemblerSubscription.kt` — composable entry (`(user, accountViewModel)`) + `UserFinderByParentFilterAssemblerSubscription`; uses `AccountViewModel.account`, `dataSources().userFinder`, commons `LifecycleAwareKeyDataSourceSubscription`. +- `UserObservers.kt` — `observeUser*`. +- `loaders/UserOutboxFinderSubAssembler.kt` — extends commons `BaseEoseManager`; reads `it.account` → `pickRelaysToLoadUsers(...)`; emits `ExplainedFilter(purpose = RELAY_LISTS, accountPubKeys = listOfNotNull(soleAccountPubKey))`. +- `watchers/UserWatcherSubAssembler.kt` — commons `BaseEoseManager`; reads `account.indexerRelayList.flow.value`; calls `filterUserMetadataForKey(...)`. +- `watchers/FilterUserMetadataForKey.kt` — emits `ExplainedFilter(PROFILE_METADATA, …)`; reads `LocalCache.relayHints.hintsForKey(...)` **statically** (the one non-injected cache ref). +- `watchers/UserReportsSubAssembler.kt` — commons `SingleSubEoseManager`; reads `account.declaredFollowsPerOutboxRelay.value`, `account.userProfile().pubkeyHex`. +- `watchers/UserCardsSubAssembler.kt` — commons `SingleSubEoseManager`; reads `account.homeRelays.flow.value`, `account.trustProviderList.liveUserRankProvider`, `…liveUserFollowerCount`, `account.userProfile().pubkeyHex`; emits `filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(...)` (already in commons `assemblers/ContactCardFilters.kt`). + +### Event side — `amethyst/.../reqCommand/event/` +- `EventFinderFilterAssembler.kt` — `EventFinderQueryState(note, override val account: Account) : AccountScopedQuery`; groups `NoteEventLoaderSubAssembler`, `EventWatcherSubAssembler`, `AddressableAuthorRelayLoaderSubAssembler(cache, ::allKeys, userFinder)`. +- `EventFinderFilterAssemblerSubscription.kt` — reads `accountViewModel.account`, `dataSources().eventFinder`. +- `EventObservers.kt` — `observeNote*` (one UI-only spot reads `accountViewModel.account.userProfile().pubkeyHex` for a moderator check). +- `loaders/NoteEventLoaderSubAssembler.kt`; `loaders/FilterMissingEvents.kt` (reads `key.account.followPlusAllMineWithSearch.flow.value`, `key.account.searchRelayList.flow.value`; `SubPurpose.REFERENCED_EVENTS`); `loaders/FilterMissingAddressables.kt` (`REFERENCED_EVENTS`). +- `loaders/AddressableAuthorRelayLoaderSubAssembler.kt` — constructs `UserFinderQueryState(author, key.account)`. +- `watchers/EventWatcherSubAssembler.kt` — commons `SingleSubEoseManager`; reads `it.account.userProfile().pubkeyHex` (attribution only); calls `filterRepliesAndReactionsToNotes/Addresses(...)`. +- `watchers/FilterRepliesAndReactionsToNotes.kt`, `FilterRepliesAndReactionsToAddresses.kt` — emit `ExplainedFilter(ENGAGEMENT, …)`. + +### Base managers and attribution +- commons `BaseEoseManager`, `PerKeyEoseManager`, `SingleSubEoseManager` — **account-agnostic**. +- amethyst `PerUserEoseManager`, `PerUniqueIdEoseManager`, `PerUserAndFollowListEoseManager`, `SingleSubNoEoseCacheEoseManager` — do `f.attributedTo(pk)` where `pk = (key as? AccountScopedQuery).account.userProfile().pubkeyHex`. The finder sub-assemblers do NOT use these; they attribute inline. +- `ExplainedFilter.attributedTo(accountPubKey: HexKey)`; `SubPurpose`/`SubPurposeGroup` — commonMain. + +## B. Account-seam decision — CHOSEN + +**Keep `UserFinderAccount` as the narrow commons seam, carrying the attribution pubkey via `userFinderPubkeyHex`. Do NOT move `AccountScopedQuery` to commons. Drop `AccountScopedQuery` from the two finder query states.** + +Justification (grounded in code): +1. Attribution reduces to `.userProfile().pubkeyHex` — a `HexKey`, already on `UserFinderAccount.userFinderPubkeyHex`. +2. The loaders' account reads are all snapshot relay-hint / follow-graph getters — exactly the `UserFinderAccount` surface (+2 additions). +3. `AccountScopedQuery` is declared over `amethyst.model.Account` and implemented by ~66 amethyst query states — can't move without dragging `Account`. +4. The finder query states never flow through the amethyst attribution managers, so they don't need `AccountScopedQuery` at all → dropping it removes the collision. + +Seam shape (commons `UserFinderAccount`), = our old branch + 2 additions: +```kotlin +interface UserFinderAccount { + val userFinderPubkeyHex: HexKey // attribution pubkey → ExplainedFilter.accountPubKeys + fun indexRelays(): Set + fun outboxHomeRelays(): Set + fun searchRelays(): Set + fun followPlusAllMineWithSearchRelays(): Set + fun commonRelays(): Set + fun cardHomeRelays(): Set + fun trustProvider(): ServiceProviderTag? + fun followerCountProvider(): ServiceProviderTag? // NEW (UserCards reads liveUserFollowerCount) + fun declaredFollowsByOutboxRelay(): Map> +} +``` +`EventFinderQueryState` reuses `UserFinderAccount` (needs only follow+search relays + pubkey). Attribution: `soleAccountPubKey = keys.map { it.account.userFinderPubkeyHex }.singleOrNull()` → `accountPubKeys = listOfNotNull(soleAccountPubKey)`. + +## C. SubPurpose mapping (preserve upstream tags across the move) + +| Moved helper | SubPurpose | +|---|---| +| `FilterUserMetadataForKey` (kind 0 bundle) | `PROFILE_METADATA` (runsInBackground) | +| `UserOutboxFinderSubAssembler` | `RELAY_LISTS` | +| `UserCardsSubAssembler` → `ContactCardFilters` (already commons) | unchanged | +| `UserReportsSubAssembler` → `filterReportsToKeysFromTrusted` | `MODERATION` | +| `EventWatcherSubAssembler` → replies/reactions | `ENGAGEMENT` | +| `NoteEventLoader` → `FilterMissingEvents/Addressables` | `REFERENCED_EVENTS` | + +`ExplainedFilter`/`SubPurpose`/`attributedTo`/`ContactCardFilters` are already commonMain + iOS-pure. Only change: `accountPubKeys` sourced from `userFinderPubkeyHex`. + +## D. Portable-unchanged vs must-rework + +**Unchanged (body identical after package move + seam swap):** the filter builders (`FilterUserMetadataForKey`, `FilterReportsToKey`, `FilterMissingEvents/Addressables`, `FilterRepliesAndReactionsToNotes/Addresses`), `observeUser*`/`observeNote*`, `UserFinderAccount` + CompositionLocals, `pickRelaysToLoadUsers` inner overload. + +**Must rework:** +1. **Move `EOSEAccountFast`** (`amethyst/service/relays/EOSE.kt`) to commons `relays/` + amethyst typealias — prereq for the loaders. (Purity risk: verify no `System.currentTimeMillis`/`Thread.sleep`.) +2. **`LocalCache.relayHints` static ref** in `FilterUserMetadataForKey` → injected `cache.relayHints` (add `val relayHints: HintIndexer` to `ICacheProvider`; LocalCache already has it). +3. **Account-seam swap** across the four user sub-assemblers + event loaders/watchers (getter-for-flow mapping listed in §B). +4. **`pickRelaysToLoadUsers`** — feed commons inner overload from `UserFinderAccount` getters (amethyst keeps a thin `Account`→relay-set wrapper). +5. **Drop `AccountScopedQuery`** from the two finder query states (verified no amethyst attribution manager consumes them). +6. `AddressableAuthorRelayLoaderSubAssembler` — `UserFinderQueryState(author, key.account)` now takes `UserFinderAccount`. Clean. + +**Desktop wiring (Phase 3/3b old plan) — unaffected:** `observeUser*`, `EventFinderFilterAssemblerSubscription(note)`, Locals, DM/search/notifications adoption, fast index-relay warm-up. `DesktopIAccount` adds `followerCountProvider() = null` (already degrades trust/declaredFollows). + +## E. Phase / commit sequence (fresh branch `feat/shared-metadata-loading-v2` off `upstream/main`) + +Cherry-pick *content*, not commits. Gates after each commons/amethyst commit: `:commons:verifyKmpPurity`, `:amethyst:compilePlayDebugKotlin`, `:commons:compileKotlinJvm` + `:desktopApp:compileKotlinJvm`. + +- **Phase 0 — prereqs:** (0a) move `EOSEAccountFast` → commons + typealias; (0b) add `relayHints` to `ICacheProvider`. +- **Phase 1 — seam:** (1a) add commons `UserFinderAccount` (+`followerCountProvider()`); (1b) `Account implements UserFinderAccount`. +- **Phase 2 — user finder → commons:** (2a) filter builders + `pickRelaysToLoadUsers` inner; (2b) the 4 sub-assemblers; (2c) `UserFinderFilterAssembler`+`UserFinderQueryState` (drop `AccountScopedQuery`) + amethyst typealias shim + commons `UserFinderSubscription`/Locals; keep composable subscription in amethyst delegating. +- **Phase 3 — event finder → commons:** (3a) filter builders; (3b) loaders/watchers + addressable bridge; (3c) assembler+state (drop `AccountScopedQuery`) + shim + `LocalEventFinder` + `observeNote*` to commons. +- **Phase 4 — Desktop wiring:** (4a) `DesktopIAccount implements UserFinderAccount`; (4b) provide Locals in `Main.kt`, wire DM/search/notifications + warm-up onto `observeUser*`/`EventFinderFilterAssemblerSubscription`. +- **Phase 5 — cleanup & tests:** delete dead originals; run `ExplainedFilterTest` + finder tests + full `:commons:check`. + +### Risk callouts +- `EOSEAccountFast` purity is the likeliest `verifyKmpPurity` tripwire. +- Preserve inline `soleAccountPubKey` attribution so "Active Relay Subscriptions" still files single-account REQs correctly (and shows "not attributed" when accounts pool relays — don't regress to per-account splitting). +- Do NOT re-introduce `AccountScopedQuery` on the two finder states; if a future upstream manager consumes them, add a thin amethyst adapter instead of widening the commons seam. From 89b2a273a06fc59bfd90713c528ae1e6f129d6d1 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 29 Jul 2026 12:26:18 +0300 Subject: [PATCH 201/227] refactor: move EOSEAccountFast to commons, KMP-purified (Phase 2 prep) EOSEAccountFast is used by the user/event finder assemblers that will move to commonMain. Relocate it to commons.relays with KmpLock instead of synchronized(...) so it passes the iOS verifyKmpPurity gate. The old amethyst.service.relays location keeps a typealias, so its 7 existing importers are untouched. (SincePerRelayMap/MutableTime/EOSERelayList were already commons typealiases.) Co-Authored-By: Claude Opus 4.8 --- .../amethyst/service/relays/EOSE.kt | 58 +----------- .../commons/relays/EOSEAccountFast.kt | 94 +++++++++++++++++++ 2 files changed, 95 insertions(+), 57 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSEAccountFast.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index 403fcaf39f..f6032b8800 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl typealias EOSERelayList = com.vitorpamplona.amethyst.commons.relays.EOSERelayList typealias SincePerRelayMap = com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap typealias MutableTime = com.vitorpamplona.amethyst.commons.relays.MutableTime +typealias EOSEAccountFast = com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast open class EOSEByKey( cacheSize: Int = 200, @@ -113,60 +114,3 @@ open class EOSEAccountKey( time: Long, ) = addOrUpdate(user, listCode, relayUrl, time) } - -class EOSEAccountFast( - cacheSize: Int = 20, -) { - private val users: LruCache = LruCache(cacheSize) - private val lock = Any() - - fun addOrUpdate( - user: T, - relayUrl: NormalizedRelayUrl, - time: Long, - ) { - synchronized(lock) { - val relayList = users[user] - if (relayList == null) { - val newList = EOSERelayList() - users.put(user, newList) - - newList.addOrUpdate(relayUrl, time) - } else { - relayList.addOrUpdate(relayUrl, time) - } - } - } - - fun removeEveryoneBut(list: Set) { - synchronized(lock) { - users.snapshot().forEach { - if (it.key !in list) { - users.remove(it.key) - } - } - } - } - - fun removeDataFor(user: T) { - synchronized(lock) { - users.remove(user) - } - } - - fun since(key: T): SincePerRelayMap? = - synchronized(lock) { - users[key]?.relayList?.toMutableMap() - } - - fun sinceRelaySet(key: T): Set? = - synchronized(lock) { - users[key]?.relayList?.keys?.toSet() - } - - fun newEose( - user: T, - relayUrl: NormalizedRelayUrl, - time: Long, - ) = addOrUpdate(user, relayUrl, time) -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSEAccountFast.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSEAccountFast.kt new file mode 100644 index 0000000000..84d5e90773 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSEAccountFast.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relays + +import androidx.collection.LruCache +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Per-key EOSE tracker keyed by an arbitrary [T] (a `User`, a pubkey, …), used + * by the relay-subscription assemblers to remember which relays already EOSE'd + * for a given key so the next filter assembly can add a `since` and avoid a full + * re-download. + * + * KMP-pure: uses [KmpLock] instead of `synchronized(...)` so it compiles for the + * iOS targets `commons` builds for. Moved out of `amethyst.service.relays` so the + * shared user/event finder assemblers can live in `commonMain`. The old location + * keeps a `typealias` for source compatibility. + */ +class EOSEAccountFast( + cacheSize: Int = 20, +) { + private val users: LruCache = LruCache(cacheSize) + private val lock = KmpLock() + + fun addOrUpdate( + user: T, + relayUrl: NormalizedRelayUrl, + time: Long, + ) { + lock.withLock { + val relayList = users[user] + if (relayList == null) { + val newList = EOSERelayList() + users.put(user, newList) + + newList.addOrUpdate(relayUrl, time) + } else { + relayList.addOrUpdate(relayUrl, time) + } + } + } + + fun removeEveryoneBut(list: Set) { + lock.withLock { + users.snapshot().forEach { + if (it.key !in list) { + users.remove(it.key) + } + } + } + } + + fun removeDataFor(user: T) { + lock.withLock { + users.remove(user) + } + } + + fun since(key: T): SincePerRelayMap? = + lock.withLock { + users[key]?.relayList?.toMutableMap() + } + + fun sinceRelaySet(key: T): Set? = + lock.withLock { + users[key]?.relayList?.keys?.toSet() + } + + fun newEose( + user: T, + relayUrl: NormalizedRelayUrl, + time: Long, + ) = addOrUpdate(user, relayUrl, time) +} From 5a245c9f58e35e0bed6a1b5e327d387d6ccbfcab Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 29 Jul 2026 14:12:24 +0300 Subject: [PATCH 202/227] refactor: add relayHints seam to ICacheProvider (Phase 2 prep) The shared user/event finder filter functions read LocalCache.relayHints statically to discover which relays likely hold a user's metadata or a missing event. To let those functions move to commonMain, expose the HintIndexer (a quartz type) on ICacheProvider. Implement it on Android LocalCache (override the existing field), add one to DesktopLocalCache, and satisfy the four commonTest stub caches. Co-Authored-By: Claude Opus 4.8 --- .../java/com/vitorpamplona/amethyst/model/LocalCache.kt | 2 +- .../amethyst/commons/model/cache/ICacheProvider.kt | 8 ++++++++ .../amethyst/commons/model/ThreadAssemblerTest.kt | 3 +++ .../commons/model/concord/ConcordChannelListLeaveTest.kt | 3 +++ .../commons/model/concord/ConcordListLateArrivalTest.kt | 3 +++ .../amethyst/commons/ui/note/ReplyContextTest.kt | 3 +++ .../amethyst/desktop/cache/DesktopLocalCache.kt | 4 ++++ 7 files changed, 25 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 9df341c771..cd2963d6d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -479,7 +479,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { @Volatile var lnurlEndpointResolver: LnurlEndpointResolver? = null - val relayHints = HintIndexer() + override val relayHints = HintIndexer() /** * Cashu mint URL directory, populated passively as diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt index 6bdb38532e..3f2e59bd64 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer /** * Cache provider interface for accessing cached Notes, Users, and Channels. @@ -41,6 +42,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey * - Platform-agnostic model layer */ interface ICacheProvider { + /** + * NIP-hints index (event/address/pubkey → relay) accumulated from consumed + * events. Used by the shared user/event finder assemblers to discover which + * relays are likely to hold a given user's metadata or a missing event. + */ + val relayHints: HintIndexer + /** * Gets a channel by Note reference. * Used for resolving relay hints for channel messages. diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt index e2a25246a9..3bbc6bbc5d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent @@ -173,6 +174,8 @@ class ThreadAssemblerTest { ) : ICacheProvider { override fun getAnyChannel(note: Note): Channel? = null + override val relayHints = HintIndexer() + override fun getUserIfExists(pubkey: HexKey): User? = null override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt index 600a56a38b..298295d60c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -82,6 +83,8 @@ class ConcordChannelListLeaveTest { private class StubCache : ICacheProvider { override fun getAnyChannel(note: Note): Channel? = null + override val relayHints = HintIndexer() + override fun getUserIfExists(pubkey: HexKey): User? = null override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt index e4325e9280..6c682a88fc 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -80,6 +81,8 @@ class ConcordListLateArrivalTest { override fun getAnyChannel(note: Note): Channel? = null + override val relayHints = HintIndexer() + override fun getUserIfExists(pubkey: HexKey): User? = null override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt index 0a23769767..59d608d092 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ReplyContextTest.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlin.test.Test import kotlin.test.assertEquals @@ -113,6 +114,8 @@ class ReplyContextTest { ) : ICacheProvider { override fun getAnyChannel(note: Note): Channel? = null + override val relayHints = HintIndexer() + override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey] override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index b83d490a28..6c5979ac08 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.checkSignature import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses @@ -90,6 +91,9 @@ class DesktopLocalCache : ICacheProvider { val addressableNotes = LargeSoftCache() private val deletedEvents = ConcurrentHashMap.newKeySet() + /** NIP-hints index accumulated from consumed events (event/address/pubkey → relay). */ + override val relayHints = HintIndexer() + val eventStream = DesktopCacheEventStream() /** Local relay store for persisting events to SQLite. Set from Main.kt on account login. */ From 1511a8018b4c60745eacc255860237768ddaea5a Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 6 Aug 2026 10:00:47 +0300 Subject: [PATCH 203/227] feat: add UserFinderAccount seam + implement on Account (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The narrow read-only relay-hint seam the shared per-user/per-note finders need, so they can move to commons without the fat amethyst.model.Account. userFinderPubkeyHex doubles as the attribution pubkey for ExplainedFilter.accountPubKeys (upstream's subscription-attribution model needs only a pubkey, not the whole Account). Adds followerCountProvider() vs the prior design — upstream's UserCardsSubAssembler now reads trustProviderList.liveUserFollowerCount alongside liveUserRankProvider. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/model/Account.kt | 32 ++++++- .../relayClient/user/UserFinderAccount.kt | 90 +++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderAccount.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 57d6f64e5e..2260fb2b95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -30,6 +30,9 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.defaults.Constants +import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect @@ -59,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCa import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.richtext.RichTextParser @@ -286,6 +290,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag @@ -354,7 +359,8 @@ class Account( relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(), nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(), -) : IAccount { +) : IAccount, + UserFinderAccount { private var userProfileCache: User? = null override fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it } @@ -366,6 +372,30 @@ class Account( override val hiddenUsersHashCodes: Set get() = hiddenUsers.flow.value.hiddenUsersHashCodes override val spammersHashCodes: Set get() = hiddenUsers.flow.value.spammersHashCodes + // UserFinderAccount — narrow, read-only relay-hint view used by the shared + // per-user metadata + per-note event finders (moved to commons). Snapshot + // getters read `.value` fresh on every filter rebuild. userFinderPubkeyHex + // doubles as the attribution pubkey for ExplainedFilter.accountPubKeys. + override val userFinderPubkeyHex: HexKey get() = userProfile().pubkeyHex + + override fun indexRelays(): Set = indexerRelayList.flow.value.ifEmpty { DefaultIndexerRelayList } + + override fun outboxHomeRelays(): Set = nip65RelayList.allFlowNoDefaults.value + privateStorageRelayList.flow.value + localRelayList.flow.value + + override fun searchRelays(): Set = (trustedRelayList.flow.value + searchRelayList.flow.value.ifEmpty { DefaultSearchRelayList }).toSet() + + override fun followPlusAllMineWithSearchRelays(): Set = followPlusAllMineWithSearch.flow.value + + override fun commonRelays(): Set = followSharedOutboxesOrProxy.flow.value.ifEmpty { Constants.eventFinderRelays } + + override fun cardHomeRelays(): Set = homeRelays.flow.value + + override fun trustProvider(): ServiceProviderTag? = trustProviderList.liveUserRankProvider.value + + override fun followerCountProvider(): ServiceProviderTag? = trustProviderList.liveUserFollowerCount.value + + override fun declaredFollowsByOutboxRelay(): Map> = declaredFollowsPerOutboxRelay.value + val userMetadata = UserMetadataState(signer, cache, scope, settings) // Per-account NIP-42 ALLOW/DENY overrides, warm-cached in memory so a relay AUTH challenge is diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderAccount.kt new file mode 100644 index 0000000000..9f625e2839 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderAccount.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.user + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag + +/** + * Narrow, read-only view of a logged-in account that the user-finder + * subscription layer needs to route metadata / relay-list / report / + * contact-card REQs for *other* users. + * + * This is deliberately NOT part of [IAccount][com.vitorpamplona.amethyst.commons.model.IAccount]: + * `IAccount` is a behavioral capability interface for the *acting* user + * (sending DMs, gift wraps, MLS groups). The relay hints needed to *discover + * other users' metadata* are a separate concern, so they live on their own + * narrow interface (ISP). + * + * All accessors are **snapshot getters** read fresh on every filter rebuild — + * matching the prior direct `account.xxx.flow.value` reads. Relay-list changes + * therefore take effect on the next subscription invalidation without any + * captured-snapshot staleness. + * + * Platforms implement this on their concrete account (Android `Account`, + * Desktop `DesktopIAccount`). Fields with no backing on a platform degrade + * safely: Desktop has no NIP-85 trust-provider subsystem wired, so + * [trustProvider] returns null and [declaredFollowsByOutboxRelay] returns an + * empty map — contact-card and report discovery become best-effort there. + */ +interface UserFinderAccount { + /** This account's own pubkey (hex). */ + val userFinderPubkeyHex: HexKey + + /** Index/discovery relays, with the platform default fallback already applied. */ + fun indexRelays(): Set + + /** Home/write relays used for outbox discovery (nip65 + private storage + local). */ + fun outboxHomeRelays(): Set + + /** Search relays (trusted + search), with the default fallback applied. */ + fun searchRelays(): Set + + /** + * Follow + all-mine + search relays, used by the per-note event-finder to + * place "missing event" / "missing addressable" REQs (reactions, zaps, + * reposts, replies) when a note references content no relay has yet placed. + * Snapshot getter, same contract as the others. + */ + fun followPlusAllMineWithSearchRelays(): Set + + /** Shared-outbox / proxy relays used as the broad common fallback. */ + fun commonRelays(): Set + + /** Home relays used specifically for NIP-51 contact-card (kind 30382) discovery. */ + fun cardHomeRelays(): Set + + /** NIP-85 trusted-assertions rank provider, or null when unsupported (e.g. Desktop). */ + fun trustProvider(): ServiceProviderTag? + + /** + * NIP-85 follower-count rank provider, or null when unsupported (e.g. Desktop). + * Read by the contact-card sub-assembler alongside [trustProvider]. + */ + fun followerCountProvider(): ServiceProviderTag? + + /** + * Declared follows keyed by the relay they were declared on, used to trust + * report authors. Empty when the platform has no follow-graph-per-relay data. + */ + fun declaredFollowsByOutboxRelay(): Map> +} From 983fc1aab272a7eb83aee52fd4d6a3e6b5fb93b0 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 6 Aug 2026 10:41:12 +0300 Subject: [PATCH 204/227] refactor: move per-user metadata finder to commons on the new upstream model (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move UserFinderFilterAssembler(+QueryState), the four sub-assemblers (UserOutboxFinder, UserWatcher, UserReports, UserCards), FilterUserMetadataForKey, and FilterReportsToKey from amethyst/reqCommand/user/ into commons/relayClient/user/, plus the inner Account-free pickRelaysToLoadUsers overload into commons PickRelaysToLoadUsers.kt. Reconciled with upstream's re-architecture: - UserFinderQueryState carries the narrow UserFinderAccount (Phase 1) instead of the full Account; DROPS AccountScopedQuery. The finder sub-assemblers extend the commons base managers and attribute inline via soleAccountPubKey — now sourced from UserFinderAccount.userFinderPubkeyHex — so upstream's per-account attribution + ExplainedFilter/SubPurpose tags are preserved unchanged. - cache: LocalCache -> ICacheProvider; FilterUserMetadataForKey + pickRelaysToLoadUsers take an injected relayHints: HintIndexer instead of the static LocalCache.relayHints. - UserOutboxFinderSubAssembler inlines the relay-tier union over the UserFinderAccount getters (behaviour-preserving vs the old Account-based outer overload). Android unchanged: UserFinderShims.kt typealiases keep call sites (incl. reqCommand/event EventFinder*) compiling; UserFinderFilterAssemblerSubscription + UserObservers stay in amethyst (Account is-a UserFinderAccount). New commons UserFinderSubscription (LocalUserFinder/LocalUserFinderAccount + overloads) and UserMetadataObservers (observeUser*) added for Desktop. amethyst FilterFindFollowMetadataForKey's outer overload now delegates to the commons inner one. Green: commons JVM + iOS purity (verifyKmpPurity), :amethyst compilePlayDebugKotlin, :desktopApp compile, spotless. Co-Authored-By: Claude Opus 4.8 --- .../follows/FilterFindFollowMetadataForKey.kt | 121 +----------- .../reqCommand/user/UserFinderShims.kt | 30 +++ .../relayClient/user/PickRelaysToLoadUsers.kt | 147 +++++++++++++++ .../user/UserFinderFilterAssembler.kt | 22 +-- .../user/UserFinderSubscription.kt | 85 +++++++++ .../relayClient/user/UserMetadataObservers.kt | 177 ++++++++++++++++++ .../loaders/UserOutboxFinderSubAssembler.kt | 42 +++-- .../user/watchers/FilterReportsToKey.kt | 2 +- .../user/watchers/FilterUserMetadataForKey.kt | 11 +- .../user/watchers/UserCardsSubAssembler.kt | 24 +-- .../user/watchers/UserReportsSubAssembler.kt | 23 +-- .../user/watchers/UserWatcherSubAssembler.kt | 21 +-- 12 files changed, 521 insertions(+), 184 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderShims.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/PickRelaysToLoadUsers.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/user/UserFinderFilterAssembler.kt (74%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderSubscription.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/user/loaders/UserOutboxFinderSubAssembler.kt (81%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/user/watchers/FilterReportsToKey.kt (96%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/user/watchers/FilterUserMetadataForKey.kt (94%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/user/watchers/UserCardsSubAssembler.kt (88%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/user/watchers/UserReportsSubAssembler.kt (89%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/user/watchers/UserWatcherSubAssembler.kt (87%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt index 4d3ed060cd..f466cb4f17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt @@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follow import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList +import com.vitorpamplona.amethyst.commons.relayClient.user.pickRelaysToLoadUsers import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.utils.mapOfSet fun pickRelaysToLoadUsers( users: Set, @@ -68,6 +68,7 @@ fun pickRelaysToLoadUsers( return pickRelaysToLoadUsers( users, + LocalCache.relayHints, indexRelays - cannotConnectRelays, homeRelays - cannotConnectRelays, searchRelays - cannotConnectRelays, @@ -77,121 +78,3 @@ fun pickRelaysToLoadUsers( hasTried, ) } - -fun pickRelaysToLoadUsers( - users: Set, - indexRelays: Set, - homeRelays: Set, - searchRelays: Set, - connected: Set, - commonRelays: Set, - cannotConnectRelays: Set, - hasTried: EOSEAccountFast, -): Map> = - mapOfSet { - users.forEachIndexed { _, key -> - val tried = (hasTried.since(key)?.keys ?: emptySet()) + cannotConnectRelays - - val outbox = key.authorRelayList()?.writeRelaysNorm() - - if (!outbox.isNullOrEmpty()) { - // If there is a home, get from it. - - // if it tried all outbox relays, stop. - // the UserWatch will take over from here. - val leftToTry = (outbox - tried) - leftToTry.forEach { - add(it, key.pubkeyHex) - } - } else { - // if not, tries hints first. - val hints = key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex) - - val leftToTryOnHints = hints - tried - - leftToTryOnHints.forEach { - add(it, key.pubkeyHex) - } - - // if there are only a few hints, broadens the search - if (leftToTryOnHints.size < 3) { - // This creates a pre-deterministic order of the array such that - // if this function is called twice, it returns the same arrays - // which gets ignored by the relay client if we send it twice - val indexRelaysLeftToTry = - (indexRelays - tried).sortedBy { relay -> - key.pubkeyHex.hashCode() xor relay.url.hashCode() - } - // This creates a pre-deterministic order of the array such that - // if this function is called twice, it returns the same arrays - // which gets ignored by the relay client if we send it twice - val homeRelaysLeftToTry = - (homeRelays - tried).sortedBy { relay -> - key.pubkeyHex.hashCode() xor relay.url.hashCode() - } - - // picks one at random to avoid overloading these relays - if (users.size > 300) { - if (indexRelaysLeftToTry.size >= 2) { - add(indexRelaysLeftToTry[0], key.pubkeyHex) - add(indexRelaysLeftToTry[1], key.pubkeyHex) - } else if (indexRelaysLeftToTry.size == 1) { - add(indexRelaysLeftToTry.first(), key.pubkeyHex) - } - - homeRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - } else { - indexRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - - homeRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - } - - if (indexRelaysLeftToTry.size < 2) { - val searchRelaysLeftToTry = searchRelays - tried - - searchRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - - val connectedRelaysLeftToTry = - (connected - tried) - .sortedBy { relay -> - key.pubkeyHex.hashCode() xor relay.url.hashCode() - }.take(100) - - // picks one at random to avoid overloading these relays - if (users.size > 300) { - connectedRelaysLeftToTry.take(20).forEach { - add(it, key.pubkeyHex) - } - } else { - connectedRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - } - - if (searchRelaysLeftToTry.size < 2) { - // This creates a pre-deterministic order of the array such that - // if this function is called twice, it returns the same arrays - // which gets ignored by the relay client if we send it twice - val allRelaysLeftToTry = - (commonRelays - tried) - .sortedBy { relay -> - key.pubkeyHex.hashCode() xor relay.url.hashCode() - }.take(100) - - allRelaysLeftToTry.forEach { - add(it, key.pubkeyHex) - } - } - } - } - } - } - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderShims.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderShims.kt new file mode 100644 index 0000000000..c9d01b3b68 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderShims.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user + +/** + * Back-compat aliases: the per-user metadata finder moved to commons + * (`com.vitorpamplona.amethyst.commons.relayClient.user`). Existing Android call + * sites that reference these by their old names resolve here. + */ +typealias UserFinderFilterAssembler = com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler + +typealias UserFinderQueryState = com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/PickRelaysToLoadUsers.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/PickRelaysToLoadUsers.kt new file mode 100644 index 0000000000..289a2c96bf --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/PickRelaysToLoadUsers.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.user + +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet + +fun pickRelaysToLoadUsers( + users: Set, + relayHints: HintIndexer, + indexRelays: Set, + homeRelays: Set, + searchRelays: Set, + connected: Set, + commonRelays: Set, + cannotConnectRelays: Set, + hasTried: EOSEAccountFast, +): Map> = + mapOfSet { + users.forEachIndexed { _, key -> + val tried = (hasTried.since(key)?.keys ?: emptySet()) + cannotConnectRelays + + val outbox = key.authorRelayList()?.writeRelaysNorm() + + if (!outbox.isNullOrEmpty()) { + // If there is a home, get from it. + + // if it tried all outbox relays, stop. + // the UserWatch will take over from here. + val leftToTry = (outbox - tried) + leftToTry.forEach { + add(it, key.pubkeyHex) + } + } else { + // if not, tries hints first. + val hints = key.allUsedRelays() + relayHints.hintsForKey(key.pubkeyHex) + + val leftToTryOnHints = hints - tried + + leftToTryOnHints.forEach { + add(it, key.pubkeyHex) + } + + // if there are only a few hints, broadens the search + if (leftToTryOnHints.size < 3) { + // This creates a pre-deterministic order of the array such that + // if this function is called twice, it returns the same arrays + // which gets ignored by the relay client if we send it twice + val indexRelaysLeftToTry = + (indexRelays - tried).sortedBy { relay -> + key.pubkeyHex.hashCode() xor relay.url.hashCode() + } + // This creates a pre-deterministic order of the array such that + // if this function is called twice, it returns the same arrays + // which gets ignored by the relay client if we send it twice + val homeRelaysLeftToTry = + (homeRelays - tried).sortedBy { relay -> + key.pubkeyHex.hashCode() xor relay.url.hashCode() + } + + // picks one at random to avoid overloading these relays + if (users.size > 300) { + if (indexRelaysLeftToTry.size >= 2) { + add(indexRelaysLeftToTry[0], key.pubkeyHex) + add(indexRelaysLeftToTry[1], key.pubkeyHex) + } else if (indexRelaysLeftToTry.size == 1) { + add(indexRelaysLeftToTry.first(), key.pubkeyHex) + } + + homeRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + } else { + indexRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + + homeRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + } + + if (indexRelaysLeftToTry.size < 2) { + val searchRelaysLeftToTry = searchRelays - tried + + searchRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + + val connectedRelaysLeftToTry = + (connected - tried) + .sortedBy { relay -> + key.pubkeyHex.hashCode() xor relay.url.hashCode() + }.take(100) + + // picks one at random to avoid overloading these relays + if (users.size > 300) { + connectedRelaysLeftToTry.take(20).forEach { + add(it, key.pubkeyHex) + } + } else { + connectedRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + } + + if (searchRelaysLeftToTry.size < 2) { + // This creates a pre-deterministic order of the array such that + // if this function is called twice, it returns the same arrays + // which gets ignored by the relay client if we send it twice + val allRelaysLeftToTry = + (commonRelays - tried) + .sortedBy { relay -> + key.pubkeyHex.hashCode() xor relay.url.hashCode() + }.take(100) + + allRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + } + } + } + } + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderFilterAssembler.kt similarity index 74% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderFilterAssembler.kt index b0383beda5..9e92c5213a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderFilterAssembler.kt @@ -18,18 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user +package com.vitorpamplona.amethyst.commons.relayClient.user import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserCardsSubAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserWatcherSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.loaders.UserOutboxFinderSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.watchers.UserCardsSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.watchers.UserReportsSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.watchers.UserWatcherSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker @@ -37,13 +35,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT @Stable class UserFinderQueryState( val user: User, - override val account: Account, -) : AccountScopedQuery + val account: UserFinderAccount, +) @Stable class UserFinderFilterAssembler( client: INostrClient, - cache: LocalCache, + cache: ICacheProvider, failureTracker: RelayOfflineTracker, ) : ComposeSubscriptionManager() { val group = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderSubscription.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderSubscription.kt new file mode 100644 index 0000000000..51dfc57ea3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderSubscription.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.user + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription + +/** + * The shared per-user metadata data source for the current front end. A front + * end provides this once near its composition root (Android via AppModules, + * Desktop via its subscriptions coordinator). Reading it without a provider is + * a programming error — the `observeUser*` composables must never be reachable + * from a composition that has no relay client (e.g. the Android `:napplet` + * sandbox process). + */ +val LocalUserFinder = + staticCompositionLocalOf { + error("LocalUserFinder not provided") + } + +/** + * The current logged-in account, in the narrow [UserFinderAccount] view the + * finder needs to route REQs. Provided alongside [LocalUserFinder]. + */ +val LocalUserFinderAccount = + staticCompositionLocalOf { + error("LocalUserFinderAccount not provided") + } + +/** + * Subscribes to relay updates for [user]'s metadata (and relay list / reports / + * contact cards) for as long as this composable is in composition, coalesced + * with every other on-screen user into batched REQs by [dataSource]. + * + * Because a `LazyColumn` composes only the visible window (+ a small prefetch + * buffer), this naturally means "load metadata only for users currently on + * screen" — the [LifecycleAwareKeyDataSourceSubscription] unsubscribes ~30s + * after the row leaves composition or the app is backgrounded. + */ +@Composable +fun UserFinderFilterAssemblerSubscription( + user: User, + account: UserFinderAccount, + dataSource: UserFinderFilterAssembler, +) { + // Different screens get their own query-state instance even when tracking + // the same user; the assembler dedups to one REQ per pubkey. + val state = remember(user, account) { UserFinderQueryState(user, account) } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} + +/** + * Convenience overload that reads the front end's [LocalUserFinder] and + * [LocalUserFinderAccount] from the composition. + */ +@Composable +fun UserFinderFilterAssemblerSubscription(user: User) { + UserFinderFilterAssemblerSubscription( + user = user, + account = LocalUserFinderAccount.current, + dataSource = LocalUserFinder.current, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt new file mode 100644 index 0000000000..c1d20a41d9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserMetadataObservers.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.user + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** + * Shared, platform-agnostic observers for a single user's metadata (kind 0). + * + * Each observer both (a) opens a composition-scoped relay subscription for the + * user via [UserFinderFilterAssemblerSubscription] — so metadata is fetched only + * while the user is on screen — and (b) collects the resulting cache flow so the + * UI recomposes when the metadata arrives. The `(user)` overloads read the + * front end's [LocalUserFinder] / [LocalUserFinderAccount]; the explicit-param + * overloads are for callers that already hold both (and for tests). + * + * These are metadata-only. Richer per-user observers that depend on account + * subsystems not yet in commons (contact-card petnames, follow counts, + * bookmarks, statuses) remain in the Android layer for now and layer on top of + * the same subscription. + */ +@Composable +fun observeUserInfo( + user: User, + userFinder: UserFinderFilterAssembler, + account: UserFinderAccount, +): State { + UserFinderFilterAssemblerSubscription(user, account, userFinder) + return user.metadata().flow.collectAsStateWithLifecycle() +} + +@Composable +fun observeUserInfo(user: User): State = observeUserInfo(user, LocalUserFinder.current, LocalUserFinderAccount.current) + +@Composable +fun observeUserPicture( + user: User, + userFinder: UserFinderFilterAssembler, + account: UserFinderAccount, +): State { + UserFinderFilterAssemblerSubscription(user, account, userFinder) + + val flow = + remember(user) { + user + .metadata() + .flow + .map { it?.info?.picture } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle( + user + .metadataOrNull() + ?.flow + ?.value + ?.info + ?.picture, + ) +} + +@Composable +fun observeUserPicture(user: User): State = observeUserPicture(user, LocalUserFinder.current, LocalUserFinderAccount.current) + +@Composable +fun observeUserBanner( + user: User, + userFinder: UserFinderFilterAssembler, + account: UserFinderAccount, +): State { + UserFinderFilterAssemblerSubscription(user, account, userFinder) + + val flow = + remember(user) { + user + .metadata() + .flow + .map { it?.info?.banner } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle( + user + .metadataOrNull() + ?.flow + ?.value + ?.info + ?.banner, + ) +} + +@Composable +fun observeUserBanner(user: User): State = observeUserBanner(user, LocalUserFinder.current, LocalUserFinderAccount.current) + +@Composable +fun observeUserAboutMe( + user: User, + userFinder: UserFinderFilterAssembler, + account: UserFinderAccount, +): State { + UserFinderFilterAssemblerSubscription(user, account, userFinder) + + val flow = + remember(user) { + user + .metadata() + .flow + .map { it?.info?.about ?: "" } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle( + user + .metadataOrNull() + ?.flow + ?.value + ?.info + ?.about ?: "", + ) +} + +@Composable +fun observeUserAboutMe(user: User): State = observeUserAboutMe(user, LocalUserFinder.current, LocalUserFinderAccount.current) + +/** + * The user's best available display name from their own metadata (kind 0), + * falling back to a truncated pubkey. Metadata-only: it does NOT apply the + * viewing account's private contact-card petname (that stays in the Android + * layer, which wraps this). + */ +@Composable +fun observeUserName( + user: User, + userFinder: UserFinderFilterAssembler, + account: UserFinderAccount, +): State { + UserFinderFilterAssemblerSubscription(user, account, userFinder) + + val flow = + remember(user) { + user + .metadata() + .flow + .map { it?.info?.bestName() ?: user.toBestDisplayName() } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(user.toBestDisplayName()) +} + +@Composable +fun observeUserName(user: User): State = observeUserName(user, LocalUserFinder.current, LocalUserFinderAccount.current) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/loaders/UserOutboxFinderSubAssembler.kt similarity index 81% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/loaders/UserOutboxFinderSubAssembler.kt index 7743fb7c7c..0243f98f69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/loaders/UserOutboxFinderSubAssembler.kt @@ -18,18 +18,18 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders +package com.vitorpamplona.amethyst.commons.relayClient.user.loaders import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState -import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState +import com.vitorpamplona.amethyst.commons.relayClient.user.pickRelaysToLoadUsers +import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils class UserOutboxFinderSubAssembler( client: INostrClient, - val cache: LocalCache, + val cache: ICacheProvider, val failureTracker: RelayOfflineTracker, allKeys: () -> Set, ) : BaseEoseManager(client, allKeys) { @@ -108,19 +108,37 @@ class UserOutboxFinderSubAssembler( // and the users being resolved are whoever is on screen rather than anyone's follow list — so // with several accounts active there is no single honest owner for a given filter, and // splitting the sweep per account would re-issue the same lookups once per account. - // Deduped by pubkey, not by `Account`: that class uses identity equality, so two objects for - // the same logged-in user would look like two accounts and suppress attribution entirely. + // Deduped by pubkey, not by account identity: two objects for the same logged-in user would + // look like two accounts and suppress attribution entirely. val soleAccountPubKey = accounts - .mapTo(mutableSetOf()) { it.userProfile().pubkeyHex } + .mapTo(mutableSetOf()) { it.userFinderPubkeyHex } .singleOrNull() + // Union of every asking account's relay tiers. The UserFinderAccount getters already apply the + // platform default fallbacks (index/search), matching the prior outer pickRelaysToLoadUsers. + val cannotConnect = failureTracker.cannotConnectRelays + val indexRelays = mutableSetOf() + val homeRelays = mutableSetOf() + val searchRelays = mutableSetOf() + val commonRelays = mutableSetOf() + accounts.forEach { account -> + indexRelays.addAll(account.indexRelays()) + homeRelays.addAll(account.outboxHomeRelays()) + searchRelays.addAll(account.searchRelays()) + commonRelays.addAll(account.commonRelays()) + } + val perRelayKeysBoth = pickRelaysToLoadUsers( noOutboxList, - accounts, + cache.relayHints, + indexRelays - cannotConnect, + homeRelays - cannotConnect, + searchRelays - cannotConnect, connectedRelays, - failureTracker.cannotConnectRelays, + commonRelays - cannotConnect, + cannotConnect, hasTried, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterReportsToKey.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterReportsToKey.kt index a369d824e7..d8b069cfa3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterReportsToKey.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +package com.vitorpamplona.amethyst.commons.relayClient.user.watchers import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt index e52167f763..23227231ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt @@ -18,16 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +package com.vitorpamplona.amethyst.commons.relayClient.user.watchers +import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -60,6 +60,7 @@ val UserMetadataForKeyKinds = fun filterUserMetadataForKey( authors: Set, + relayHints: HintIndexer, indexRelays: Set, cannotConnectRelays: Set, since: EOSEAccountFast, @@ -72,7 +73,7 @@ fun filterUserMetadataForKey( val relays = when { outbox == null -> - key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays + key.allUsedRelays() + relayHints.hintsForKey(key.pubkeyHex) + indexRelays // Outbox is published but exhausted (every relay either EOSE'd // or is known-unreachable) and metadata is still missing — // widen to indexers so a misconfigured outbox doesn't strand diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserCardsSubAssembler.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserCardsSubAssembler.kt index 0404db46cb..9c4ffbc31e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserCardsSubAssembler.kt @@ -18,16 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +package com.vitorpamplona.amethyst.commons.relayClient.user.watchers +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.toHexSet import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState -import com.vitorpamplona.amethyst.service.relays.MutableTime -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState +import com.vitorpamplona.amethyst.commons.relays.MutableTime +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.utils.mapOfSet class UserCardsSubAssembler( client: INostrClient, - val cache: LocalCache, + val cache: ICacheProvider, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys) { override fun newEose( @@ -74,22 +74,22 @@ class UserCardsSubAssembler( // accounts, so with several active none of them owns a given filter. val soleAccountPubKey = accounts - .mapTo(mutableSetOf()) { it.userProfile().pubkeyHex } + .mapTo(mutableSetOf()) { it.userFinderPubkeyHex } .singleOrNull() val trustedAccounts: Map> = mapOfSet { accounts.forEach { account -> - account.homeRelays.flow.value.forEach { - add(it, account.userProfile().pubkeyHex) + account.cardHomeRelays().forEach { + add(it, account.userFinderPubkeyHex) } } - accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { provider -> + accounts.map { it.trustProvider() }.forEach { provider -> if (provider != null) { add(provider.relayUrl, provider.pubkey) } } - accounts.map { it.trustProviderList.liveUserFollowerCount.value }.forEach { provider -> + accounts.map { it.followerCountProvider() }.forEach { provider -> if (provider != null) { add(provider.relayUrl, provider.pubkey) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserReportsSubAssembler.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserReportsSubAssembler.kt index 4371a4f822..5731d42b64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserReportsSubAssembler.kt @@ -18,16 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +package com.vitorpamplona.amethyst.commons.relayClient.user.watchers +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.toHexSet import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState -import com.vitorpamplona.amethyst.service.relays.MutableTime -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState +import com.vitorpamplona.amethyst.commons.relays.MutableTime +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class UserReportsSubAssembler( client: INostrClient, - val cache: LocalCache, + val cache: ICacheProvider, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys) { override fun newEose( @@ -72,12 +72,13 @@ class UserReportsSubAssembler( } private fun filtersFor( - account: Account, + account: UserFinderAccount, lastUsersOnFilter: Set, ): List { - val accountPubKey = account.userProfile().pubkeyHex + val accountPubKey = account.userFinderPubkeyHex - return account.declaredFollowsPerOutboxRelay.value + return account + .declaredFollowsByOutboxRelay() .flatMap { (relay, trustedUsersInThisRelay) -> // this relay + accounts are where we could find reports. // we might have already loaded them, so let's separate new targets that were checked before from the others diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserWatcherSubAssembler.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserWatcherSubAssembler.kt index f5381a9be1..673b71a977 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserWatcherSubAssembler.kt @@ -18,14 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +package com.vitorpamplona.amethyst.commons.relayClient.user.watchers -import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState -import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState +import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker @@ -37,7 +36,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils class UserWatcherSubAssembler( client: INostrClient, - val cache: LocalCache, + val cache: ICacheProvider, val failureTracker: RelayOfflineTracker, allKeys: () -> Set, ) : BaseEoseManager(client, allKeys) { @@ -100,20 +99,18 @@ class UserWatcherSubAssembler( // account and the users are whoever is on screen, so with several askers none of them owns it. val soleAccountPubKey = keys - .mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex } + .mapTo(mutableSetOf()) { it.account.userFinderPubkeyHex } .singleOrNull() val indexRelays = mutableSetOf() keys.mapTo(mutableSetOf()) { it.account }.forEach { - indexRelays.addAll( - it.indexerRelayList.flow.value - .ifEmpty { DefaultIndexerRelayList }, - ) + indexRelays.addAll(it.indexRelays()) } val newFilters = filterUserMetadataForKey( users, + cache.relayHints, indexRelays, failureTracker.cannotConnectRelays, latestEOSEs, From 4d31cd7d25f049393ba9c4fcffe071b7ec2ef00d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 6 Aug 2026 11:02:09 +0300 Subject: [PATCH 205/227] refactor: move per-note event finder to commons on the new upstream model (Phase 3) Move EventFinderFilterAssembler(+QueryState), the loaders (NoteEventLoader, FilterMissingEvents, FilterMissingAddressables, AddressableAuthorRelayLoader) and watchers (EventWatcher, FilterRepliesAndReactionsToNotes/Addresses) from amethyst/reqCommand/event/ into commons/relayClient/event/, mirroring the Phase 2 user-finder move. - EventFinderQueryState carries the narrow UserFinderAccount; DROPS AccountScopedQuery. Attribution stays inline via userFinderPubkeyHex; ExplainedFilter/SubPurpose tags (REFERENCED_EVENTS / ENGAGEMENT) preserved. - cache: LocalCache -> ICacheProvider threaded into NoteEventLoaderSubAssembler + the FilterMissing* functions (which called the LocalCache singleton statically); getOrCreateUser is now nullable at these sites. Added ICacheProvider.checkGetOrCreateUser default; LocalCache.checkGetOrCreateUser now overrides it. - SingleSubNoEoseCacheEoseManager moved to commons (account-agnostic accountPubKeyOf); its two amethyst callers keep attribution via a new amethyst subclass AccountScopedSingleSubNoEoseCacheEoseManager (ChannelLoader) or the plain commons base (NWCPaymentWatcher, which never attributed). Android unchanged via EventFinderShims.kt (typealiases + AccountViewModel overload); EventObservers.kt stays in amethyst. New commons EventFinderFilterAssemblerSubscription (LocalEventFinder + (note) overload reusing LocalUserFinderAccount) for Desktop. Updated the direct loader-function callers (search/hashtag/thread sub-assemblers + the moved test) to import from commons and pass LocalCache. Green: commons JVM + iOS purity, :amethyst compilePlayDebugKotlin, :desktopApp compile, spotless. Co-Authored-By: Claude Opus 4.8 --- .../amethyst/model/LocalCache.kt | 2 +- ...ntScopedSingleSubNoEoseCacheEoseManager.kt | 46 ++++++++++ .../ChannelLoaderSubAssembler.kt | 4 +- ...lerSubscription.kt => EventFinderShims.kt} | 40 ++++----- .../nwc/NWCPaymentWatcherSubAssembler.kt | 2 +- .../subassemblies/FilterByAddress.kt | 6 +- .../subassemblies/FilterByEvent.kt | 8 +- .../hashtag/datasource/FilterHashtagLabels.kt | 6 +- .../FilterMissingEventsForThread.kt | 17 ++-- ...ssableAuthorRelayLoaderSubAssemblerTest.kt | 5 +- .../commons/model/cache/ICacheProvider.kt | 10 +++ .../SingleSubNoEoseCacheEoseManager.kt | 13 ++- .../event/EventFinderFilterAssembler.kt | 25 +++--- .../EventFinderFilterAssemblerSubscription.kt | 84 +++++++++++++++++++ ...ddressableAuthorRelayLoaderSubAssembler.kt | 16 ++-- .../loaders/FilterMissingAddressables.kt | 34 ++++---- .../event/loaders/FilterMissingEvents.kt | 38 +++++---- .../loaders/NoteEventLoaderSubAssembler.kt | 17 ++-- .../watchers/EventWatcherSubAssembler.kt | 16 ++-- .../FilterRepliesAndReactionsToAddresses.kt | 6 +- .../FilterRepliesAndReactionsToNotes.kt | 6 +- 21 files changed, 280 insertions(+), 121 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/AccountScopedSingleSubNoEoseCacheEoseManager.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/{EventFinderFilterAssemblerSubscription.kt => EventFinderShims.kt} (61%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt (85%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/event/EventFinderFilterAssembler.kt (70%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblerSubscription.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt (89%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/event/loaders/FilterMissingAddressables.kt (79%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/event/loaders/FilterMissingEvents.kt (79%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/event/loaders/NoteEventLoaderSubAssembler.kt (67%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/event/watchers/EventWatcherSubAssembler.kt (89%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/event/watchers/FilterRepliesAndReactionsToAddresses.kt (96%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/event/watchers/FilterRepliesAndReactionsToNotes.kt (96%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index cd2963d6d7..4029469535 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -679,7 +679,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() } - fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull() + override fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull() fun load(keys: List): List = keys.mapNotNull(::checkGetOrCreateUser) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/AccountScopedSingleSubNoEoseCacheEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/AccountScopedSingleSubNoEoseCacheEoseManager.kt new file mode 100644 index 0000000000..c76800af32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/AccountScopedSingleSubNoEoseCacheEoseManager.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +/** + * Amethyst variant of [SingleSubNoEoseCacheEoseManager] that restores single-account + * attribution for [AccountScopedQuery] keys. + * + * The commons base is account-agnostic (attribution defaults to null) so it can live in + * commonMain. Query states that carry an [Account] (home feed, channels, notifications, …) + * subclass this so their single-account REQs still show up attributed in "Active Relay + * Subscriptions". + * + * Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses + * HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the + * other under "not attributed" despite both being built from a single account's data. + */ +abstract class AccountScopedSingleSubNoEoseCacheEoseManager( + client: INostrClient, + allKeys: () -> Set, + invalidateAfterEose: Boolean = false, +) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose) { + override fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt index c14d14bd19..f06d9a28d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.AccountScopedSingleSubNoEoseCacheEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class ChannelLoaderSubAssembler( client: INostrClient, allKeys: () -> Set, -) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { +) : AccountScopedSingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { override fun updateFilter(keys: List): List = filterMissingChannelsById(keys) override fun distinct(key: ChannelFinderQueryState) = key.channel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderShims.kt similarity index 61% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderShims.kt index 005f4f203e..b3a9458bab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderShims.kt @@ -21,30 +21,30 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription -import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +/** + * Back-compat aliases: the per-note event finder moved to commons + * (`com.vitorpamplona.amethyst.commons.relayClient.event`). Existing Android call + * sites that reference these by their old names resolve here. + */ +typealias EventFinderFilterAssembler = com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssembler + +typealias EventFinderQueryState = com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState + +/** + * Android convenience overload: pulls the account + shared event-finder data source + * out of [accountViewModel] and delegates to the commons subscription. `Account` + * is-a `UserFinderAccount`, so no adaptation is needed. + */ @Composable fun EventFinderFilterAssemblerSubscription( note: Note, accountViewModel: AccountViewModel, -) = EventFinderFilterAssemblerSubscription(note, accountViewModel.account, accountViewModel.dataSources().eventFinder) - -@Composable -fun EventFinderFilterAssemblerSubscription( - note: Note, - account: Account, - dataSource: EventFinderFilterAssembler, -) { - // different screens get different states - // even if they are tracking the same tag. - val state = - remember(note, account) { - EventFinderQueryState(note, account) - } - - LifecycleAwareKeyDataSourceSubscription(state, dataSource) -} +) = EventFinderFilterAssemblerSubscription( + note, + accountViewModel.account, + accountViewModel.dataSources().eventFinder, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt index b179e5a042..51e4ae0e67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt index a527046727..df31d45479 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingAddressables +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindAddress import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress @@ -37,7 +37,7 @@ fun filterByAddress( val list = mapOfSet { if (note.event == null) { - potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindAddress(LocalCache, note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, note.address) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt index 0a56940449..ae225e1b27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -38,7 +38,7 @@ fun filterByEvent( val list = mapOfSet { if (note !is AddressableNote && note.event == null) { - potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindEvent(LocalCache, note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, note.idHex) } } @@ -46,7 +46,7 @@ fun filterByEvent( // loads threading that is event-based note.replyTo?.forEach { parentNote -> if (parentNote !is AddressableNote && note.event == null) { - potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindEvent(LocalCache, note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, note.idHex) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt index 670eff235a..7106576dfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -93,7 +93,7 @@ fun filterHashtagLabels( val target = LocalCache.getNoteIfExists(targetId) if (target?.event == null) { val targetNote = LocalCache.getOrCreateNote(targetId) - potentialRelaysToFindEvent(targetNote).ifEmpty { relays }.forEach { relayUrl -> + potentialRelaysToFindEvent(LocalCache, targetNote).ifEmpty { relays }.forEach { relayUrl -> add(relayUrl, targetId) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt index c2a7d7ba40..a9de4c769d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/subassembies/FilterMissingEventsForThread.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies import com.vitorpamplona.amethyst.commons.model.ThreadAssembler +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingAddressables +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindAddress +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet @@ -37,14 +38,14 @@ fun filterMissingEventsForThread( val missingEvents = mapOfSet { if (threadInfo.root.event == null && threadInfo.root !is AddressableNote) { - potentialRelaysToFindEvent(threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl -> + potentialRelaysToFindEvent(LocalCache, threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl -> add(relayUrl, threadInfo.root.idHex) } } threadInfo.allNotes.forEach { if (it !is AddressableNote && it.event == null) { - potentialRelaysToFindEvent(it).ifEmpty { defaultRelays }.forEach { relayUrl -> + potentialRelaysToFindEvent(LocalCache, it).ifEmpty { defaultRelays }.forEach { relayUrl -> add(relayUrl, it.idHex) } } @@ -59,14 +60,14 @@ fun filterMissingEventsForThread( // note's aTag idHex into the hex-keyed event-hint index, which throws // on the non-hex string and kills the whole filter build — leaving a // thread opened on an uncached naddr permanently unfetched. - potentialRelaysToFindAddress(rootNote).ifEmpty { defaultRelays }.forEach { relayUrl -> + potentialRelaysToFindAddress(LocalCache, rootNote).ifEmpty { defaultRelays }.forEach { relayUrl -> add(relayUrl, rootNote.address) } } threadInfo.allNotes.forEach { if (it is AddressableNote && it.event == null) { - potentialRelaysToFindAddress(it).ifEmpty { defaultRelays }.forEach { relayUrl -> + potentialRelaysToFindAddress(LocalCache, it).ifEmpty { defaultRelays }.forEach { relayUrl -> add(relayUrl, it.address) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssemblerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssemblerTest.kt index 842471e6a4..82fd16f813 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssemblerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssemblerTest.kt @@ -20,11 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.AddressableAuthorRelayLoaderSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.quartz.nip01Core.core.Address import io.mockk.every import io.mockk.mockk diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt index 3f2e59bd64..7d9091967f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt @@ -142,5 +142,15 @@ interface ICacheProvider { */ fun getOrCreateUser(pubkey: HexKey): User? + /** + * Gets or creates a User by public key hex, swallowing any failure. + * Used by the event-finder relay-hint scan, which touches many potentially + * malformed pubkeys and must never throw mid-scan. + * + * @param key The user's public key in hex format + * @return The User (existing or newly created), or null on failure + */ + fun checkGetOrCreateUser(key: HexKey): User? = runCatching { getOrCreateUser(key) }.getOrNull() + fun justConsumeMyOwnEvent(event: Event): Boolean } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt index a80357e283..622d9f8fbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt @@ -18,11 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers -import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo -import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -85,9 +83,10 @@ abstract class SingleSubNoEoseCacheEoseManager( /** * The account behind [key], when the key is account-scoped. Null for keys about other users. * - * Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses - * HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the - * other under "not attributed" despite both being built from a single account's data. + * Account-agnostic in commons: front ends that want single-account attribution override this + * (see the amethyst `AccountScopedSingleSubNoEoseCacheEoseManager`, which reads + * `(key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex`). The default returns null, + * so pooled / cross-account subscriptions are filed as "not attributed". */ - private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex + open fun accountPubKeyOf(key: Any?): String? = null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssembler.kt similarity index 70% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssembler.kt index f1f320ae50..a5e1ed022c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssembler.kt @@ -18,36 +18,35 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event +package com.vitorpamplona.amethyst.commons.relayClient.event import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.AddressableAuthorRelayLoaderSubAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.AddressableAuthorRelayLoaderSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.NoteEventLoaderSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.event.watchers.EventWatcherSubAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag @Stable class EventFinderQueryState( val note: Note, - override val account: Account, -) : AccountScopedQuery + val account: UserFinderAccount, +) @Stable class EventFinderFilterAssembler( client: INostrClient, - cache: LocalCache, + cache: ICacheProvider, userFinder: UserFinderFilterAssembler, ) : ComposeSubscriptionManager() { val group = listOf( - NoteEventLoaderSubAssembler(client, ::allKeys), + NoteEventLoaderSubAssembler(client, cache, ::allKeys), EventWatcherSubAssembler(client, ::allKeys), AddressableAuthorRelayLoaderSubAssembler(cache, ::allKeys, userFinder), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblerSubscription.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..bf9f637ec1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblerSubscription.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.event + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount + +/** + * The shared per-note event data source (reactions / zaps / reposts / replies / + * OTS / references) for the current front end. Provided once near the composition + * root (Android via AppModules, Desktop via its subscriptions coordinator). + * Reading it without a provider is a programming error — the per-note observers + * must never be reachable from a composition that has no relay client (e.g. the + * Android `:napplet` sandbox process). + * + * The *account* half is reused from the user-finder: [LocalUserFinderAccount] + * already carries the narrow relay-hint seam the event loaders need. + */ +val LocalEventFinder = + staticCompositionLocalOf { + error("LocalEventFinder not provided") + } + +/** + * Subscribes to relay updates for [note]'s interactions (reactions, zaps, + * reposts, replies, …) for as long as this composable is in composition, + * coalesced with every other on-screen note into batched REQs by [dataSource]. + * + * Like the user-finder, because a `LazyColumn` composes only the visible window + * (+ a small prefetch buffer) this means "load interactions only for notes + * currently on screen" — [LifecycleAwareKeyDataSourceSubscription] unsubscribes + * ~30s after the row leaves composition or the app is backgrounded. + */ +@Composable +fun EventFinderFilterAssemblerSubscription( + note: Note, + account: UserFinderAccount, + dataSource: EventFinderFilterAssembler, +) { + // Different screens get their own query-state instance even when tracking + // the same note; the assembler dedups to one REQ per note. + val state = + remember(note, account) { + EventFinderQueryState(note, account) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} + +/** + * Convenience overload that reads the front end's [LocalEventFinder] and + * [LocalUserFinderAccount] from the composition. + */ +@Composable +fun EventFinderFilterAssemblerSubscription(note: Note) { + EventFinderFilterAssemblerSubscription( + note = note, + account = LocalUserFinderAccount.current, + dataSource = LocalEventFinder.current, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt index 9dba6ed2bd..68e5a40544 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt @@ -18,15 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders +package com.vitorpamplona.amethyst.commons.relayClient.event.loaders +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState import com.vitorpamplona.amethyst.commons.service.BundledUpdate -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -41,7 +41,7 @@ import kotlinx.coroutines.IO * relay list arrives, [EventFinderFilterAssembler] is invalidated and can query the correct relay. */ class AddressableAuthorRelayLoaderSubAssembler( - val cache: LocalCache, + val cache: ICacheProvider, val allKeys: () -> Set, val userFinder: UserFinderFilterAssembler, ) : IEoseManager { @@ -69,7 +69,7 @@ class AddressableAuthorRelayLoaderSubAssembler( val note = key.note if (note is AddressableNote && note.event == null) { val author = cache.getOrCreateUser(note.address.pubKeyHex) - if (author.authorRelayList() == null) { + if (author != null && author.authorRelayList() == null) { needed.add(UserFinderQueryState(author, key.account)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingAddressables.kt similarity index 79% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingAddressables.kt index 41eac22796..f52e1a3242 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingAddressables.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingAddressables.kt @@ -18,33 +18,36 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders +package com.vitorpamplona.amethyst.commons.relayClient.event.loaders +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet -fun potentialRelaysToFindAddress(note: AddressableNote): Set { +fun potentialRelaysToFindAddress( + cache: ICacheProvider, + note: AddressableNote, +): Set { val set = mutableSetOf() - LocalCache.getOrCreateUser(note.address.pubKeyHex).outboxRelays()?.let { + cache.getOrCreateUser(note.address.pubKeyHex)?.outboxRelays()?.let { set.addAll(it) } - set.addAll(LocalCache.relayHints.hintsForAddress(note.idHex)) + set.addAll(cache.relayHints.hintsForAddress(note.idHex)) - LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } + cache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } note.replyTo?.forEach { parentNote -> set.addAll(parentNote.relays) - LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } + cache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } parentNote.author?.inboxRelays()?.let { set.addAll(it) } } @@ -52,7 +55,7 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set note.replies.forEach { childNote -> set.addAll(childNote.relays) - LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } + cache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } childNote.author?.outboxRelays()?.let { set.addAll(it) } } @@ -72,13 +75,16 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set return set } -fun filterMissingAddressables(keys: List): List { +fun filterMissingAddressables( + cache: ICacheProvider, + keys: List, +): List { val addressesPerRelay = mapOfSet { keys.forEach { key -> - val default = key.account.followPlusAllMineWithSearch.flow.value + val default = key.account.followPlusAllMineWithSearchRelays() if (key.note is AddressableNote && key.note.event == null) { - potentialRelaysToFindAddress(key.note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindAddress(cache, key.note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, key.note.address) } } @@ -86,7 +92,7 @@ fun filterMissingAddressables(keys: List): List if (note is AddressableNote && note.event == null) { - potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindAddress(cache, note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, note.address) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingEvents.kt similarity index 79% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingEvents.kt index f9f545fc00..65cac46200 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingEvents.kt @@ -18,33 +18,36 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders +package com.vitorpamplona.amethyst.commons.relayClient.event.loaders +import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet -fun potentialRelaysToFindEvent(note: Note): Set { +fun potentialRelaysToFindEvent( + cache: ICacheProvider, + note: Note, +): Set { val set = mutableSetOf() - set.addAll(LocalCache.relayHints.hintsForEvent(note.idHex)) + set.addAll(cache.relayHints.hintsForEvent(note.idHex)) note.author?.outboxRelays()?.let { set.addAll(it) } - LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } + cache.getAnyChannel(note)?.relays()?.let { set.addAll(it) } note.replyTo?.forEach { parentNote -> set.addAll(parentNote.relays) - LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } + cache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) } parentNote.author?.inboxRelays()?.let { set.addAll(it) } } @@ -52,7 +55,7 @@ fun potentialRelaysToFindEvent(note: Note): Set { note.replies.forEach { childNote -> set.addAll(childNote.relays) - LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } + cache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) } childNote.author?.outboxRelays()?.let { set.addAll(it) } } @@ -81,7 +84,7 @@ fun potentialRelaysToFindEvent(note: Note): Set { val noteEvent = parent.event if (noteEvent is PubKeyHintProvider) { noteEvent.linkedPubKeys().forEach { potentialAuthor -> - LocalCache.checkGetOrCreateUser(potentialAuthor)?.let { potentialAuthor -> + cache.checkGetOrCreateUser(potentialAuthor)?.let { potentialAuthor -> potentialAuthor.outboxRelays()?.let { set.addAll(it) } potentialAuthor.inboxRelays()?.let { set.addAll(it) } } @@ -98,18 +101,21 @@ fun potentialRelaysToFindEvent(note: Note): Set { return set } -fun filterMissingEvents(keys: List): List { +fun filterMissingEvents( + cache: ICacheProvider, + keys: List, +): List { val eventsPerRelay = mapOfSet { keys.forEach { key -> - val default = key.account.followPlusAllMineWithSearch.flow.value + val default = key.account.followPlusAllMineWithSearchRelays() if (key.note !is AddressableNote && key.note.event == null) { - potentialRelaysToFindEvent(key.note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindEvent(cache, key.note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, key.note.idHex) } - key.account.searchRelayList.flow.value.forEach { relayUrl -> + key.account.searchRelays().forEach { relayUrl -> add(relayUrl, key.note.idHex) } } @@ -117,7 +123,7 @@ fun filterMissingEvents(keys: List): List if (note !is AddressableNote && note.event == null) { - potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl -> + potentialRelaysToFindEvent(cache, note).ifEmpty { default }.forEach { relayUrl -> add(relayUrl, note.idHex) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/NoteEventLoaderSubAssembler.kt similarity index 67% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/NoteEventLoaderSubAssembler.kt index 9ff15e6407..172ba4d101 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/NoteEventLoaderSubAssembler.kt @@ -18,21 +18,28 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders +package com.vitorpamplona.amethyst.commons.relayClient.event.loaders -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient class NoteEventLoaderSubAssembler( client: INostrClient, + val cache: ICacheProvider, allKeys: () -> Set, ) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { override fun updateFilter(keys: List) = listOfNotNull( - filterMissingEvents(keys), - filterMissingAddressables(keys), + filterMissingEvents(cache, keys), + filterMissingAddressables(cache, keys), ).flatten() override fun distinct(key: EventFinderQueryState) = key.note + + // Attribute to the account that owns this subscription, when a single account is watching. + // Deduped by pubkey hex, not by account identity, so two objects for the same logged-in user + // don't look like two accounts and suppress attribution. + override fun accountPubKeyOf(key: Any?): String? = (key as? EventFinderQueryState)?.account?.userFinderPubkeyHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/EventWatcherSubAssembler.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/EventWatcherSubAssembler.kt index e58b596d2d..25a9bb3b5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/EventWatcherSubAssembler.kt @@ -18,15 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers +package com.vitorpamplona.amethyst.commons.relayClient.event.watchers +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState -import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast -import com.vitorpamplona.amethyst.service.relays.MutableTime -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState +import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast +import com.vitorpamplona.amethyst.commons.relays.MutableTime +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -67,7 +67,7 @@ class EventWatcherSubAssembler( // the same logged-in user would look like two accounts and suppress attribution entirely. val soleAccountPubKey = keys - .mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex } + .mapTo(mutableSetOf()) { it.account.userFinderPubkeyHex } .singleOrNull() return groupByRelayPresence(lastNotesOnFilter, latestEOSEs) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt index cce973f915..16e0f9f435 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers +package com.vitorpamplona.amethyst.commons.relayClient.event.watchers +import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt index 64510793f8..f96a5b46fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -20,12 +20,12 @@ */ @file:Suppress("DEPRECATION") -package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers +package com.vitorpamplona.amethyst.commons.relayClient.event.watchers +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent From a867f9b2ca4ae58c018099721fba4b9cec00e9c1 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 10 Aug 2026 10:59:37 +0300 Subject: [PATCH 206/227] feat: wire Desktop to the shared per-visible metadata + reaction finders (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-apply the Desktop adoption on top of the commons finders (now on upstream's model): - DesktopIAccount implements UserFinderAccount (connected-relays/nip65 relay hints; trustProvider/followerCountProvider = null, declaredFollowsByOutboxRelay = emptyMap — Desktop has no NIP-85 subsystem). followerCountProvider() is the new getter this model needs. - DesktopRelaySubscriptionsCoordinator builds userFinder + eventFinder (RelayOfflineTracker). - Main.kt provides LocalUserFinder/LocalUserFinderAccount/LocalEventFinder at both logged-in composition roots. - Per-visible adoption across feed (FeedNoteCardBody), NoteCard (profile/thread/bookmarks/ search/quoted), DM headers + conversation list, UserSearchCard (observeUserInfo), NotificationsScreen, thread replies — plus the fast index-relay warm-up on the feed. Metadata + reactions now load strictly per on-screen user/note on Desktop, coalesced into batched REQs by the shared finders. Green: :desktopApp compile, :amethyst compilePlayDebugKotlin, :commons verifyKmpPurity, spotless. Co-Authored-By: Claude Opus 4.8 --- .../commons/ui/components/UserSearchCard.kt | 16 ++++- .../vitorpamplona/amethyst/desktop/Main.kt | 9 +++ .../amethyst/desktop/model/DesktopIAccount.kt | 35 ++++++++- .../DesktopRelaySubscriptionsCoordinator.kt | 29 ++++++++ .../amethyst/desktop/ui/FeedScreen.kt | 72 ++++++++++++------- .../desktop/ui/NotificationsScreen.kt | 32 +++++---- .../amethyst/desktop/ui/SearchScreen.kt | 19 ++--- .../amethyst/desktop/ui/ThreadScreen.kt | 11 ++- .../desktop/ui/chats/ChatroomHeader.kt | 21 ++++-- .../desktop/ui/chats/ConversationListPane.kt | 6 +- .../amethyst/desktop/ui/note/NoteCard.kt | 22 ++++++ 11 files changed, 207 insertions(+), 65 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt index bd7e82f54f..4badbacef1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily @@ -39,6 +40,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.accessibility_navigate import com.vitorpamplona.amethyst.commons.resources.accessibility_user_avatar @@ -51,6 +53,12 @@ import org.jetbrains.compose.resources.stringResource * @param badge Optional overlay drawn on top of the avatar (bottom-right * by convention). Used by Desktop for the WoT trust-score chip; Android * call sites leave it null. Forwarded to [UserAvatar]. + * + * Loads the user's kind-0 metadata only while the card is composed via + * [observeUserInfo], so a search-results list only fetches metadata for the + * users currently on screen (coalesced into the shared finder's batched REQs). + * Requires [LocalUserFinder]/[LocalUserFinderAccount] in scope — provided at + * the Desktop logged-in roots; this card is Desktop-only. */ @Composable fun UserSearchCard( @@ -59,6 +67,8 @@ fun UserSearchCard( modifier: Modifier = Modifier, badge: @Composable (BoxScope.() -> Unit)? = null, ) { + val metadata by observeUserInfo(user) + Card( modifier = modifier @@ -76,7 +86,7 @@ fun UserSearchCard( ) { UserAvatar( userHex = user.pubkeyHex, - pictureUrl = user.profilePicture(), + pictureUrl = metadata?.info?.picture ?: user.profilePicture(), size = 40.dp, contentDescription = stringResource(Res.string.accessibility_user_avatar), badge = badge, @@ -84,11 +94,11 @@ fun UserSearchCard( Column(modifier = Modifier.weight(1f)) { Text( - user.toBestDisplayName(), + metadata?.info?.bestName() ?: user.toBestDisplayName(), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, ) - val nip05 = user.metadataOrNull()?.nip05() + val nip05 = metadata?.info?.nip05 ?: user.metadataOrNull()?.nip05() if (nip05 != null) { Text( nip05, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 3c5faabd88..704c5f1a2c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -81,8 +81,11 @@ import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSetti import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationReadState import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationSettings import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalBanner +import com.vitorpamplona.amethyst.commons.relayClient.event.LocalEventFinder import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinder +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady import com.vitorpamplona.amethyst.commons.wot.LocalWoTService @@ -1470,6 +1473,9 @@ private fun AppInner( LocalNamecoinService provides namecoinService, LocalSpamExemptKeys provides spamExemptKeys, com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount, + LocalUserFinder provides subscriptionsCoordinator.userFinder, + LocalUserFinderAccount provides iAccount, + LocalEventFinder provides subscriptionsCoordinator.eventFinder, ) { val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState() Column(modifier = Modifier.fillMaxSize()) { @@ -2114,6 +2120,9 @@ fun MainContent( LocalRelayCategories provides relayCategories, LocalBlossomServers provides iAccount.blossomServerList.flow, com.vitorpamplona.amethyst.desktop.model.LocalDesktopIAccount provides iAccount, + LocalUserFinder provides subscriptionsCoordinator.userFinder, + LocalUserFinderAccount provides iAccount, + LocalEventFinder provides subscriptionsCoordinator.eventFinder, com.vitorpamplona.amethyst.desktop.ui.LocalSnackbarHost provides snackbarHostState, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 3d9b28bbb2..c045e74df5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.model.nipB7Blossom.BlossomServerListSt import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.moderation.PreferencesSensitiveContentSettings import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager @@ -64,6 +65,7 @@ import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope @@ -92,11 +94,42 @@ class DesktopIAccount( private val scope: CoroutineScope, private val accountRelays: DesktopAccountRelays? = null, val dmInboxResolver: DmInboxRelayResolver? = null, -) : IAccount { +) : IAccount, + UserFinderAccount { override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME) override val pubKey: String = accountState.pubKeyHex + // UserFinderAccount — Desktop's relay-hint view for the shared per-user + // metadata subscription layer. Desktop has no separate indexer/search relay + // lists nor a NIP-85 trust provider, so it routes discovery through its + // connected relays + NIP-65 outbox and degrades trust/reports to null/empty + // (contact-card ranking + report loading are best-effort here — see + // UserFinderAccount). + override val userFinderPubkeyHex: HexKey get() = pubKey + + override fun indexRelays(): Set = relayManager.connectedRelays.value + + override fun outboxHomeRelays(): Set = nip65RelayList.allFlowNoDefaults.value + relayManager.connectedRelays.value + + override fun searchRelays(): Set = relayManager.connectedRelays.value + + // Desktop has no merged follow/mine/search relay-list subsystem; route + // missing-event discovery through the connected relays (same degrade path + // as the other hints above). + override fun followPlusAllMineWithSearchRelays(): Set = relayManager.connectedRelays.value + + override fun commonRelays(): Set = relayManager.connectedRelays.value + + override fun cardHomeRelays(): Set = nip65RelayList.allFlowNoDefaults.value + + override fun trustProvider(): ServiceProviderTag? = null + + // Desktop has no NIP-85 rank/follower providers wired (same as trustProvider). + override fun followerCountProvider(): ServiceProviderTag? = null + + override fun declaredFollowsByOutboxRelay(): Map> = emptyMap() + // ----- State Classes (pin important notes via strong refs for GC retention) ----- val oldBookmarkState = OldBookmarkListState(signer, localCache, scope) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 3a315d6507..78c1cffa71 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.desktop.subscriptions import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoordinator +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert import com.vitorpamplona.amethyst.commons.wot.OutboxCacheGateway import com.vitorpamplona.amethyst.commons.wot.OutboxDispatcher @@ -32,6 +34,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -83,6 +86,32 @@ class DesktopRelaySubscriptionsCoordinator( private val indexRelays: Set, private val localCache: DesktopLocalCache, ) { + /** + * Tracks relays that refuse to connect, so the shared user-finder skips + * them when picking discovery relays. Self-registers as a client listener. + */ + private val failureTracker = RelayOfflineTracker(client) + + /** + * The shared, composition-scoped per-user metadata subscription assembler + * (moved to commons). Desktop composables reach it via [LocalUserFinder] + * (provided in Main.kt) and subscribe per visible user through + * `observeUserPicture(user)` / `observeUserInfo(user)`, so metadata loads + * only for on-screen users. Coalesces every subscribed user into batched + * REQs — no per-avatar REQ storm. + */ + val userFinder = UserFinderFilterAssembler(client, localCache, failureTracker) + + /** + * The shared, composition-scoped per-note event subscription assembler + * (reactions / zaps / reposts / replies, moved to commons). Desktop note + * rows reach it via [LocalEventFinder] (provided in Main.kt) and subscribe + * per visible note through `EventFinderFilterAssemblerSubscription(note)`, so + * interactions load only for on-screen notes. Composes [userFinder] to + * resolve authors of not-yet-cached addressable notes. + */ + val eventFinder = EventFinderFilterAssembler(client, localCache, userFinder) + // Rate limiter: 20 requests per second to avoid flooding relays private val rateLimiter = MetadataRateLimiter(maxRequestsPerSecond = 20, scope = scope) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 16696692b5..3c77deba9c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -95,6 +95,10 @@ import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction import com.vitorpamplona.amethyst.commons.nip64Chess.RelaySyncStatus +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState import com.vitorpamplona.amethyst.commons.search.QuerySerializer @@ -268,6 +272,20 @@ private fun FeedNoteCardBody( myPubKeyHex: String? = null, onFollow: ((String) -> Unit)? = null, ) { + // Load this note author's metadata (kind 0 + relay lists) only while this + // card is composed — i.e. on or near screen. The shared commons finder + // coalesces every visible author into batched REQs, giving per-row, + // visibility-scoped metadata loading that matches Android's model. + val cardAuthor = note.author + if (cardAuthor != null) { + UserFinderFilterAssemblerSubscription(cardAuthor) + } + + // Load this note's interactions (reactions / zaps / reposts / replies) only + // while the card is composed — the per-note counterpart to the author + // subscription above, coalesced into batched REQs by the shared event finder. + EventFinderFilterAssemblerSubscription(note) + if (event is PollEvent) { DesktopPollCard( note = note, @@ -751,17 +769,19 @@ fun FeedScreen( } } - // Viewport-aware metadata loading: only fetch for visible notes + buffer - // Uses snapshotFlow to avoid per-frame recomposition from scroll observation + // Fast first-paint warm-up: one batched kind-0 REQ straight to the index + // relays for the first visible authors. The per-row UserFinder subscriptions + // (in FeedNoteCardBody) are the source of truth — they do NIP-65 outbox + // routing and tear down off-screen — but their two-hop discovery is slower to + // first paint, so this immediate batch fills names/avatars instantly. Also + // prefetches reactions + referenced (repost/quote) notes for the initial set. LaunchedEffect(feedState, subscriptionsCoordinator) { if (subscriptionsCoordinator == null || feedState !is FeedState.Loaded) return@LaunchedEffect - val loadedFeed = feedState as FeedState.Loaded - // Initial load: batch metadata for first visible notes immediately val initialNotes = viewModel.feedState.visibleNotes().take(30) if (initialNotes.isNotEmpty()) { val authors = initialNotes.mapNotNull { it.author?.pubkeyHex }.distinct() - subscriptionsCoordinator.loadMetadataBatched(authors) + if (authors.isNotEmpty()) subscriptionsCoordinator.loadMetadataBatched(authors) subscriptionsCoordinator.loadMetadataForNotes(initialNotes) } } @@ -988,7 +1008,11 @@ fun FeedScreen( val loadedState by state.feed.collectAsState() val lazyListState = homeFeedLazyListState - // Viewport-aware scroll observation: fetch metadata for newly visible notes + // Fast-path warm-up on scroll: batch a kind-0 REQ to index + // relays for authors entering the viewport (+10 buffer), so + // names/avatars paint immediately. Complementary to the per-row + // FeedNoteCardBody subscriptions, which remain the source of + // truth (outbox routing + off-screen teardown). LaunchedEffect(lazyListState, loadedState) { if (subscriptionsCoordinator == null) return@LaunchedEffect val feedList = loadedState.list @@ -999,7 +1023,7 @@ fun FeedScreen( if (info.visibleItemsInfo.isEmpty()) return@snapshotFlow -1 to -1 info.visibleItemsInfo.first().index to info.visibleItemsInfo.last().index }.distinctUntilChanged() - .debounce(500) + .debounce(300) .collect { (first, last) -> if (first < 0) return@collect val from = (first - 10).coerceAtLeast(0) @@ -1974,15 +1998,9 @@ private fun ExpandedNoteContent( // Get reply notes from cache — recompute when replies change val replyNotes = remember(repliesState) { note.replies.sortedByDescending { it.createdAt() } } - // Load metadata for reply authors - LaunchedEffect(replyNotes, subscriptionsCoordinator) { - if (subscriptionsCoordinator != null && replyNotes.isNotEmpty()) { - val authors = replyNotes.mapNotNull { it.event?.pubKey }.distinct() - if (authors.isNotEmpty()) { - subscriptionsCoordinator.loadMetadataBatched(authors) - } - } - } + // Reply-author metadata (kind 0) is loaded per-row: each CommentItem below + // opens its own composition-scoped observeUser* subscription, so metadata + // loads for on-screen replies only and tears down when the thread closes. Column(modifier = Modifier.padding(top = 8.dp)) { // Comments card @@ -2022,24 +2040,30 @@ private fun ExpandedNoteContent( replyNotes.take(5).forEachIndexed { index, replyNote -> val replyEvent = replyNote.event val flowSet = remember(replyNote) { replyNote.flow() } - val metadataState by flowSet.metadata.stateFlow.collectAsState() val reactionsState by flowSet.reactions.stateFlow.collectAsState() val zapsState by flowSet.zaps.stateFlow.collectAsState() DisposableEffect(replyNote) { onDispose { replyNote.clearFlow() } } - val author = - remember(replyEvent?.pubKey, metadataState) { - replyEvent?.pubKey?.let { localCache.getUserIfExists(it) } - } + // Load this reply's own interactions (reactions/zaps) only + // while the comment row is composed. + EventFinderFilterAssemblerSubscription(replyNote) + + // Load + observe this reply author's metadata only while the + // comment row is composed; observeUser* both subscribes and + // drives recomposition when kind-0 arrives. + val replyAuthorPubKey = replyEvent?.pubKey + val author = remember(replyAuthorPubKey, localCache) { replyAuthorPubKey?.let { localCache.getOrCreateUser(it) } } + val authorName = author?.let { observeUserName(it).value } + val authorPicture = author?.let { observeUserPicture(it).value } val reactionCount = remember(reactionsState) { replyNote.countReactions() } val zapAmount = remember(zapsState) { replyNote.zapsAmount } CommentItem( - authorName = author?.toBestDisplayName() ?: replyEvent?.pubKey?.take(8) ?: "", + authorName = authorName ?: replyAuthorPubKey?.take(8) ?: "", authorHandle = author?.pubkeyNpub()?.take(16)?.let { "@$it..." } ?: "", - authorAvatarUrl = author?.profilePicture(), - authorPubKeyHex = replyEvent?.pubKey ?: "", + authorAvatarUrl = authorPicture, + authorPubKeyHex = replyAuthorPubKey ?: "", content = replyEvent?.content ?: "", timeAgo = (replyEvent?.createdAt ?: 0L).toTimeAgo(), reactionCount = reactionCount, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index be0adf4ad5..4bdb8f1083 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -67,6 +67,8 @@ import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationK import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationReadState import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationSettings import com.vitorpamplona.amethyst.commons.moderation.notifications.nowEpochSeconds +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState @@ -654,10 +656,11 @@ private fun AggregateCard( horizontalArrangement = Arrangement.spacedBy((-4).dp), ) { reactorPubKeys.take(5).forEach { pk -> - val user = remember(pk, metadataVersion) { localCache.getUserIfExists(pk) } + val user = remember(pk, localCache) { localCache.getOrCreateUser(pk) } + val picture by observeUserPicture(user) UserAvatar( userHex = pk, - pictureUrl = user?.profilePicture(), + pictureUrl = picture, size = 20.dp, modifier = Modifier.clickable { onNavigateToProfile(pk) }, ) @@ -694,15 +697,17 @@ private fun AggregateCard( .clickable { onNavigateToProfile(pk) } .padding(vertical = 3.dp), ) { - val user = remember(pk, metadataVersion) { localCache.getUserIfExists(pk) } + val user = remember(pk, localCache) { localCache.getOrCreateUser(pk) } + val picture by observeUserPicture(user) + val name by observeUserName(user) UserAvatar( userHex = pk, - pictureUrl = user?.profilePicture(), + pictureUrl = picture, size = 22.dp, ) Spacer(Modifier.size(6.dp)) Text( - user?.toBestDisplayName() ?: pk.take(12), + name, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) @@ -821,14 +826,17 @@ fun NotificationCard( // actual zap sender lives in the nested zap request. effectiveAuthorPubKey // returns the right one per kind. val pk = notification.effectiveAuthorPubKey - val user = remember(pk, metadataVersion, localCache) { localCache?.getUserIfExists(pk) } + // Load + observe the actor's metadata only while this card is composed. + // localCache is only null in previews/defaults; guard the observers so we + // never touch LocalUserFinder off the provider tree. + val user = remember(pk, localCache) { localCache?.getOrCreateUser(pk) } + val observedName = user?.let { observeUserName(it).value } + val observedPicture = user?.let { observeUserPicture(it).value } val displayName = - remember(user, metadataVersion, pk) { - user?.toBestDisplayName() - ?: pk.hexToByteArrayOrNull()?.toNpub()?.take(12) - ?: pk.take(12) - } - val pictureUrl = remember(user, metadataVersion) { user?.profilePicture() } + observedName + ?: pk.hexToByteArrayOrNull()?.toNpub()?.take(12) + ?: pk.take(12) + val pictureUrl = observedPicture val unread by remember(notification.timestamp, lastReadAt) { derivedStateOf { notification.timestamp > lastReadAt } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index ba976472ea..cbddc13117 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -349,19 +349,12 @@ fun SearchScreen( } } - // Load author metadata for incoming note results. NIP-50 search relays - // typically don't return kind-0 metadata alongside notes, and the - // subscription explicitly drops MetadataEvents anyway — so display name - // and avatar arrive only after we explicitly fetch them from index - // relays via the coordinator. - LaunchedEffect(noteResults, subscriptionsCoordinator) { - val coordinator = subscriptionsCoordinator ?: return@LaunchedEffect - if (noteResults.isEmpty()) return@LaunchedEffect - val authors = noteResults.map { it.pubKey }.distinct() - if (authors.isNotEmpty()) { - coordinator.loadMetadataBatched(authors) - } - } + // Note-result author metadata (kind 0) is no longer batch-fetched here. + // Each result renders through NoteCard, which opens its own composition-scoped + // UserFinderFilterAssembler subscription — so the author's metadata is fetched + // from index/outbox relays per on-screen result and torn down when scrolled off. + // (NIP-50 search relays don't return kind-0 and the subscription drops + // MetadataEvents; the per-row finder covers that gap.) // Fetch interactions (incl. kind-1018 poll responses) for poll results so their // tallies populate — NIP-50 search returns the polls but not their responses. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index ecbd160bc1..dad4160970 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -194,12 +193,10 @@ fun ThreadScreen( onDispose { subId?.let { coordinator.releaseInteractions(it) } } } - // Load metadata for thread authors via coordinator - LaunchedEffect(threadNotes, subscriptionsCoordinator) { - if (subscriptionsCoordinator != null && threadNotes.isNotEmpty()) { - subscriptionsCoordinator.loadMetadataForNotes(threadNotes) - } - } + // Thread-author metadata + note interactions now load per row: each thread + // note renders through NoteCard, which opens composition-scoped UserFinder + + // EventFinder subscriptions. (requestInteractions above remains the thread's + // explicit interaction-refresh path.) // Fetch quoted notes referenced in thread content val quotedNoteIds = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt index 96a771e6d3..3a3b724d41 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt @@ -29,12 +29,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment 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 com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.accessibility_user_avatar import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar @@ -59,6 +62,10 @@ fun ChatroomHeader( modifier: Modifier = ChatStdPadding, onClick: () -> Unit, ) { + // Load + observe the partner's metadata only while this header is composed. + val picture by observeUserPicture(user) + val name by observeUserName(user) + Column( Modifier .fillMaxWidth() @@ -68,14 +75,14 @@ fun ChatroomHeader( Row(verticalAlignment = Alignment.CenterVertically) { UserAvatar( userHex = user.pubkeyHex, - pictureUrl = user.profilePicture(), + pictureUrl = picture, size = ChatSize34dp, contentDescription = stringResource(Res.string.accessibility_user_avatar), ) Column(modifier = Modifier.padding(start = 10.dp)) { Text( - text = user.toBestDisplayName(), + text = name, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 1, @@ -107,7 +114,12 @@ fun GroupChatroomHeader( modifier: Modifier = ChatStdPadding, onClick: () -> Unit, ) { - val participants = users.joinToString(", ") { it.toBestDisplayName() } + // Load + observe each participant's metadata only while this header is + // composed, so names and the group-icon avatar update as kind-0 arrives. + // forEach is inline, so the @Composable observeUserName call is legal here. + val participantNames = mutableListOf() + users.forEach { participantNames.add(observeUserName(it).value) } + val participants = participantNames.joinToString(", ") Column( modifier = Modifier @@ -121,9 +133,10 @@ fun GroupChatroomHeader( Row(verticalAlignment = Alignment.CenterVertically) { // Show first user's avatar as the group icon users.firstOrNull()?.let { firstUser -> + val firstUserPicture by observeUserPicture(firstUser) UserAvatar( userHex = firstUser.pubkeyHex, - pictureUrl = firstUser.profilePicture(), + pictureUrl = firstUserPicture, size = ChatSize34dp, contentDescription = stringResource(Res.string.accessibility_user_avatar), ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index 337b738e8b..3ada46ff4d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -66,6 +66,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -320,9 +321,12 @@ private fun ConversationCard( val firstUser = item.users.firstOrNull() Box { if (firstUser != null) { + // Load + observe the conversation's primary user only while this + // row is composed (i.e. on screen in the list). + val firstUserPicture by observeUserPicture(firstUser) UserAvatar( userHex = firstUser.pubkeyHex, - pictureUrl = firstUser.profilePicture(), + pictureUrl = firstUserPicture, size = 40.dp, ) } else if (item.isGroup) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index adf0b732ca..27d6ae795a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -56,6 +56,8 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.UrlParser @@ -63,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText +import com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer @@ -112,6 +115,25 @@ fun NoteCard( replyContext: ReplyContext? = null, onNavigateToThread: ((String) -> Unit)? = null, ) { + // Load the author's metadata (kind 0) only while this card is composed — + // i.e. on/near screen. This one call covers every screen that renders + // through NoteCard (profile, thread, bookmarks, search); the shared commons + // finder coalesces all visible authors into batched REQs. + val noteCardCache = LocalDesktopCache.current + val noteCardAuthor = remember(note.pubKeyHex, noteCardCache) { noteCardCache?.getOrCreateUser(note.pubKeyHex) } + if (noteCardAuthor != null) { + UserFinderFilterAssemblerSubscription(noteCardAuthor) + } + + // Load this note's interactions (reactions / zaps / reposts / replies) only + // while the card is composed — covers every NoteCard surface (profile, thread, + // bookmarks, search, quoted embeds). Guarded on cache presence so previews + // (no LocalDesktopCache → no LocalEventFinder) don't hit the provider default. + val noteCardNote = remember(note.id, noteCardCache) { noteCardCache?.getNoteIfExists(note.id) } + if (noteCardNote != null) { + EventFinderFilterAssemblerSubscription(noteCardNote) + } + val urls = remember(note.content) { UrlParser().parseValidUrls(note.content) } val imageUrls = remember(urls) { From fbe163e8f28db1ab40e4ec22ed6ecccdfb7595f7 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 10 Aug 2026 11:00:52 +0300 Subject: [PATCH 207/227] test+docs: commons finder test + document per-visible loading (Phase 5) - EventFinderFilterAssemblyTest (commons jvmTest): filterMissingEvents batches one ids-filter per relay with sorted ids; ICacheProvider.checkGetOrCreateUser default tolerates a throwing/null getOrCreateUser via a fake cache. - relay-client skill + commons/ARCHITECTURE.md: document observeUser*/ EventFinderFilterAssemblerSubscription/observeNote* as the canonical per-visible loading entry points and the LocalUserFinder/LocalUserFinderAccount/LocalEventFinder seams. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/relay-client/SKILL.md | 26 ++++ commons/ARCHITECTURE.md | 2 +- .../event/EventFinderFilterAssemblyTest.kt | 119 ++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblyTest.kt diff --git a/.claude/skills/relay-client/SKILL.md b/.claude/skills/relay-client/SKILL.md index c1019cde8f..1b72fb6977 100644 --- a/.claude/skills/relay-client/SKILL.md +++ b/.claude/skills/relay-client/SKILL.md @@ -94,6 +94,32 @@ class MetadataFilterAssembler( Assemblers stay pure — no state, no I/O. They're the composition seam: `FeedMetadataCoordinator` takes a list of visible notes and assembles a single metadata filter covering every referenced pubkey. +## Per-visible loading — the canonical entry points (`observeUser*` / `observeNote*`) + +Prefer these over hand-rolled "load metadata for this list" calls. They are the shared, +KMP way to load data **only for what's on screen** — a composable subscribes while it is in +composition and unsubscribes ~30s after it leaves (or the app backgrounds). Both live in +`commons/relayClient/`: + +- **Per user** (`relayClient/user/`): `observeUserInfo/Picture/Banner/AboutMe/Name(user)` + each open a composition-scoped `UserFinderFilterAssemblerSubscription(user)` **and** return + reactive `State`. Metadata (kind 0 + relay lists) loads for on-screen users only, coalesced + into one batched REQ per relay for the whole visible set. +- **Per note** (`relayClient/event/`): `EventFinderFilterAssemblerSubscription(note)` loads a + note's interactions (reactions / zaps / reposts / replies) while it is composed. Android's + `observeNote*` display observers layer on top of the same subscription. + +Both read front-end-provided CompositionLocals — `LocalUserFinder` / `LocalUserFinderAccount` +(reused by the event finder) / `LocalEventFinder` — provided once near the composition root +(Android `AppModules`, Desktop `Main.kt` via its subscriptions coordinator). The account seam +is the narrow `UserFinderAccount` (snapshot relay-hint getters), NOT the fat `IAccount`. +`error()` defaults mean these must never be reached from a composition without a relay client +(e.g. the Android `:napplet` sandbox). + +The load-once, viewport-batch path (`FeedMetadataCoordinator.loadMetadataForNotes` / +`loadMetadataBatched`) is superseded for foreground loading; `MetadataPreloader` remains only +as an optional off-screen background warmer. + ## Preloaders `MetadataPreloader` is the "I need metadata for 200 pubkeys, but don't melt my CPU or the relay" path. It uses `MetadataRateLimiter` (token bucket) to throttle bulk fetches and group them into relay-friendly chunks. diff --git a/commons/ARCHITECTURE.md b/commons/ARCHITECTURE.md index b86c122c6f..25c594b532 100644 --- a/commons/ARCHITECTURE.md +++ b/commons/ARCHITECTURE.md @@ -101,7 +101,7 @@ they are shared across the GUI apps. Treat as GUI-shared, not strictly headless. ### Relay client | Package | UI? | Purpose | |----------------|-----|---------| -| `relayClient` | no | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a `composeSubscriptionManagers` subpackage name, this is subscription-lifecycle logic, not UI.) | +| `relayClient` | no | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a `composeSubscriptionManagers` subpackage name, this is subscription-lifecycle logic, not UI.) The canonical **per-visible loading** entry points live here: `relayClient/user/` (`observeUser*` — kind-0 metadata) and `relayClient/event/` (`EventFinderFilterAssemblerSubscription`/`observeNote*` — reactions/zaps/reposts). See the `relay-client` skill. | | `relays` | no | Low-level EOSE/relay-timing bookkeeping (`EOSECache`, `EOSERelayList`). | ### Platform abstractions (`expect`/`actual`) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblyTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblyTest.kt new file mode 100644 index 0000000000..034565a5d5 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblyTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.event + +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the extracted (Phase 2b) per-note event-finder filter assembly + * and the [ICacheProvider] seam it relies on. + */ +class EventFinderFilterAssemblyTest { + private val relay1 = NormalizedRelayUrl("wss://relay1.test/") + private val relay2 = NormalizedRelayUrl("wss://relay2.test/") + + /** + * One batched `ids` filter per relay — NOT one filter per event id — and the + * ids are sorted deterministically so REQs dedup across rebuilds. + */ + @Test + fun `filterMissingEvents batches one filter per relay with sorted ids`() { + val filters = + filterMissingEvents( + mapOf( + relay1 to setOf("bbbb", "aaaa", "cccc"), + relay2 to setOf("dddd"), + ), + ) + + assertEquals("one batched filter per relay", 2, filters.size) + + val r1 = filters.first { it.relay == relay1 } + assertEquals(listOf("aaaa", "bbbb", "cccc"), r1.filter.ids) + + val r2 = filters.first { it.relay == relay2 } + assertEquals(listOf("dddd"), r2.filter.ids) + } + + @Test + fun `filterMissingEvents skips relays with no ids and empty input`() { + assertTrue(filterMissingEvents(emptyMap()).isEmpty()) + assertTrue(filterMissingEvents(mapOf(relay1 to emptySet())).isEmpty()) + } + + /** + * The new default [ICacheProvider.checkGetOrCreateUser] must swallow a + * malformed-key throw and return null (the event-finder follows pubkey hints + * parsed out of arbitrary events, some of which are junk). + */ + @Test + fun `checkGetOrCreateUser tolerates a throwing getOrCreateUser`() { + val throwing = + object : StubCache() { + override fun getOrCreateUser(pubkey: HexKey): User? = throw IllegalArgumentException("bad key") + } + assertNull(throwing.checkGetOrCreateUser("not-a-key")) + } + + @Test + fun `checkGetOrCreateUser passes through a null result`() { + assertNull(StubCache().checkGetOrCreateUser("00")) + } + + /** Minimal [ICacheProvider] that returns nothing; override per test. */ + private open class StubCache : ICacheProvider { + override val relayHints = HintIndexer() + + override fun getAnyChannel(note: Note): Channel? = null + + override fun getUserIfExists(pubkey: HexKey): User? = null + + override fun countUsers(predicate: (String, User) -> Boolean): Int = 0 + + override fun getNoteIfExists(hexKey: HexKey): Note? = null + + override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null + + override fun getOrCreateAddressableNote(key: Address): com.vitorpamplona.amethyst.commons.model.AddressableNote = error("unused") + + override fun getEventStream(): ICacheEventStream = error("unused") + + override fun hasBeenDeleted(event: Any): Boolean = false + + override fun getOrCreateUser(pubkey: HexKey): User? = null + + override fun justConsumeMyOwnEvent(event: Event): Boolean = false + } +} From eaba40f2a6f320768906fa8536454549d82fbda9 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 10 Aug 2026 14:51:19 +0300 Subject: [PATCH 208/227] =?UTF-8?q?fix:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20narrow=20search-relay=20seam,=20Local=20providers?= =?UTF-8?q?=20on=20Android,=20bug=20+=20test=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings from @davotoula: 1. Behaviour drift (real): the per-note event finder reused Account.searchRelays() (trusted + own search list), widening every missing-event REQ to trusted relays. Add a narrow UserFinderAccount.searchOnlyRelays() (= the pre-extraction account.searchRelayList read) and use it in FilterMissingEvents. Implemented on Account and DesktopIAccount. 2. Runtime trap: LocalUserFinder/LocalUserFinderAccount/LocalEventFinder error() when unprovided and were only provided on Desktop. Provide them on Android too, at the logged-in root (AppNavigation) from accountViewModel.dataSources() + .account, so any shared composable using the no-arg observeUser*/EventFinderFilterAssemblerSubscription overloads is safe on Android (the :napplet process never renders these). 3. Removed the redundant `.ifEmpty { DefaultSearchRelayList }` in Account.searchRelays() (SearchRelayListState.flow already applies that fallback) + its now-unused import. 4. Fixed a pre-existing shadowing bug on lines this PR touches: FilterByEvent's `note.replyTo?.forEach { parentNote -> }` used `note` in the body, so parent notes were never fetched — now uses `parentNote`. 5. Added a test at the layer where #1 lived: filterMissingEvents(cache, keys) fans a missing event to searchOnlyRelays + the follow/mine/search default, NOT the trusted relays that searchRelays would add. 6. Replaced an inline fully-qualified name in the test with an import (CLAUDE.md style). Green: commons jvmTest + verifyKmpPurity, :amethyst compilePlayDebugKotlin, :desktopApp compile, spotless. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/model/Account.kt | 7 +- .../subassemblies/FilterByEvent.kt | 6 +- .../amethyst/ui/navigation/AppNavigation.kt | 12 ++++ .../event/loaders/FilterMissingEvents.kt | 2 +- .../relayClient/user/UserFinderAccount.kt | 11 ++- .../event/EventFinderFilterAssemblyTest.kt | 68 ++++++++++++++++++- .../amethyst/desktop/model/DesktopIAccount.kt | 3 + 7 files changed, 101 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2260fb2b95..4cfab50126 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -32,7 +32,6 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermi import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList -import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect @@ -382,7 +381,11 @@ class Account( override fun outboxHomeRelays(): Set = nip65RelayList.allFlowNoDefaults.value + privateStorageRelayList.flow.value + localRelayList.flow.value - override fun searchRelays(): Set = (trustedRelayList.flow.value + searchRelayList.flow.value.ifEmpty { DefaultSearchRelayList }).toSet() + // searchRelayList.flow already applies the DefaultSearchRelayList fallback internally + // (SearchRelayListState.normalizeSearchRelayListWithBackup), so no ifEmpty needed here. + override fun searchRelays(): Set = (trustedRelayList.flow.value + searchRelayList.flow.value).toSet() + + override fun searchOnlyRelays(): Set = searchRelayList.flow.value override fun followPlusAllMineWithSearchRelays(): Set = followPlusAllMineWithSearch.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt index ae225e1b27..1bab42b2ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt @@ -45,9 +45,9 @@ fun filterByEvent( // loads threading that is event-based note.replyTo?.forEach { parentNote -> - if (parentNote !is AddressableNote && note.event == null) { - potentialRelaysToFindEvent(LocalCache, note).ifEmpty { default }.forEach { relayUrl -> - add(relayUrl, note.idHex) + if (parentNote !is AddressableNote && parentNote.event == null) { + potentialRelaysToFindEvent(LocalCache, parentNote).ifEmpty { default }.forEach { relayUrl -> + add(relayUrl, parentNote.idHex) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index ee1ace2d8d..2f1287512d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -50,6 +50,9 @@ import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState +import com.vitorpamplona.amethyst.commons.relayClient.event.LocalEventFinder +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinder +import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert @@ -341,6 +344,15 @@ fun AppNavigation( CompositionLocalProvider( LocalScreenLayout provides screenLayout, LocalTabReselectCoordinator provides tabReselectCoordinator, + // Provide the shared finder CompositionLocals so any commons composable that + // uses the no-arg observeUser*/EventFinderFilterAssemblerSubscription(note) + // overloads works when rendered on Android (they error() if unprovided). Android's + // own UI uses the AccountViewModel overloads and doesn't strictly need these, but + // providing them removes the runtime trap for shared composables reaching the + // logged-in tree. (The :napplet process never renders these composables.) + LocalUserFinder provides accountViewModel.dataSources().userFinder, + LocalUserFinderAccount provides accountViewModel.account, + LocalEventFinder provides accountViewModel.dataSources().eventFinder, ) { AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { Box(Modifier.fillMaxSize()) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingEvents.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingEvents.kt index 65cac46200..6675cf7538 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingEvents.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/FilterMissingEvents.kt @@ -115,7 +115,7 @@ fun filterMissingEvents( add(relayUrl, key.note.idHex) } - key.account.searchRelays().forEach { relayUrl -> + key.account.searchOnlyRelays().forEach { relayUrl -> add(relayUrl, key.note.idHex) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderAccount.kt index 9f625e2839..d1ecaae910 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/UserFinderAccount.kt @@ -56,9 +56,18 @@ interface UserFinderAccount { /** Home/write relays used for outbox discovery (nip65 + private storage + local). */ fun outboxHomeRelays(): Set - /** Search relays (trusted + search), with the default fallback applied. */ + /** Search relays (trusted + own search list), for the user-finder's search-tier fallback. */ fun searchRelays(): Set + /** + * Just this account's own NIP-51 search relay list — WITHOUT the trusted-relay + * union that [searchRelays] adds. This is the narrow set the per-note event + * finder fans "missing event" REQs to, matching the pre-extraction + * `account.searchRelayList` read (reusing [searchRelays] there would have + * unintentionally widened the fan-out to trusted relays). + */ + fun searchOnlyRelays(): Set + /** * Follow + all-mine + search relays, used by the per-note event-finder to * place "missing event" / "missing addressable" REQs (reactions, zaps, diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblyTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblyTest.kt index 034565a5d5..cfb3c42909 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblyTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/EventFinderFilterAssemblyTest.kt @@ -20,18 +20,22 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.event +import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents +import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -73,6 +77,43 @@ class EventFinderFilterAssemblyTest { assertTrue(filterMissingEvents(mapOf(relay1 to emptySet())).isEmpty()) } + /** + * The per-note event finder fans a missing event out to the account's own + * search relays ([UserFinderAccount.searchOnlyRelays]) plus the + * follow/mine/search default — NOT the trusted-relay union that + * [UserFinderAccount.searchRelays] adds. Regression guard for reusing the + * wrong getter here (which would silently widen every missing-event REQ to + * trusted relays). + */ + @Test + fun `filterMissingEvents(keys) uses searchOnlyRelays, not the trusted searchRelays union`() { + val searchOnly = NormalizedRelayUrl("wss://search.test/") + val trustedExtra = NormalizedRelayUrl("wss://trusted.test/") // only in searchRelays() + val default = NormalizedRelayUrl("wss://default.test/") // followPlusAllMineWithSearchRelays() + + val account = + object : StubAccount() { + override fun searchOnlyRelays() = setOf(searchOnly) + + override fun searchRelays() = setOf(searchOnly, trustedExtra) + + override fun followPlusAllMineWithSearchRelays() = setOf(default) + } + + // event == null and not addressable → a "missing event"; no author/replies, + // and the stub cache has empty relay hints, so potentialRelaysToFindEvent is + // empty and the code falls back to the default relays + searchOnlyRelays. + val note = Note("a".repeat(64)) + + val filters = filterMissingEvents(StubCache(), listOf(EventFinderQueryState(note, account))) + val relays = filters.map { it.relay }.toSet() + + assertTrue("search-only relay carries the missing-event REQ", searchOnly in relays) + assertTrue("follow/mine/search default carries it", default in relays) + assertFalse("trusted relay (only in searchRelays) must NOT be fanned out to", trustedExtra in relays) + filters.forEach { assertEquals(listOf(note.idHex), it.filter.ids) } + } + /** * The new default [ICacheProvider.checkGetOrCreateUser] must swallow a * malformed-key throw and return null (the event-finder follows pubkey hints @@ -106,7 +147,7 @@ class EventFinderFilterAssemblyTest { override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null - override fun getOrCreateAddressableNote(key: Address): com.vitorpamplona.amethyst.commons.model.AddressableNote = error("unused") + override fun getOrCreateAddressableNote(key: Address): AddressableNote = error("unused") override fun getEventStream(): ICacheEventStream = error("unused") @@ -116,4 +157,29 @@ class EventFinderFilterAssemblyTest { override fun justConsumeMyOwnEvent(event: Event): Boolean = false } + + /** Minimal [UserFinderAccount] returning nothing; override the relevant getters per test. */ + private open class StubAccount : UserFinderAccount { + override val userFinderPubkeyHex: HexKey = "00".repeat(32) + + override fun indexRelays(): Set = emptySet() + + override fun outboxHomeRelays(): Set = emptySet() + + override fun searchRelays(): Set = emptySet() + + override fun searchOnlyRelays(): Set = emptySet() + + override fun followPlusAllMineWithSearchRelays(): Set = emptySet() + + override fun commonRelays(): Set = emptySet() + + override fun cardHomeRelays(): Set = emptySet() + + override fun trustProvider(): ServiceProviderTag? = null + + override fun followerCountProvider(): ServiceProviderTag? = null + + override fun declaredFollowsByOutboxRelay(): Map> = emptyMap() + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index c045e74df5..e68f95c233 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -114,6 +114,9 @@ class DesktopIAccount( override fun searchRelays(): Set = relayManager.connectedRelays.value + // Desktop has no separate NIP-51 search relay list; degrade to connected relays. + override fun searchOnlyRelays(): Set = relayManager.connectedRelays.value + // Desktop has no merged follow/mine/search relay-list subsystem; route // missing-event discovery through the connected relays (same degrade path // as the other hints above). From 70c53a8fcddd267c958bbb752965a3c08087a9df Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Aug 2026 10:25:25 -0400 Subject: [PATCH 209/227] fix(concord): make the invite-list writes actually durable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A high-effort audit of the branch found that the durability guarantees the previous commits claimed were not the guarantees the code provided. Three of these are in the code written to close the last review, and they defeat exactly what those commits set out to fix. `INostrClient.publish` returns Unit — it queues an event and never reports acceptance; `publishAndConfirm` is the confirming variant. So every `runCatching { publish(...); true }` was true whenever local signing worked. That made minting's "record the link before handing out the URL" gate decorative, and made revoke worse than decorative: it reported success for a tombstone no relay stored, then recorded the kind-13303 tombstone, whose merge drops the entry — destroying the only `signer_sk` that could ever retire the link while the link stayed live. Both paths, and the Refounding re-mint, now confirm. `fetchAll` returns an empty list on cannot-connect / CLOSED / idle-timeout, so "a relay served us and had nothing" and "nobody answered" were the same observation. Reading the second as "no list yet" reintroduced, one layer below, the wipe the null-vs-empty work existed to prevent. `fetchAllWithHooks` gains a `doneOut` of per-relay terminal reasons plus `anyRelayServed()`, and both clients now only treat an empty read as an empty list when a relay actually reached EOSE. The rest: - `drainConcordRekeys` discarded the entry `adoptConcordRoot` now returns, so only the account that *launched* a rotation re-minted its links. An admin who was merely re-keyed left every link they had handed out on the dead root, and anyone stranded behind one could never recover — which is the branch's headline goal, holding only for the rotator. - The join-time ban gate fetched the Control Plane from `bundle.relays` alone (stale metadata refuses a community we can plainly reach) with a single un-paged REQ (truncated at the relay's filter cap, so a missing older ban edition fails the gate OPEN, re-admitting the account it exists to refuse). Now unions in the relays that just served the bundle, and pages. - `decodeOrNull` failed the whole document for one structurally incompatible entry. Since null now means "refuse to write", that converted the old silent data loss into a permanent write lock on a coordinate that never ages out. Unreadable entries are carried verbatim instead, so they neither block the account nor get dropped on re-encode. - The list read took the newest event of any kind and then cast, so one stray event at the coordinate read as "unreadable" forever. Filters by kind first. - The Refounding refresh did one full round trip per link, serially, inside a user-visible rotation. One pooled REQ over every link signer, then concurrent confirmed re-mints, classified per coordinate so one link's tombstone cannot decide another's status. Verified on a tablet: mint, list, revoke and the cross-client refusal still work end to end — and with the community relay killed, revoke now reports "The link couldn't be revoked" and leaves the entry intact, where before it would have claimed success and destroyed the key. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/model/AccountConcordActions.kt | 138 +++++++++++++----- .../com/vitorpamplona/amethyst/cli/Context.kt | 3 + .../amethyst/cli/commands/ConcordCommands.kt | 15 +- .../commons/actions/ConcordActions.kt | 10 ++ .../cord05Invites/ConcordInviteList.kt | 96 +++++++++--- .../NostrClientFetchAllWithHooksExt.kt | 21 +++ .../cord05Invites/ConcordInviteListTest.kt | 37 +++++ 7 files changed, 255 insertions(+), 65 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index 35816e52ce..1bf9339ae4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -54,8 +54,11 @@ import com.vitorpamplona.quartz.nip01Core.core.Event 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.relay.client.accessories.anyRelayServed import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -65,6 +68,9 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import java.util.concurrent.ConcurrentHashMap /** Name of the default Concord community Admin role minted by "Make admin". */ @@ -191,12 +197,29 @@ class AccountConcordActions( val relays = account.outboxRelays.flow.value if (relays.isEmpty()) return null val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(account.signer.pubKey)) + // Terminal reasons, not just events: `fetchAll` returns an empty list both when a relay + // served us and had nothing AND when nothing answered at all (cannot-connect, CLOSED, idle + // timeout). Treating the second as "no list yet" is precisely how a read-merge-write wipes + // the signer_sk of every link it failed to read, so the two must be told apart. + val reasons = mutableMapOf() + val events = + account.client.fetchAllWithHooks( + filters = relays.associateWith { listOf(filter) }, + doneOut = reasons, + ) { _, _ -> true } + val newest = - account.client - .fetchAll(filters = relays.associateWith { listOf(filter) }) + events + .mapNotNull { it.second as? ConcordInviteListEvent } + // Filter by kind BEFORE picking the newest: taking the newest of anything and then + // casting means one stray event at this coordinate reads as "unreadable" forever. .maxByOrNull { it.createdAt } - ?: return ConcordInviteListDocument.EMPTY // nothing published yet — safe to start one - return (newest as? ConcordInviteListEvent)?.decrypt(account.signer) + ?: return if (reasons.anyRelayServed()) { + ConcordInviteListDocument.EMPTY // a relay answered and had nothing — safe to start one + } else { + null // nobody answered; we know nothing about what is published + } + return newest.decrypt(account.signer) } /** @@ -216,9 +239,11 @@ class AccountConcordActions( Log.w("Concord") { "Refusing to write the invite list: could not read the current one (would drop other links' signer_sk)" } return false } + // publishAndConfirm, never publish: `INostrClient.publish` returns Unit — it queues the event + // and never reports acceptance — so a `runCatching { publish(); true }` is true whenever + // local signing worked, and every caller's "did the record land?" gate becomes decorative. return runCatching { - account.client.publish(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo) - true + account.client.publishAndConfirm(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo) }.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false) } @@ -243,30 +268,43 @@ class AccountConcordActions( val list = readConcordInviteList() ?: return 0 val tombstoned = list.tombstones.mapTo(HashSet()) { it.token } val now = TimeUtils.now() - var count = 0 - for (link in list.entries) { - if (link.communityId != entry.id) continue - // An elapsed or retired link can no longer be joined; re-posting it would only resurrect - // a dead URL at a live epoch. - if (link.isExpired(now) || link.token in tombstoned) continue - runCatching { - val token = link.token.hexToByteArray() - val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }) - // Honour a revocation published at this coordinate, and carry the live bundle's own - // fields forward — only the epoch's key material changes. - val current = ConcordActions.classifyInvite(wraps, token) as? InviteBundleStatus.Live ?: return@runCatching - val moved = - current.invite.copy( - communityRoot = entry.root, - rootEpoch = entry.rootEpoch, - controlPk = entry.controlPk, - relays = entry.relays, - ) - account.client.publish(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays) - count++ - }.onFailure { Log.w("Concord", "invite refresh failed for ${entry.id}", it) } + + // An elapsed or retired link can no longer be joined; re-posting it would only resurrect a + // dead URL at a live epoch. + val links = list.entries.filter { it.communityId == entry.id && !it.isExpired(now) && it.token !in tombstoned } + if (links.isEmpty()) return 0 + + // One REQ for every link's bundle rather than a round trip each. This runs inside the + // user-visible Refounding, and a serial fetch per link makes a removal take time linear in + // how many links the creator ever minted, each able to wait out its own idle timeout. + val byAuthor = links.associateBy { it.signerPubKeyHex().lowercase() } + val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.bundlesFilter(byAuthor.keys.toList())) }) + val wrapsByAuthor = wraps.groupBy { it.pubKey.lowercase() } + + return coroutineScope { + byAuthor + .map { (author, link) -> + async { + runCatching { + val token = link.token.hexToByteArray() + // Classify per coordinate, never over the pooled set: one link's newer + // revocation tombstone must not decide another link's status. + val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), token) as? InviteBundleStatus.Live ?: return@runCatching false + val moved = + current.invite.copy( + communityRoot = entry.root, + rootEpoch = entry.rootEpoch, + controlPk = entry.controlPk, + relays = entry.relays, + ) + // Confirmed: a link counted as moved but never stored is a link its + // holders can no longer redeem, reported as a success. + account.client.publishAndConfirm(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays) + }.onFailure { Log.w("Concord", "invite refresh failed for ${entry.id}", it) }.getOrDefault(false) + } + }.awaitAll() + .count { it } } - return count } /** @@ -393,10 +431,12 @@ class AccountConcordActions( val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } if (relays.isEmpty()) return false + // Confirmed, not fire-and-forget. A `publish` that returns Unit would report success for a + // tombstone no relay stored — and the list write below would then drop this entry on merge, + // destroying the only `signer_sk` that could ever retire the link while the link stays live. val published = runCatching { - account.client.publish(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays) - true + account.client.publishAndConfirm(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays) }.onFailure { Log.w("Concord", "invite revocation failed for $communityId", it) }.getOrDefault(false) if (!published) return false @@ -477,7 +517,15 @@ class AccountConcordActions( // other door into the same room. // // Fails CLOSED on an unreadable plane: the banlist is only knowable once the bundle yields - // the root, and no verdict means no join. + // the root, and no verdict means no join. Two things make that safe to insist on rather than + // a way to brick valid invites: + // + // - the plane is fetched over the SAME relays that just served the bundle, not the relay + // list inside the bundle alone, which can be stale (a moved relay, a link minted before a + // relay change) and would otherwise refuse a community we can plainly reach; + // - it is PAGED, because a single REQ is truncated at the relay's per-filter cap. A missing + // older ban edition fails the gate open — it re-admits the very account it exists to + // refuse — so the one direction we must not economise on is completeness. val joinKeys = ConcordActions.controlPlaneKeys( communityRoot = bundle.communityRoot.hexToByteArray(), @@ -485,12 +533,14 @@ class AccountConcordActions( rootEpoch = bundle.rootEpoch, controlPk = bundle.controlPk, ) - val joinRelays = bundle.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { relays } - val joinEditions = - ConcordActions.controlEditions( - account.client.fetchAll(filters = joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }), - joinKeys, - ) + // Union, not `ifEmpty`: the relays that served the bundle are known-good for this community, + // and the bundle's own list is the one that goes stale. + val joinRelays = bundle.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + relays + val planeWraps = mutableListOf() + account.client.fetchAllPagesFromPool( + filters = joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, + ) { event, _ -> planeWraps.add(event) } + val joinEditions = ConcordActions.controlEditions(planeWraps, joinKeys) if (joinEditions.isEmpty()) return ConcordInviteResult.NotReachable if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(account.signer.pubKey)) { return ConcordInviteResult.Banned @@ -1210,7 +1260,17 @@ class AccountConcordActions( // who has themselves been banned could still rotate the whole community. val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN) if (!authorized) continue - adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) + val adopted = adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) + + // Move our own links onto the epoch we just adopted. Rotating is not the only way to end + // up on a new epoch — being re-keyed is the common one — and a link creator who is merely + // re-keyed would otherwise leave every link they handed out pointing at the dead root, + // which is exactly the orphaning this branch exists to stop. Stranded recovery reads the + // bundle's epoch, so a link nobody re-mints is a member nobody can recover. + adopted?.let { next -> + val moved = refreshConcordInviteLinks(next) + if (moved > 0) Log.i("Concord") { "Rekey ${next.id}: refreshed $moved invite link(s) to epoch ${received.newEpoch}" } + } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 02b482bebf..185cbc7f6d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -582,12 +582,15 @@ class Context( diagnoseSlow: Boolean = false, deadOut: MutableMap? = null, pendingOnAuthRequired: Boolean = false, + /** Per-relay terminal reason, so a caller can tell an empty answer from no answer. */ + doneOut: MutableMap? = null, ): List> = client.fetchAllWithHooks( filters = filters, idleTimeoutMs = idleTimeoutMs, pendingOnAuthRequired = pendingOnAuthRequired, deadOut = deadOut, + doneOut = doneOut, onTimeout = if (diagnoseSlow) { { stalled, doneReasons, collected -> logSlowDrain(idleTimeoutMs, stalled, doneReasons, collected) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 77047c4e5b..51ad02fd7e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.anyRelayServed import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -724,13 +725,19 @@ object ConcordCommands { val relays = ctx.outboxRelays() if (relays.isEmpty()) return null val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(ctx.signer.pubKey)) + // Terminal reasons, not just events: a drain returns nothing both when a relay served us and + // had nothing AND when nobody answered. Reading the second as "no list yet" is how the + // read-merge-write below wipes the signer_sk of every link it failed to read. + val reasons = mutableMapOf() val newest = ctx - .drain(relays.associateWith { listOf(filter) }) - .map { it.second } + .drain(relays.associateWith { listOf(filter) }, doneOut = reasons) + // Filter by kind BEFORE picking the newest — a stray event at this coordinate would + // otherwise make the list read as unreadable and refuse every later write. + .mapNotNull { it.second as? ConcordInviteListEvent } .maxByOrNull { it.createdAt } - ?: return ConcordInviteListDocument.EMPTY // nothing published yet — safe to start one - return (newest as? ConcordInviteListEvent)?.decrypt(ctx.signer) + ?: return if (reasons.anyRelayServed()) ConcordInviteListDocument.EMPTY else null + return newest.decrypt(ctx.signer) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index dfb51935d7..e11a4278fa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -201,6 +201,16 @@ object ConcordActions { /** The public invite bundle for a link signer. */ fun bundleFilter(linkSignerPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = listOf(linkSignerPubKeyHex)) + /** + * The bundles of several links at once — one REQ over every link signer instead of a round trip + * per link, which is what a Refounding needs when it re-mints a creator's whole set. + * + * Partition the result by `pubKey` before classifying: [ConcordInviteBundle.classify] resolves a + * single coordinate, so handing it a pooled set would let one link's revocation tombstone decide + * another link's status purely by being newer. + */ + fun bundlesFilter(linkSignerPubKeyHexes: List): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = linkSignerPubKeyHexes) + /** Pending direct invites addressed to the given member (indexed by k=3313). */ fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString()))) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt index 5b930eb261..f136aed3fd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt @@ -30,9 +30,11 @@ import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject private val NoExtras: JsonObject = JsonObject(emptyMap()) @@ -77,11 +79,19 @@ class ConcordInviteListTombstone( val residue: JsonObject = NoExtras, ) -/** The decoded kind-13303 document: live [entries], [tombstones], and document-level [residue]. */ +/** + * The decoded kind-13303 document: live [entries], [tombstones], and document-level [residue]. + * + * [opaqueEntries] holds entries that did not type-check — a wrong-typed field from another client or + * a newer schema. They are carried verbatim rather than dropped (re-encoding without them would + * delete somebody's `signer_sk`) and rather than failing the whole read (which would refuse every + * future mint and revoke for this account until someone else repaired the list). + */ class ConcordInviteListDocument( val entries: List = emptyList(), val tombstones: List = emptyList(), val residue: JsonObject = NoExtras, + val opaqueEntries: List = emptyList(), ) { companion object { val EMPTY = ConcordInviteListDocument() @@ -160,41 +170,80 @@ object ConcordInviteList { private object WireDocumentSerializer : ExtrasPreserving(WireDocument.serializer()) /** - * Decodes the plaintext document, or **null** when it cannot be parsed. + * Decodes the plaintext document, or **null** when the document itself cannot be read. * * Null rather than an empty document on purpose: this list is replaceable, so a caller that * treats "I could not read it" as "it is empty" and republishes destroys every `signer_sk` it * did not manage to read — secrets that cannot be regenerated, orphaning every outstanding * invite at a dead epoch. Callers MUST distinguish the two (see [ConcordInviteList.merge]'s - * callers). Each field still defaults, so one odd entry does not abort the whole array. + * callers). + * + * Null is reserved for a *document-level* failure — not JSON, or `entries`/`tombstones` present + * but not arrays. A single entry that does not type-check is kept verbatim in + * [ConcordInviteListDocument.opaqueEntries] instead: failing the whole read for one odd row + * would refuse every future mint and revoke for the account, permanently, since a replaceable + * coordinate never ages out — turning the old silent data loss into a permanent write lock. */ fun decodeOrNull(json: String): ConcordInviteListDocument? = try { - val doc = ConcordJson.instance.decodeFromString(WireDocumentSerializer, json) - ConcordInviteListDocument( - entries = - doc.entries.map { + val root = ConcordJson.instance.parseToJsonElement(json).jsonObject + val opaque = mutableListOf() + + val entries = + (root["entries"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element -> + val obj = element.jsonObject + try { + val it = ConcordJson.instance.decodeFromJsonElement(WireEntrySerializer, obj) ConcordInviteListEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.extras) - }, - tombstones = doc.tombstones.map { ConcordInviteListTombstone(it.token, it.communityId, it.extras) }, - residue = doc.extras, + } catch (_: Exception) { + opaque.add(obj) + null + } + } + + val tombstones = + (root["tombstones"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element -> + try { + val it = ConcordJson.instance.decodeFromJsonElement(WireTombstoneSerializer, element.jsonObject) + ConcordInviteListTombstone(it.token, it.communityId, it.extras) + } catch (_: Exception) { + // A tombstone we cannot read must not silently un-retire its link, but we + // have no token to key it by, so it can only ride along as document residue. + null + } + } + + ConcordInviteListDocument( + entries = entries, + tombstones = tombstones, + residue = JsonObject(root - "entries" - "tombstones"), + opaqueEntries = opaque, ) } catch (_: Exception) { null } - fun encode(doc: ConcordInviteListDocument): String = - ConcordJson.instance.encodeToString( - WireDocumentSerializer, - WireDocument( - entries = - doc.entries.map { - WireEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.residue) - }, - tombstones = doc.tombstones.map { WireTombstone(it.token, it.communityId, it.residue) }, - extras = doc.residue, - ), - ) + fun encode(doc: ConcordInviteListDocument): String { + val wire = + ConcordJson.instance + .encodeToJsonElement( + WireDocumentSerializer, + WireDocument( + entries = + doc.entries.map { + WireEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.residue) + }, + tombstones = doc.tombstones.map { WireTombstone(it.token, it.communityId, it.residue) }, + extras = doc.residue, + ), + ).jsonObject + + // Entries we could not type ride back out untouched. Dropping them here is the data loss + // this whole class exists to prevent — they are somebody's link signer too. + if (doc.opaqueEntries.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire) + val entries = JsonArray((wire["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries) + return ConcordJson.instance.encodeToString(JsonObject.serializer(), JsonObject(wire + ("entries" to entries))) + } /** * Merges [patch] onto [base], keyed by `token` — the spec's own merge key. A token present in @@ -218,6 +267,9 @@ object ConcordInviteList { entries = entries.values.toList(), tombstones = tombstones.values.toList(), residue = JsonObject(base.residue + patch.residue), + // Untyped entries survive the merge for the same reason they survive a decode: we cannot + // read them, so we are in no position to decide they are disposable. + opaqueEntries = (base.opaqueEntries + patch.opaqueEntries).distinct(), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt index 62d13ce5e9..f28864dca0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt @@ -79,6 +79,14 @@ suspend fun INostrClient.fetchAllWithHooks( subscriptionId: String = newSubId(), pendingOnAuthRequired: Boolean = false, deadOut: MutableMap? = null, + /** + * Receives the terminal reason per relay ("eose", "closed:…", "cannot:…"), so a caller can + * tell "a relay served us and had nothing" from "nobody served us". An empty result alone + * cannot: both look like zero events, and treating the second as the first is how a + * read-merge-write on a replaceable event destroys the entries it failed to read. See + * [anyRelayServed]. + */ + doneOut: MutableMap? = null, onTimeout: ((stalled: Set, doneReasons: Map, collected: List>) -> Unit)? = null, /** * Hard wall-clock ceiling. The idle window alone is unbounded when a relay @@ -237,9 +245,22 @@ suspend fun INostrClient.fetchAllWithHooks( classifyDrainFailure(reason)?.let { out[relay] = it } } } + doneOut?.putAll(doneReasons) return collected } +/** The terminal reason recorded when a relay finished serving a subscription normally. */ +const val DONE_REASON_EOSE = "eose" + +/** + * True when at least one relay completed the fetch normally, i.e. answered and reached EOSE. + * + * Read against the map filled by `fetchAllWithHooks`'s `doneOut`. An empty event list means + * "nothing matched" only when this is true; otherwise it means "nobody told us", and a caller + * that overwrites a replaceable event on that basis deletes whatever it could not read. + */ +fun Map.anyRelayServed(): Boolean = values.any { it == DONE_REASON_EOSE } + /** * [fetchAllPagesFromPool] with a suspending per-event hook: paginates every relay * to completion (each on its own `until` cursor, up to [maxConcurrentRelays] at diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt index 7a3904bdbc..b3a6dbde5e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt @@ -123,6 +123,43 @@ class ConcordInviteListTest { assertEquals(null, ConcordInviteList.decodeOrNull("{\"entries\":\"wrong type\"}")) } + @Test + fun oneUnreadableEntryDoesNotFailTheWholeDocumentOrGetDropped() { + // One structurally incompatible entry — a newer schema turning a scalar into an object, the + // realistic version, since the lenient parser already coerces plain scalar mismatches — used + // to null the whole document. Because null now means "refuse to write", that turned a single + // odd row into a permanent lock on mint and revoke for the account: a replaceable coordinate + // never ages out, so nothing would ever clear it. + val mixed = + """ + { "entries": [ + { "token": "aa", "signer_sk": "bb", "community_id": "cc", "url": "u1" }, + { "token": {"v": "dd"}, "signer_sk": "dd", "community_id": "cc", "url": "u2", "mark": "keepme" } + ], + "tombstones": [] } + """.trimIndent() + + val doc = ConcordInviteList.decodeOrNull(mixed) + assertEquals(listOf("aa"), doc!!.entries.map { it.token }, "the readable entry still decodes") + assertEquals(1, doc.opaqueEntries.size, "the unreadable entry is kept, not discarded") + + // And it survives a re-encode: dropping it would delete somebody's signer_sk, which is the + // exact data loss this class exists to prevent. + assertTrue(ConcordInviteList.encode(doc).contains("keepme"), "unreadable entry lost on re-encode") + } + + @Test + fun aMergeCarriesUnreadableEntriesThrough() { + val base = ConcordInviteList.decodeOrNull("""{"entries":[{"token":{"v":7},"mark":"opaque"}],"tombstones":[]}""")!! + val patch = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t", "sk", "c", "u"))) + + val merged = ConcordInviteList.merge(base, patch) + + assertEquals(listOf("t"), merged.entries.map { it.token }) + // We cannot read it, so we are in no position to decide it is disposable. + assertTrue(ConcordInviteList.encode(merged).contains("opaque"), "merge dropped an unreadable entry") + } + @Test fun anUnreadableListIsDistinguishableFromAnEmptyOne() { // The whole point of the null: a caller must be able to tell "I could not read it" from From d3921cd7f547af9448ae3ddcd5570908e5aa53d3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Aug 2026 15:40:15 -0400 Subject: [PATCH 210/227] fix(ci): unbreak iOS compile and the arm64 desktop smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two red checks on this branch, from two unrelated causes. 1. `test-quartz-ios` — AddressableAuthorRelayLoaderSubAssembler moved into commonMain still calling `synchronized(lock)`. That resolves from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and only fails at `:commons:compileKotlinIosSimulatorArm64`. Swapped to `KmpLock.withLock {}` (reentrant on every platform, and `withLock` is inline so `commit()`'s early `return` still works). The `verifyKmpPurity` gate missed it because it only forbade the `kotlin.jvm.Synchronized` *annotation*, not the bare call. Added `synchronized(` to the forbidden list in both :commons and :quartz so the next one fails in seconds instead of at the iOS compile step. 2. `release-deb-launch (ubuntu-24.04-arm)` — pre-existing infra break, not from this branch (same failure on other PRs since ~Aug 5). libskiko-linux-arm64.so needs libEGL.so.1 and the runner has no libegl1, so the app died at startup. create-release.yml already fixes this via scripts/add-deb-libegl-dep.sh; the smoke test never adopted it. Added that step, and switched the install from `dpkg -i` (which does not resolve dependencies) to `apt-get install ./x.deb` so the declared libegl1 is actually pulled in — this now exercises the same artifact release ships. Verified: :commons:compileKotlinIosSimulatorArm64, both verifyKmpPurity gates (and confirmed the new pattern fails when the bug is reintroduced), :commons:jvmTest, :amethyst:testPlayDebugUnitTest for the assembler test, and the .deb libegl mechanism end-to-end in an arm64 ubuntu:24.04 container. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/smoke-test-desktop.yml | 20 +++++++++++++++++-- commons/build.gradle.kts | 4 ++++ ...ddressableAuthorRelayLoaderSubAssembler.kt | 11 ++++++---- quartz/build.gradle.kts | 4 ++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index 6d8159ab0e..57b992ae73 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -92,13 +92,29 @@ jobs: chmod +x scripts/relax-deb-libicu.sh scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb + # Mirrors the same step in create-release.yml so this job exercises the + # exact .deb release ships. libskiko-linux-arm64.so has libEGL.so.1 in + # DT_NEEDED and jpackage does not scan lib/app/ for Depends, so without + # this the arm64 app dies at startup with + # UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file + # See scripts/add-deb-libegl-dep.sh for the full rationale. + - name: Add libegl1 dep to arm64 .deb + run: | + set -euo pipefail + chmod +x scripts/add-deb-libegl-dep.sh + scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb + - name: Install .deb run: | + # Installed via apt (not `dpkg -i`) so the .deb's declared Depends are + # actually resolved — that is what pulls in libegl1 on the arm64 + # runner, which does not ship it preinstalled. + # # jpackage's post-install script runs xdg-desktop-menu which fails # on CI runners ("No writable system menu directory"). The files are # extracted successfully; only the menu registration fails. Allow the - # dpkg error, then verify the binary was actually installed. - sudo dpkg -i desktopApp/build/compose/binaries/main-release/deb/*.deb || true + # install error, then verify the binary was actually installed. + sudo apt-get install -y ./desktopApp/build/compose/binaries/main-release/deb/*.deb || true echo "Installed files:" dpkg -L amethyst | head -30 # Fail if the binary wasn't actually extracted diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 2fccbea2ce..952b39c596 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -297,6 +297,10 @@ val verifyKmpPurity by tasks.registering { "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", "java.util.UUID" to "use kotlin.uuid.Uuid", "kotlin.jvm.Synchronized" to "use KmpLock.withLock {}", + // The bare call, not just the annotation: `synchronized(lock) {}` resolves + // from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and + // only fails at the iOS compile step. Catch it here instead. + "synchronized(" to "`synchronized` is JVM-only — use KmpLock.withLock {}", "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", ) val offenders = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt index 68e5a40544..46dbae7ac7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/loaders/AddressableAuthorRelayLoaderSubAssembler.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryStat import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState import com.vitorpamplona.amethyst.commons.service.BundledUpdate +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -46,8 +48,9 @@ class AddressableAuthorRelayLoaderSubAssembler( val userFinder: UserFinderFilterAssembler, ) : IEoseManager { // Private monitor: @Synchronized locks on `this`, which leaves the instance's monitor - // reachable to anything holding a reference to this assembler. - private val lock = Any() + // reachable to anything holding a reference to this assembler. KmpLock (not `synchronized`) + // because this file lives in commonMain and must compile for the iOS targets too. + private val lock = KmpLock() // Only ever touched while holding [lock]. See commit() and destroy(). private var activeSubscriptions: Set = emptySet() @@ -92,7 +95,7 @@ class AddressableAuthorRelayLoaderSubAssembler( * never call back into this class. Revisit if that changes. */ private fun commit(needed: Set) { - synchronized(lock) { + lock.withLock { if (destroyed) return userFinder.subscribe((needed - activeSubscriptions).toList()) @@ -103,7 +106,7 @@ class AddressableAuthorRelayLoaderSubAssembler( } override fun destroy() { - synchronized(lock) { + lock.withLock { destroyed = true bundler.cancel() userFinder.unsubscribe(activeSubscriptions.toList()) diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 8a93c5758d..4771e49055 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -424,6 +424,10 @@ val verifyKmpPurity by tasks.registering { "Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual", "java.util.UUID" to "use kotlin.uuid.Uuid", "kotlin.jvm.Synchronized" to "use a KMP lock primitive", + // The bare call, not just the annotation: `synchronized(lock) {}` resolves + // from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and + // only fails at the iOS compile step. Catch it here instead. + "synchronized(" to "`synchronized` is JVM-only — use a KMP lock primitive", "kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile", ) val offenders = From 704198099695e03cbe9240e0ca92e402ce85178e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Aug 2026 21:08:05 +0000 Subject: [PATCH 211/227] negentropy: let a caller decline an id before the download REQ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit negentropySync names every id the relay has that the caller lacks, then fetches all of them. There is no point between those two steps where a caller can say "not that one" — onEvent is the first hook, and by then the body has already crossed the wire. That is a real cost for a mirror. A store keeping only the newest version of a replaceable event refuses every relay's older copy, and negentropy re-offers those copies on every sync because they are genuinely absent from the local id set. Measured on a downstream mirror against relay.damus.io, nos.lol and relay.primal.net: three passes over kinds 0/3/10002 produced 5, 18 and 29 REPLACED rejections, the same events re-downloaded each time. Adds an optional `wantId: ((HexKey) -> Boolean)?` to negentropySync, negentropySyncOrFetch and negentropySyncFanOut, consulted for each id before the REQ, plus a `skipped` count on the three result types. Default null keeps every existing call byte-identical. Three decisions worth stating: - The gate runs on a whole reconcile round's ids, BEFORE they are chunked into fetch batches. Gating after the chunking keeps the batch count and shrinks every batch instead — at the density this exists for, a fetchBatch of 500 becomes a hundred REQs of five ids each, turning a bandwidth saving into a latency regression. - `skipped` is reported apart from both `downloaded` and `needCount`. needCount stays the honest protocol diff whether or not the caller fetched it, and without a separate number an operator cannot tell a predicate that does nothing from one that eats everything — both are silent. - keep() returns an empty list, never null. A nullable return invites `gate?.keep(ids) ?: ids`, which reads as "no gate, keep everything" and means "everything was declined, so send everything". That elvis turned the fully-declining case into a full download during development; the non-null contract removes the trap rather than documenting it. The gate does not cover a window handed to onUnreconcilableWindow: that is drained over REQ, which names no ids before streaming bodies. Documented on both the base function and the combinator. --- .../accessories/NegentropyFanOutResult.kt | 7 + .../NostrClientNegentropyFanOutExt.kt | 10 ++ .../NostrClientNegentropySyncExt.kt | 114 ++++++++++++++- .../relay/client/accessories/NeedGateTest.kt | 137 ++++++++++++++++++ .../relay/NostrClientNegentropySyncTest.kt | 117 +++++++++++++++ 5 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NeedGateTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyFanOutResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyFanOutResult.kt index 96392ace8a..a7cbb1678f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyFanOutResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyFanOutResult.kt @@ -36,4 +36,11 @@ class NegentropyFanOutResult( val downloaded: Int, val windows: Int, val connections: Int, + /** + * Ids `wantId` declined, so no `REQ` was ever issued for them. `0` when no + * predicate was passed. Same accounting as + * [NegentropySyncResult.skipped]: apart from [downloaded], and never folded + * into [needCount], which stays the honest protocol diff. + */ + val skipped: Int = 0, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt index e941af4417..35583fe02c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyFanOutExt.kt @@ -84,6 +84,13 @@ suspend fun negentropySyncFanOut( fetchBatch: Int = 250, idleTimeoutMs: Long = 120_000L, reconcileConcurrency: Int = 2, + /** + * Same contract as [negentropySync]'s: consulted for every id the reconcile + * names, before the `REQ` that would fetch it. Declined ids are counted in + * [NegentropyFanOutResult.skipped]. Called from several reconciler + * coroutines at once, so it must be cheap and thread-safe. + */ + wantId: ((HexKey) -> Boolean)? = null, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropyFanOutResult { @@ -91,6 +98,7 @@ suspend fun negentropySyncFanOut( val need = AtomicInt(0) val have = AtomicInt(0) + val skipped = AtomicInt(0) val windows = AtomicInt(0) val used = AtomicInt(0) var downloaded = 0 @@ -155,6 +163,7 @@ suspend fun negentropySyncFanOut( need.addAndFetch(it) }, onHave = { have.addAndFetch(it) }, + gate = NeedGate(wantId) { skipped.addAndFetch(it) }, sendNeedBatch = { batch -> idBatches.send(batch) }, sendHaveBatch = if (localEntries.isEmpty() && localIndex == null) null else { _ -> }, ) @@ -192,6 +201,7 @@ suspend fun negentropySyncFanOut( downloaded = downloaded, windows = windows.load(), connections = used.load(), + skipped = skipped.load(), ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt index 51941e1856..570f56c147 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt @@ -77,6 +77,18 @@ class NegentropySyncResult( val downloaded: Int, val windows: Int, val peerCap: Long? = null, + /** + * Ids the reconcile named that `wantId` declined, so no `REQ` was ever + * issued for them. Always `0` when no predicate was passed. + * + * Reported apart from [downloaded] and from [needCount] on purpose: + * [needCount] stays the honest protocol diff (what the relay has that the + * local set lacks) whether or not the caller chose to fetch it, and a + * skipped id was never a download. Folding either way would leave a caller + * unable to tell "my predicate is doing nothing" from "my predicate is + * eating everything" — and both are silent. + */ + val skipped: Int = 0, ) /** @@ -151,6 +163,26 @@ class NegentropySyncResult( * `limitation.max_subscriptions`; e.g. strfry defaults to 20) and exceeding the * cap can wedge the connection, not just fail the extra REQ — size the two knobs * to fit the target relay. + * @param wantId optional gate consulted for every id the reconcile names, + * BEFORE the `REQ` that would download it. Return `false` and the id is dropped + * from the fetch queue and counted in [NegentropySyncResult.skipped]; the + * reconcile itself is untouched, so [NegentropySyncResult.needCount] still + * reports the true diff. Called from the reconciler coroutines (possibly + * several at once when `reconcileConcurrency > 1`), so it must be cheap and + * thread-safe — a membership test, not a query. + * + * The case this exists for: a caller whose store will refuse an id no matter + * how often it arrives. A mirror that keeps only the newest version of a + * replaceable event is offered every relay's older copy on every sync, and + * without a hook here the only place to decline is after the body is already + * on the wire. `onEvent` is too late to save the bytes. + * + * **It does not cover a window handed to [onUnreconcilableWindow].** That + * window is drained by the caller over `REQ`, and a `REQ` names no ids before + * it streams bodies, so declined events in such a window arrive anyway and are + * not counted in [NegentropySyncResult.skipped]. Rare — it takes a single + * second denser than the relay's cap — but a caller treating the gate as an + * absolute bound on what it can receive would be wrong. * @param onProgress optional `(needSoFar, downloaded)` ticks as work proceeds. * @param onEvent called once per distinct event, serially, from the single * delivery consumer coroutine (not the relay reader thread) — so it never overlaps @@ -170,10 +202,12 @@ suspend fun INostrClient.negentropySync( localIndex: NegentropyLocalIndex? = null, targetWindow: Int = 0, onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null, + wantId: ((HexKey) -> Boolean)? = null, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropySyncResult { val need = AtomicInt(0) + val skipped = AtomicInt(0) val windows = AtomicInt(0) var downloaded = 0 var peerCap: Long? = null @@ -213,6 +247,8 @@ suspend fun INostrClient.negentropySync( // single consumer loop below so the user callback is never // invoked from two coroutines at once. onNeed = { need.addAndFetch(it) }, + onSkipped = { skipped.addAndFetch(it) }, + wantId = wantId, deliver = { events.send(it) }, ) } finally { @@ -241,6 +277,7 @@ suspend fun INostrClient.negentropySync( downloaded = downloaded, windows = windows.load(), peerCap = peerCap, + skipped = skipped.load(), ) } @@ -257,6 +294,7 @@ suspend fun INostrClient.negentropySync( localIndex: NegentropyLocalIndex? = null, targetWindow: Int = 0, onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null, + wantId: ((HexKey) -> Boolean)? = null, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropySyncResult = @@ -273,6 +311,7 @@ suspend fun INostrClient.negentropySync( localIndex = localIndex, targetWindow = targetWindow, onUnreconcilableWindow = onUnreconcilableWindow, + wantId = wantId, onProgress = onProgress, onEvent = onEvent, ) @@ -342,6 +381,14 @@ suspend fun INostrClient.negentropySyncOrFetch( localEntries: List = emptyList(), localIndex: NegentropyLocalIndex? = null, targetWindow: Int = 0, + /** + * Passed straight to [negentropySync]. Note it does NOT apply to the paged + * fallback: a `REQ` names no ids before it streams bodies, so there is + * nothing to gate there. A caller relying on this to bound its downloads + * should read [NegentropyOrFetchResult.pagedFallback] and expect the + * suppressed ids to arrive after all when a window pages. + */ + wantId: ((HexKey) -> Boolean)? = null, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropyOrFetchResult { @@ -397,6 +444,7 @@ suspend fun INostrClient.negentropySyncOrFetch( if (accept(event)) onProgress?.invoke(delivered, delivered) } }, + wantId = wantId, onProgress = onProgress, ) { accept(it) } NegentropyOrFetchResult( @@ -440,6 +488,7 @@ suspend fun INostrClient.negentropySyncOrFetch( localEntries: List = emptyList(), localIndex: NegentropyLocalIndex? = null, targetWindow: Int = 0, + wantId: ((HexKey) -> Boolean)? = null, onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null, onEvent: suspend (Event) -> Unit, ): NegentropyOrFetchResult = @@ -455,10 +504,53 @@ suspend fun INostrClient.negentropySyncOrFetch( localEntries = localEntries, localIndex = localIndex, targetWindow = targetWindow, + wantId = wantId, onProgress = onProgress, onEvent = onEvent, ) +/** + * Decides which of the ids a reconcile named are actually worth a `REQ`. + * + * Small and separate because it is the only place in the download path where + * an id can still be declined for free, and because the three things it does + * are each easy to get wrong in a lambda nobody can call from a test: apply + * the predicate, count what was dropped, and refuse to emit an empty batch. + * + * [keep] runs on the reconciler coroutines, so with `reconcileConcurrency > 1` + * it is called concurrently — hence the counting goes through [onSkipped], + * which the caller makes atomic, rather than a field here. + * + * **It is applied to a whole reconcile round's ids, before they are chunked + * into fetch batches.** Gating after the chunking instead would keep the batch + * COUNT and shrink every one of them: at the density this exists for (a mirror + * declining most of what it is offered) a `fetchBatch` of 500 would become a + * hundred `REQ`s of five ids apiece, each with its own EOSE round trip — + * turning a bandwidth saving into a latency regression. + */ +internal class NeedGate( + private val wantId: ((HexKey) -> Boolean)?, + private val onSkipped: (Int) -> Unit, +) { + /** + * The subset of [batch] to download. Empty when everything was declined — + * deliberately NOT null: the caller's chunk loop already does nothing with + * an empty list, and a nullable return here invites + * `gate?.keep(ids) ?: ids`, which reads as "no gate, keep everything" and + * silently means "everything was declined, so send everything". That exact + * elvis collapsed the fully-declining case into a full download once. + * + * With no predicate this returns [batch] itself — the unfiltered sync is + * the common case and must not pay a copy for a feature it is not using. + */ + fun keep(batch: List): List { + val wanted = if (wantId == null) batch else batch.filter(wantId) + val dropped = batch.size - wanted.size + if (dropped > 0) onSkipped(dropped) + return wanted + } +} + /** * The whole-sync pipeline: a single pool of [maxConcurrentReqs] download workers * fed by up to [reconcileConcurrency] concurrent window reconciliations through a @@ -494,6 +586,8 @@ private suspend fun INostrClient.syncPipeline( targetWindow: Int, onWindow: () -> Unit, onNeed: (Int) -> Unit, + onSkipped: (Int) -> Unit, + wantId: ((HexKey) -> Boolean)?, onPeerCap: ((Long) -> Unit)?, onUnreconcilableWindow: (suspend (Filter) -> Unit)?, deliver: suspend (Event) -> Unit, @@ -526,6 +620,9 @@ private suspend fun INostrClient.syncPipeline( onHave = {}, onPeerCap = onPeerCap, onUnreconcilableWindow = onUnreconcilableWindow, + // The gate is applied inside the reconcile, before these batches are + // cut — see NeedGate — so by here every id is one we want. + gate = NeedGate(wantId, onSkipped), sendNeedBatch = { batch -> idBatches.send(batch) }, sendHaveBatch = null, ) @@ -583,6 +680,9 @@ internal suspend fun reconcileWindows( // that hit the window, so a slow drain holds that reconciler — with // reconcileConcurrency = 1 the rest of the sweep waits for it. onUnreconcilableWindow: (suspend (Filter) -> Unit)? = null, + // Applied to each round's need ids before they are chunked. Null for the + // callers that hand the ids straight to their own consumer. + gate: NeedGate? = null, sendNeedBatch: suspend (List) -> Unit, sendHaveBatch: (suspend (List) -> Unit)?, ) = coroutineScope { @@ -703,6 +803,7 @@ internal suspend fun reconcileWindows( fetchBatch = batchSize, onNeed = onNeed, onHave = onHave, + gate = gate, sendNeedBatch = sendNeedBatch, sendHaveBatch = sendHaveBatch, ) @@ -1032,6 +1133,7 @@ private suspend fun INostrClient.reconcileStreaming( fetchBatch: Int, onNeed: (Int) -> Unit, onHave: (Int) -> Unit, + gate: NeedGate? = null, sendNeedBatch: suspend (List) -> Unit, sendHaveBatch: (suspend (List) -> Unit)?, ): ReconcileOutcome { @@ -1170,13 +1272,19 @@ private suspend fun INostrClient.reconcileStreaming( val result = session.processMessage(frame.payload) val needIds = result.needIds if (needIds.isNotEmpty()) { + // Counted before the gate: this is the protocol diff, and + // it is true whether or not the caller wants to fetch it. onNeed(needIds.size) + // Gated before the chunking, so a selective predicate + // yields FEWER full batches rather than the same number + // of nearly-empty ones — see NeedGate. + val wanted = if (gate == null) needIds else gate.keep(needIds) var i = 0 - while (i < needIds.size) { - val end = min(i + fetchBatch, needIds.size) + while (i < wanted.size) { + val end = min(i + fetchBatch, wanted.size) // Copy each batch so the frame's full id list can be freed // as soon as it is chunked; suspends under back-pressure. - sendNeedBatch(ArrayList(needIds.subList(i, end))) + sendNeedBatch(ArrayList(wanted.subList(i, end))) i = end } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NeedGateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NeedGateTest.kt new file mode 100644 index 0000000000..f404e96887 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NeedGateTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * The one point on the download path where an id can still be declined for + * free. Everything asserted here is a property the inline version of this code + * could break silently: a sync with no predicate must not start allocating, a + * fully-declined batch must not become an empty REQ, and the skip count must + * stay separate from the reconcile's own diff so an operator can tell a + * predicate that does nothing from one that eats everything. + */ +class NeedGateTest { + private fun id(n: Int): HexKey = n.toString().padStart(64, '0') + + private fun batch(range: IntRange) = range.map(::id) + + private class Skips { + var total = 0 + var calls = 0 + + val sink: (Int) -> Unit = { + total += it + calls++ + } + } + + @Test + fun `with no predicate the batch passes through untouched and uncopied`() { + // The unfiltered sync is the common case and must not pay a copy per + // batch for a feature it is not using. + val skips = Skips() + val input = batch(1..500) + val kept = NeedGate(null, skips.sink).keep(input) + + assertSame(input, kept, "an unfiltered batch must be the same list instance, not a copy") + assertEquals(0, skips.total) + assertEquals(0, skips.calls, "nothing was dropped so the counter should not even be touched") + } + + @Test + fun `a predicate keeps its subset in order`() { + val skips = Skips() + val wanted = setOf(id(2), id(4), id(6)) + val kept = NeedGate({ it in wanted }, skips.sink).keep(batch(1..6)) + + assertEquals(listOf(id(2), id(4), id(6)), kept) + assertEquals(3, skips.total) + } + + @Test + fun `a fully declined batch yields an empty list and never null`() { + // Non-null on purpose. A nullable return invites + // `gate?.keep(ids) ?: ids`, which reads as "no gate, keep everything" + // and silently means "everything declined, so send everything" — a + // fully-declining gate turning into a full download. + val skips = Skips() + val kept = NeedGate({ false }, skips.sink).keep(batch(1..10)) + + assertTrue(kept.isEmpty(), "nothing survived, so there is nothing to send") + assertEquals(10, skips.total) + } + + @Test + fun `an empty input yields empty and counts nothing`() { + val skips = Skips() + assertTrue(NeedGate({ true }, skips.sink).keep(emptyList()).isEmpty()) + assertEquals(0, skips.total) + } + + @Test + fun `a predicate that accepts everything drops nothing`() { + val skips = Skips() + val kept = NeedGate({ true }, skips.sink).keep(batch(1..20)) + + assertEquals(20, kept.size) + assertEquals(0, skips.total) + assertEquals(0, skips.calls) + } + + @Test + fun `skips accumulate across batches`() { + // One gate spans a whole sync, so the count has to survive more than + // the batch it was produced in. + val skips = Skips() + val gate = NeedGate({ it.endsWith("1") }, skips.sink) + gate.keep(batch(1..10)) + gate.keep(batch(11..20)) + + assertEquals(18, skips.total, "only ids 1 and 11 of the twenty end in 1") + } + + @Test + fun `the predicate sees every id in the batch exactly once`() { + val seen = mutableListOf() + NeedGate({ + seen.add(it) + true + }, Skips().sink).keep(batch(1..5)) + + assertEquals(batch(1..5), seen) + } + + @Test + fun `a sync result reports no skips unless a predicate declined something`() { + // Back-compat: every existing caller constructs this without the new + // field and must keep reading zero. + val result = NegentropySyncResult(needCount = 7, haveCount = 0, downloaded = 7, windows = 1) + + assertEquals(0, result.skipped) + assertTrue(result.needCount == 7, "the reconcile diff is unaffected by the gate") + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt index 6f4b121146..4524099b2c 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt @@ -360,6 +360,123 @@ class NostrClientNegentropySyncTest : RelayClientTest() { } } + /** + * `wantId` declines ids BEFORE the download REQ, so the events never cross + * the wire — the point of putting the hook here rather than at `onEvent`. + * + * The assertion that matters is the one on the recorded `REQ` filters: a + * gate that merely dropped events after delivery would satisfy every other + * check in this test while saving nothing. + */ + @Test + fun wantIdSkipsIdsBeforeTheyAreEverRequested() = + runBlocking { + // Every id the relay was actually asked for, straight off the wire. + val requested = java.util.Collections.synchronizedList(mutableListOf()) + val recording = + object : PassThroughPolicy() { + override fun accept(cmd: ReqCmd): PolicyResult { + cmd.filters.forEach { f -> f.ids?.let { requested.addAll(it) } } + return PolicyResult.Accepted(cmd) + } + } + + val hub = InProcessRelays(defaultPolicy = { recording }) + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(hub, scope) + try { + val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7791/") + val events = (1..20).map { SyntheticEvents.fakeEvent(idSeed = it, kind = 1) } + hub.getOrCreate(url).preload(events) + + // Decline the first ten by id. + val unwanted = events.take(10).map { it.id }.toSet() + + val got = mutableListOf() + val result = + withTimeout(30_000) { + client.negentropySync( + relay = url, + filter = Filter(kinds = listOf(1)), + idleTimeoutMs = 10_000L, + wantId = { it !in unwanted }, + ) { got.add(it) } + } + + assertEquals(10, got.size, "only the wanted half is delivered") + assertTrue(got.none { it.id in unwanted }, "no declined event was delivered") + assertEquals(10, result.downloaded) + assertEquals(10, result.skipped, "the declined ids are reported apart from the download count") + assertEquals( + 20, + result.needCount, + "needCount stays the honest protocol diff — the relay really did have all 20 that we lacked", + ) + + // The whole point: the declined ids were never asked for. + assertTrue( + requested.none { it in unwanted }, + "a declined id must never reach a REQ; requested = ${requested.filter { it in unwanted }}", + ) + // Every wanted id WAS asked for — so the gate declined the right + // half rather than simply starving the download. Asserted as a + // subset rather than a count because negentropySync also opens a + // keep-alive subscription carrying a sentinel id. + assertTrue( + requested.containsAll(events.drop(10).map { it.id }), + "every wanted id should still have been requested", + ) + } finally { + client.disconnect() + scope.cancel() + hub.close() + } + } + + /** With no `wantId` nothing changes: every id is fetched and `skipped` is 0. */ + @Test + fun withoutWantIdEveryIdIsStillRequested() = + runBlocking { + defaultRelay.preload(SyntheticEvents.batch(12, kind = 1)) + + val got = mutableListOf() + val result = + withTimeout(20_000) { + client.negentropySync( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + ) { got.add(it) } + } + + assertEquals(12, got.size) + assertEquals(0, result.skipped, "no predicate means nothing is ever skipped") + } + + /** + * A gate that declines everything must finish cleanly rather than hang: the + * empty batches are dropped instead of being queued as REQs for no ids. + */ + @Test + fun wantIdDecliningEverythingDownloadsNothingAndStillCompletes() = + runBlocking { + defaultRelay.preload(SyntheticEvents.batch(15, kind = 1)) + + val got = mutableListOf() + val result = + withTimeout(20_000) { + client.negentropySync( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + wantId = { false }, + ) { got.add(it) } + } + + assertTrue(got.isEmpty(), "nothing was wanted, so nothing is delivered") + assertEquals(0, result.downloaded) + assertEquals(15, result.skipped) + assertEquals(15, result.needCount, "the reconcile still saw the full diff") + } + /** * On a relay that reconciles fine, [negentropySyncOrFetch] uses negentropy and * does not page. From 9ede348915062c5a7434b6a0af8ce0bf9eb48b7a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Aug 2026 17:57:12 -0400 Subject: [PATCH 212/227] fix(relays): seed search/indexer relay flows with the defaults, not empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both SearchRelayListState.flow and IndexerRelayListState.flow are supposed to never be empty: normalizeXxxWithBackup() substitutes the curated default set both when the account has no kind:10007 / kind:10086 and when the one it has decodes to zero relays (`?.ifEmpty { null } ?: Default…`). Every REQ assembler that reads `.flow` relies on that. The `stateIn` seed was the one value contradicting it. `flowOn(Dispatchers.IO)` means the first real emission can never be synchronous with `stateIn`, so `.value` was `emptySet()` until the collector ran — and for a NIP-46 signer whose list carries private entries that wait is a remote decrypt round trip, so the window is unbounded rather than sub-millisecond. A reader landing in that window queries nothing at all. AmethystAppFunctions (the system-assistant entry point, invoked cold) is the realistic case — it does `if (relays.isEmpty()) return SearchProfilesResult.empty()` directly under a comment asserting the flow "already resolves to a concrete relay set … or the curated default set", which is exactly the invariant that did not hold yet. Seeding with the defaults makes "never empty" true for the whole lifetime of the flow. Only `.flow` changes; `.flowNoDefaults` still seeds empty, so the editing and diffing paths (SearchRelayListViewModel, IndexerRelayListViewModel, AddInboxRelayForSearchCard, TrustedRelayListsState, Account.saveRelayList's diff) still see what the user actually configured. Consequence, and the intended trade: a reader in that window now queries the default relays instead of silently querying none. Co-Authored-By: Claude Opus 5 (1M context) --- .../indexerRelays/IndexerRelayListState.kt | 13 ++++++++++++- .../searchRelays/SearchRelayListState.kt | 15 ++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt index 40f30c1dbb..fcc0647212 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt @@ -62,6 +62,17 @@ class IndexerRelayListState( suspend fun normalizeIndexerRelayListWithBackupNoDefaults(note: Note): Set = indexListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() + /** + * The account's indexer relays, **never empty** — [normalizeIndexerRelayListWithBackup] + * substitutes [DefaultIndexerRelayList] both when there is no kind:10086 and when the + * one we have decodes to zero relays. Callers assembling metadata / relay-list REQs read + * this and can rely on getting a usable set; use [flowNoDefaults] instead to show or diff + * what the user actually configured. + * + * Seeded with [DefaultIndexerRelayList] rather than `emptySet()`, for the same reason as + * the search list: `flowOn(IO)` makes the first real emission asynchronous, so an + * `emptySet()` seed left a window where `.value` contradicted the contract above. + */ val flow = getIndexerRelayListFlow() .map { normalizeIndexerRelayListWithBackup(it.note) } @@ -70,7 +81,7 @@ class IndexerRelayListState( .stateIn( scope, SharingStarted.Eagerly, - emptySet(), + DefaultIndexerRelayList, ) val flowNoDefaults = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt index 3a227ef306..4d690210e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt @@ -62,6 +62,19 @@ class SearchRelayListState( suspend fun normalizeSearchRelayListWithBackupNoDefaults(note: Note): Set = searchListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() + /** + * The account's search relays, **never empty** — [normalizeSearchRelayListWithBackup] + * substitutes [DefaultSearchRelayList] both when there is no kind:10007 and when the + * one we have decodes to zero relays. Callers assembling NIP-50 REQs read this and can + * rely on getting a usable set; use [flowNoDefaults] instead to show or diff what the + * user actually configured. + * + * Seeded with [DefaultSearchRelayList] rather than `emptySet()`: `flowOn(IO)` means the + * first real emission can never be synchronous with `stateIn`, so an `emptySet()` seed + * left a window where `.value` contradicted the "never empty" contract above and search + * silently queried nothing. That window is unbounded for a NIP-46 signer whose list has + * private entries, since the first emission waits on a remote decrypt. + */ val flow = getSearchRelayListFlow() .map { normalizeSearchRelayListWithBackup(it.note) } @@ -70,7 +83,7 @@ class SearchRelayListState( .stateIn( scope, SharingStarted.Eagerly, - emptySet(), + DefaultSearchRelayList, ) val flowNoDefaults = From 422ae2d2d6f7bfd2da8a5d559b471a39afdb26e6 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:24:27 +0000 Subject: [PATCH 213/227] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-cs/strings.xml | 2 ++ amethyst/src/main/res/values-de-rDE/strings.xml | 16 ++++++++++++++++ amethyst/src/main/res/values-pt-rBR/strings.xml | 16 ++++++++++++++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 2 ++ docs/changelog/translators.json | 2 ++ 5 files changed, 38 insertions(+) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 2fce7e78c2..358ff5201b 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -3977,6 +3977,8 @@ Sdílet s vývojáři Pokud se zdá, že Amethyst spotřebovává baterii nebo data, můžete tento report odeslat vývojářům v šifrované DM. Obsahuje pouze čísla na této obrazovce a technická počítadla za nimi — žádné příspěvky, kontakty ani podrobnosti o prohlížení. Nic se neodešle, dokud v obrazovce zprávy neklepnete na Odeslat. Odeslat report přes DM + Kopírovat + Sdílet Zjištěno vysoké využití prostředků Amethyst nedávno spotřeboval více, než se očekávalo: %1$s. Chcete vývojářům odeslat report o využití v šifrované DM? Před odesláním čehokoli uvidíte celý report. %1$s mobilních dat na pozadí za jeden den diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index b8f2041354..d97a88f44c 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1949,6 +1949,7 @@
Stöbern Medien + Hashtags Themen Unterhaltung Suche @@ -1979,8 +1980,10 @@ Standortbasierte Räume für die Gebiete, denen du folgst, abgefragt bei den Relays, die sie führen. Chat und Zap-Ziele, die an Live-Streams hängen, die du geöffnet hast oder denen du folgst. DM-Posteingang + Wallet Nutzap-Posteingang Mint-Verzeichnis + Wallet Connect Community-Chats Community-Feeds + Backlog Workflow-Läufe Agentenarbeit Neuer Lauf @@ -4646,6 +4659,7 @@ Benötigt deine Genehmigung Wartet auf Genehmigung (keine Beschreibung) + Workflow: %1$s von wartet auf Du bist der Genehmigende, aber diese Anmeldung kann keine Entscheidung signieren. @@ -4681,11 +4695,13 @@ Noch keine Workflows. Öffne das Menü oben und wähle „Neue Definition…“, um einen zu erstellen, und löse ihn dann aus. Was soll er tun? Lauf auslösen + Workflow Noch keine Workflows definiert Workflow auswählen Neue Definition… Neue Workflow-Definition Benennt ihn für den Kanal und veröffentlicht sein YAML-Rezept (kind-30620). Ein echtes Buzz-Relay führt das YAML aus. Selbst gehostet führt der Runner seinen konfigurierten Befehl aus — hier benennt und katalogisiert die Definition den Lauf nur. + Name build-und-test YAML-Rezept Die Definition konnte nicht veröffentlicht werden — prüfe, ob du in diesem Workspace posten kannst. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 1ceae75a2e..a7309a0c0e 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1941,8 +1941,13 @@ + + %1$s \u00b7 %2$d relé + %1$s \u00b7 %2$d relés + Navegação Mídia + Hashtags Tópicos Conversa Pesquisa @@ -1976,6 +1981,7 @@ Carteira Caixa de entrada de nutzaps Diretório de mints + Wallet Connect Chats de comunidades Feeds de comunidades + Backlog Execuções de fluxo de trabalho Trabalho do agente Nova execução diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 59ec28bf1d..bbef7915c4 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -3839,6 +3839,8 @@ Dela med utvecklarna Om Amethyst verkar dra batteri eller data kan du skicka den här rapporten till utvecklarna i ett krypterat DM. Den innehåller bara siffrorna på den här skärmen och de tekniska räknarna bakom dem — inga inlägg, kontakter eller surfdetaljer. Ingenting skickas förrän du trycker på Skicka i meddelandeskärmen. Skicka rapport via DM + Kopiera + Dela Hög resursanvändning upptäckt Amethyst förbrukade mer än väntat nyligen: %1$s. Vill du skicka en användningsrapport till utvecklarna i ett krypterat DM? Du får se hela rapporten innan något skickas. %1$s mobildata i bakgrunden på en dag diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 0697190820..6364d560b3 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -121,6 +121,8 @@ "user": "davotoula", "languages": [ "Czech", + "German", + "Portuguese, Brazilian", "Swedish" ] }, From 59b36592f344dc9fc4067cca5a3c0a26d61fe49b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Aug 2026 18:29:02 -0400 Subject: [PATCH 214/227] fix(relays): seed relay flows from precached tags, not just the defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the previous commit, which seeded SearchRelayListState.flow and IndexerRelayListState.flow with the curated default sets so `.value` is never empty before the first async emission. Seeding with the defaults fixed the "queries nothing" hole but was blunt: an account with its own relays would briefly advertise the defaults instead. Seed from the precached resolution instead: searchListEvent(note)?.let { decryptionCache.cachedRelays(it) } ?.ifEmpty { null } ?: DefaultSearchRelayList `cachedRelays` is the non-suspending sibling of `relays` — public tags plus any already-decrypted private tags, and it never asks the signer, so it cannot block or trigger a NIP-46 round trip from a property initializer. At login the note's event is usually still null, so this resolves through settings.backupXxxRelayList (restored from LocalPreferences) and an account with public relays gets its own relays immediately. Only accounts whose relays are exclusively private still see the defaults for the window, and they get a working set rather than nothing. Same shape as the suspend normalizer, so all three arms still hold: no list → defaults, list with zero relays → defaults, list with relays → those relays. PrecachedRelayListSeedTest pins the property the refinement depends on — that public relays are readable with no signer involvement — plus the empty-list arm that hands over to the defaults, and a foreign-author read. Co-Authored-By: Claude Opus 5 (1M context) --- .../indexerRelays/IndexerRelayListState.kt | 17 +++- .../searchRelays/SearchRelayListState.kt | 18 +++- .../nip51Lists/PrecachedRelayListSeedTest.kt | 91 +++++++++++++++++++ 3 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/PrecachedRelayListSeedTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt index fcc0647212..1491bee591 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt @@ -62,6 +62,17 @@ class IndexerRelayListState( suspend fun normalizeIndexerRelayListWithBackupNoDefaults(note: Note): Set = indexListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() + /** + * Same resolution as [normalizeIndexerRelayListWithBackup] but non-suspending, for use as the + * [flow] seed. Reads the event's public tags plus any *already decrypted* private tags; it + * never asks the signer, so it cannot block or hit a NIP-46 round trip. + * + * At login `indexerListNote.event` is usually still null and this resolves through + * `settings.backupIndexRelayList`, restored from LocalPreferences — so an account with public + * indexer relays gets its own relays immediately instead of the defaults. + */ + fun normalizeIndexerRelayListPrecached(note: Note): Set = indexListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList + /** * The account's indexer relays, **never empty** — [normalizeIndexerRelayListWithBackup] * substitutes [DefaultIndexerRelayList] both when there is no kind:10086 and when the @@ -69,8 +80,8 @@ class IndexerRelayListState( * this and can rely on getting a usable set; use [flowNoDefaults] instead to show or diff * what the user actually configured. * - * Seeded with [DefaultIndexerRelayList] rather than `emptySet()`, for the same reason as - * the search list: `flowOn(IO)` makes the first real emission asynchronous, so an + * Seeded via [normalizeIndexerRelayListPrecached] rather than `emptySet()`, for the same + * reason as the search list: `flowOn(IO)` makes the first real emission asynchronous, so an * `emptySet()` seed left a window where `.value` contradicted the contract above. */ val flow = @@ -81,7 +92,7 @@ class IndexerRelayListState( .stateIn( scope, SharingStarted.Eagerly, - DefaultIndexerRelayList, + normalizeIndexerRelayListPrecached(indexerListNote), ) val flowNoDefaults = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt index 4d690210e3..eb3714dc5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt @@ -62,6 +62,18 @@ class SearchRelayListState( suspend fun normalizeSearchRelayListWithBackupNoDefaults(note: Note): Set = searchListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() + /** + * Same resolution as [normalizeSearchRelayListWithBackup] but non-suspending, for use as the + * [flow] seed. Reads the event's public tags plus any *already decrypted* private tags; it + * never asks the signer, so it cannot block or hit a NIP-46 round trip. + * + * At login `searchListNote.event` is usually still null and this resolves through + * `settings.backupSearchRelayList`, restored from LocalPreferences — so an account with public + * search relays gets its own relays immediately instead of the defaults. Accounts whose relays + * are exclusively private fall back to [DefaultSearchRelayList] until the first decrypt lands. + */ + fun normalizeSearchRelayListPrecached(note: Note): Set = searchListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList + /** * The account's search relays, **never empty** — [normalizeSearchRelayListWithBackup] * substitutes [DefaultSearchRelayList] both when there is no kind:10007 and when the @@ -69,8 +81,8 @@ class SearchRelayListState( * rely on getting a usable set; use [flowNoDefaults] instead to show or diff what the * user actually configured. * - * Seeded with [DefaultSearchRelayList] rather than `emptySet()`: `flowOn(IO)` means the - * first real emission can never be synchronous with `stateIn`, so an `emptySet()` seed + * Seeded via [normalizeSearchRelayListPrecached] rather than `emptySet()`: `flowOn(IO)` means + * the first real emission can never be synchronous with `stateIn`, so an `emptySet()` seed * left a window where `.value` contradicted the "never empty" contract above and search * silently queried nothing. That window is unbounded for a NIP-46 signer whose list has * private entries, since the first emission waits on a remote decrypt. @@ -83,7 +95,7 @@ class SearchRelayListState( .stateIn( scope, SharingStarted.Eagerly, - DefaultSearchRelayList, + normalizeSearchRelayListPrecached(searchListNote), ) val flowNoDefaults = diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/PrecachedRelayListSeedTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/PrecachedRelayListSeedTest.kt new file mode 100644 index 0000000000..89c0df7647 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip51Lists/PrecachedRelayListSeedTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists + +import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Guards the non-suspending seed that [SearchRelayListState.flow] is initialized with. + * + * The seed exists so `.value` is never empty before the first async emission lands (see the + * KDoc on `flow`). For it to be an improvement over just using the curated defaults, reading an + * account's *public* relays must work with no signer involvement at all — otherwise a NIP-46 + * account would still block. These tests pin that property. + */ +class PrecachedRelayListSeedTest { + private val relayA = RelayUrlNormalizer.normalize("wss://relay.example.com") + private val relayB = RelayUrlNormalizer.normalize("wss://other.example.com") + + /** + * The load-bearing claim: public relay tags are readable synchronously. `cachedRelays` is + * non-suspending, so if this returned empty the seed would silently degrade to the defaults + * for every account. + */ + @Test + fun cachedRelays_readsPublicRelaysWithoutDecrypting() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val event = SearchRelayListEvent.create(relays = listOf(relayA, relayB), signer = signer) + + val relays = SearchRelayListDecryptionCache(signer).cachedRelays(event) + + assertEquals(setOf(relayA, relayB), relays) + } + + /** + * A kind:10007 with no relays must read as empty here, so the seed's `?.ifEmpty { null }` + * arm hands over to the curated defaults rather than seeding an empty set. + */ + @Test + fun cachedRelays_emptyListReadsEmptySoTheSeedCanFallBack() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val event = SearchRelayListEvent.create(relays = emptyList(), signer = signer) + + val relays = SearchRelayListDecryptionCache(signer).cachedRelays(event) + + assertTrue(relays.isEmpty()) + } + + /** + * Reading another account's list must not blow up or leak a decrypt attempt — the private + * cache refuses to build for a foreign pubkey, so only the public tags come back. + */ + @Test + fun cachedRelays_foreignAuthorStillYieldsPublicRelays() = + runTest { + val author = NostrSignerInternal(KeyPair()) + val reader = NostrSignerInternal(KeyPair()) + val event = SearchRelayListEvent.create(relays = listOf(relayA), signer = author) + + val relays = SearchRelayListDecryptionCache(reader).cachedRelays(event) + + assertEquals(setOf(relayA), relays) + } +} From 37f9c5ad85246e7b1bca0b77d39d7972a29270b8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 11 Aug 2026 00:40:26 -0400 Subject: [PATCH 215/227] fix(embed): restore the keyboard on tab return, and none for readonly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device testing on a tablet (SM-T220, Android 14) walked every text-field focus path in the embedded tab. Two of them were wrong. **The tab-return restore never fired.** `noteKeyboardOnLeave` sampled `WindowInsets.imeAnimationTarget > 0 && isMirroringPageField()` inside `onDispose`, on the assumption that the dispose runs before anything hides the IME. It does not: by the time it runs, the nav transition has already snapped the animation target to 0 *and* taken focus off the view, so both halves read false and every tab was recorded as "left without a keyboard". Instrumented on device, the leave was `keyboardUp=false mirroring=false imeBottomPx=0` for all three nav-rail routes, so `pendingRestore` was false on every return and a tab left mid-typing always came back with the keyboard down. Ask the mirror what it *intends* instead of sampling the window at teardown: `RemoteImeView.keyboardWanted` is set when we raise the keyboard and cleared when the field blurs or the user puts the keyboard away, so it still reads true while the view is being torn down. Telling "user dismissed it" apart from "the tab went away" is what that clearing needs, and there is no key hook for it — Android 13+ routes the IME's back dismissal through OnBackInvokedCallback, so `onKeyPreIme` is never called (tried first; it silently never fired and the tab over-restored). The two cases are distinguishable by what else is true when the insets collapse, measured on device: dismiss: imeBottomPx=0 hasFocus=true mirrors=true tab switch: imeBottomPx=0 hasFocus=false mirrors=false so a collapse while we still mirror the field is the dismissal, and a switch never looks like one — the focus loss lands in the same frame as the insets. **A readonly field raised a keyboard that cannot type.** `isEditable` in the shim never looked at `readOnly`, so the host took the field and showed a keyboard whose keystrokes the page discards. Native, checked side by side in the full-screen WebView on the same page, focuses a readonly field without a keyboard. The field stays "editable" for selection (native offers handles and Copy there); only the raise is suppressed, via one guard in `raiseKeyboard` so the fresh-focus, tap-doorbell and tab-restore paths are all covered. Verified on device, 27/27 checks: fresh focus raises for text/textarea/ contenteditable/email/number/password/search/tel and not for disabled or readonly; BACK-dismiss then re-tap restores; re-tapping a field whose keyboard is up keeps it; leaving mid-typing restores on return (~1s, 5/5 runs) while a dismissed tab stays down; typing after either restore lands in the right field at the right caret; page-background tap blurs; address-bar keyboard never arms an embed restore; and the full-screen round trip leaves the embed IME working. `tools/ime-test/keyboard.html` is the page those checks drive: every field type plus a live focus readout and an event log that marks taps on an already-focused field, which is the case with no DOM event of its own. Co-Authored-By: Claude Opus 5 (1M context) --- .../loggedIn/embed/EmbeddedImeBridge.kt | 7 ++ .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 22 ++-- .../ui/screen/loggedIn/embed/RemoteImeView.kt | 39 ++++++ .../composeResources/files/napplet/shim.js | 5 +- tools/ime-test/keyboard.html | 119 ++++++++++++++++++ 5 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 tools/ime-test/keyboard.html diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt index c49a87b2ab..bb2673f7bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt @@ -96,6 +96,7 @@ private fun parseFocus(o: JSONObject) = inputType = o.optString("inputType", "text"), enterKeyHint = o.optString("enterKeyHint", ""), multiline = o.optBoolean("multiline", false), + readOnly = o.optBoolean("readOnly", false), text = o.optString("text", ""), selStart = o.optInt("selStart", 0), selEnd = o.optInt("selEnd", 0), @@ -109,6 +110,12 @@ sealed interface ImeEvent { val inputType: String, val enterKeyHint: String, val multiline: Boolean, + /** + * The field is `readonly`: focusable and selectable, but not typeable. Native Chrome focuses such a + * field without raising the keyboard, so the host must not either — otherwise the user gets a keyboard + * whose keystrokes the page discards. + */ + val readOnly: Boolean = false, val text: String, val selStart: Int, val selEnd: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index 50bc57161f..d8441ae2ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -348,10 +348,14 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // the selection). All the show/hide state lives in one [SelectionUiState] so the rules — toolbar hides // while dragging or scrolling, handles hide while scrolling — are expressed in one place. val sel = remember { SelectionUiState() } - // Read at dispose time to record whether the keyboard was up as the user left this tab (see - // [EmbeddedTabHost.noteKeyboardOnLeave]). The dispose runs before anything hides the IME — our own - // onPageBlur below is what takes it down — so this still reads the pre-switch state. - val keyboardUp = rememberUpdatedState(imeBottomPx > 0) + // The keyboard collapsing while this view still mirrors the page field means the user put it away on a + // field they are still on (BACK, or the IME's own hide button) — record it so returning to this tab + // doesn't pop the keyboard back over the page. A tab switch also collapses the insets but does NOT + // look like this: measured on device, the switch takes focus off the view in the same frame, so + // isMirroringPageField() is already false there and the mark this tab was owed survives. + LaunchedEffect(activeId, imeBottomPx) { + if (imeBottomPx == 0 && imeView.isMirroringPageField()) imeView.noteKeyboardDismissed() + } DisposableEffect(imeBridge) { val boundId = activeId // A warm tab keeps its page focus while parked, so returning to it fires no focus event: ask the @@ -423,10 +427,14 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // way: blurring it here would fire the page's own blur handlers — validation, autocomplete // dismissal, submit-on-blur — for a switch the user never made inside the page. // - // Only a keyboard THIS mirror holds counts: the tab's own chrome has host-side fields (the - // browser's address bar), and typing in one of those must not arm a restore for a page field. + // Ask the mirror what it *intends* rather than sampling the window: by the time this dispose + // runs, the nav transition has already snapped `WindowInsets.imeAnimationTarget` to 0 and + // taken focus off the view, so both would report "no keyboard" for every tab the user left + // mid-typing — which is exactly the case this restore exists for. [wantsKeyboardForPageField] + // also answers the other half: only a keyboard THIS mirror holds counts, so typing in the + // browser's own address bar never arms a restore for a page field. if (boundId != null) { - EmbeddedTabHost.noteKeyboardOnLeave(boundId, keyboardUp.value && imeView.isMirroringPageField()) + EmbeddedTabHost.noteKeyboardOnLeave(boundId, imeView.wantsKeyboardForPageField()) } imeView.onPageBlur() imeView.bind(null) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt index bdb55e1aa0..27be249c47 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt @@ -72,6 +72,17 @@ class RemoteImeView( // ship the PREVIOUS tab's text to the page on the first keystroke. private var mirroring = false + // Whether the keyboard is *meant* to be up for the field we mirror — our own intent, not the window's + // current state. Deliberately not derived from the IME insets or [hasFocus]: by the time a tab switch + // tears this view down, `WindowInsets.imeAnimationTarget` has already snapped to 0 and the view has + // already lost focus, so anything sampled then reports "no keyboard" for a tab the user left mid-typing. + // Set when we raise the keyboard, cleared when the user dismisses it or the page field blurs. + private var keyboardWanted = false + + // The mirrored field is `readonly`. Kept here rather than checked at each call site so every raise path — + // a fresh focus, the tap doorbell, and a tab restore — is covered by the one guard in [raiseKeyboard]. + private var fieldReadOnly = false + private val imm get() = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager private val flush = Runnable { flushState() } @@ -170,6 +181,7 @@ class RemoteImeView( ) { configureFor(focus) mirroring = true + fieldReadOnly = focus.readOnly // Focus the EditText BEFORE seeding text/selection. An EditText jumps its caret to the end when it // gains focus; if we seed first, that end-position then overrides the seed and gets shipped to the // page — so a tap mid-text lands the caret at the end of the field. Seeding AFTER focus makes the @@ -205,6 +217,13 @@ class RemoteImeView( /** True while the keyboard this view holds belongs to a page field — see [mirroring]. */ fun isMirroringPageField() = mirroring && hasFocus() + /** + * Whether this tab should come back with its keyboard up: we mirror a page field and the keyboard was + * meant to be showing when we were asked. Safe to call while the view is being torn down, which is the + * whole point — see [keyboardWanted]. + */ + fun wantsKeyboardForPageField() = mirroring && keyboardWanted + /** * Put the keyboard back on the field this view already mirrors — the user tapped it after dismissing the * keyboard, which leaves the page's focus (and this mirror) untouched, so there is nothing to re-seed. @@ -213,11 +232,29 @@ class RemoteImeView( */ @Suppress("DEPRECATION") // InputMethodManager.SHOW_IMPLICIT is deprecated; no equivalent flag on the newer API. fun raiseKeyboard() { + // A readonly field takes focus and can be selected/copied, but nothing can be typed into it — native + // Chrome shows no keyboard for one, so neither do we. + if (fieldReadOnly) return + keyboardWanted = true post { if (hasFocus()) imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } } + /** + * The user put the keyboard away (BACK, or the IME's own hide affordance) while still on this field: a + * deliberate "I'm done typing", so returning to this tab must NOT pop the keyboard back up. + * + * Called by the layer when the IME insets collapse while this view still mirrors the page field. That + * condition is what separates a dismissal from a tab switch — on a switch the view has already lost focus + * by the time the insets collapse, so [isMirroringPageField] is false and this never fires. (Note there is + * no usable key hook for this: Android 13+ routes the IME's back-dismiss through OnBackInvokedCallback, so + * `onKeyPreIme` is never called.) + */ + fun noteKeyboardDismissed() { + keyboardWanted = false + } + // When the current selection first became a range, and how many of its collapse-abandonments we've // re-asserted. Chrome abandons a selection *immediately* (~60ms); a deliberate user tap-to-collapse comes // later — so we only re-assert within a short window of the range forming, bounded for safety. @@ -256,6 +293,8 @@ class RemoteImeView( /** The page field blurred: drop the keyboard. */ fun onPageBlur() { mirroring = false + keyboardWanted = false + fieldReadOnly = false removeCallbacks(reportRangeLost) if (hadRange) { hadRange = false diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js index 6afbca5e04..4ac67e1cd1 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shim.js +++ b/commons/src/commonMain/composeResources/files/napplet/shim.js @@ -376,7 +376,8 @@ var inputType = isCE(n) ? 'text' : (t === 'TEXTAREA' ? 'textarea' : (n.type || 'text').toLowerCase()); var sel = selOf(n); return { type:'ime.focus', inputType: inputType, enterKeyHint: (n.enterKeyHint || ''), - multiline: multiline, text: valOf(n), selStart: sel[0], selEnd: sel[1], geom: fieldGeom(n) }; + multiline: multiline, readOnly: !!n.readOnly, text: valOf(n), selStart: sel[0], + selEnd: sel[1], geom: fieldGeom(n) }; } // Like `ime.focus`, but for a field that is ALREADY focused: the answer to the host's `ime.resync`, which // it asks for when it needs to (re-)take a field whose focus never moved in the page. @@ -390,7 +391,7 @@ return { type:'ime.refocus', inputType: isCE(n) ? 'text' : (t === 'TEXTAREA' ? 'textarea' : (n.type || 'text').toLowerCase()), enterKeyHint: (n.enterKeyHint || ''), multiline: isCE(n) || t === 'TEXTAREA', - text: valOf(n), selStart: sel[0], selEnd: sel[1] }; + readOnly: !!n.readOnly, text: valOf(n), selStart: sel[0], selEnd: sel[1] }; } // Last selection we either applied (applyState) or already reported, so the asynchronous // selectionchange our own setSel triggers doesn't echo back to the host as a fresh edit. diff --git a/tools/ime-test/keyboard.html b/tools/ime-test/keyboard.html new file mode 100644 index 0000000000..a976e0626d --- /dev/null +++ b/tools/ime-test/keyboard.html @@ -0,0 +1,119 @@ + + +Keyboard focus matrix + + +
FOCUS: (none)
+ +
+
+
+
+
hello world
+
+
+
+
+
+
+
+
+ + + + +
+ +
+ + From b7ef983bd83768b15968bebff05c237acbec494e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 11 Aug 2026 00:51:42 -0400 Subject: [PATCH 216/227] fix(embed): no caret handle or Cut/Paste on a readonly field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppressing the keyboard for a readonly field was only half of it. The selection UI still treated it as fully editable: a tap-and-hold raised the insertion caret handle, and its toolbar offered Cut and Paste — on a field the page will not let you modify. Cut appeared to work in the mirror while the page kept its text, so the two silently drifted apart. Native, on the same page in the full-screen WebView, gives a readonly field selection handles and a Copy / Select-all bar, and nothing else: no caret handle (there is no caret to place) and no editing actions. Carry the flag into SelectionUiState so the overlay can reason about it: `fieldReadOnly` gates the insertion handle (and with it the Paste/Select-all popup that hangs off it), and the field toolbar drops Cut and Paste. Selection, its handles, Copy and Select-all are untouched — that half is what native offers and it works today. `cutSelection`/`pasteClipboard` refuse on a readonly field too. The toolbar no longer offers them, so this is a backstop, placed next to the ops so a future call site can't reintroduce the drift. Device-verified: readonly long-press selects with handles and shows exactly "Copy | Select all"; a plain tap gives no keyboard and no caret droplet; an editable field still shows all four actions and keeps its caret handle. Co-Authored-By: Claude Opus 5 (1M context) --- .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 26 ++++++++++++++----- .../ui/screen/loggedIn/embed/RemoteImeView.kt | 7 +++-- .../screen/loggedIn/embed/SelectionUiState.kt | 16 +++++++++++- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index d8441ae2ff..f0b00c2319 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -377,6 +377,7 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // Cancel any in-flight scroll-hide from the page phase: otherwise the field's own // selection-reveal scrolls keep it armed and the new field handles/toolbar never appear. sel.scrolling = false + sel.onFieldReadOnly(event.readOnly) sel.onFieldGeometry(event.geometry, event.text.isNotEmpty()) } ImeEvent.WantKeyboard -> { @@ -398,6 +399,7 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // Re-arm "the field has text" so a tap can show the insertion handle again (a tab // switch resets it). Geometry stays null here, so this can't pop a handle up on its // own — native shows nothing until the user touches the field. + sel.onFieldReadOnly(event.focus.readOnly) sel.onFieldGeometry(null, event.focus.text.isNotEmpty()) } ImeEvent.Blur -> { @@ -529,19 +531,29 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // In-field (/