mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
Merge remote-tracking branch 'origin/main' into claude/armada-nip29-integration-lwqard
# Conflicts: # cli/tests/.gitignore
This commit is contained in:
+44
@@ -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.amethyst.commons.defaults
|
||||
|
||||
/**
|
||||
* Curated indexer relays for resolving NIP-17 inbox lookups (kind:10050).
|
||||
*
|
||||
* Used by [com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver]
|
||||
* via a SEPARATE unauthenticated NostrClient — these queries MUST NOT carry an
|
||||
* AUTH event back to the user's identity key (security review F-01: an
|
||||
* authenticated indexer fan-out turns "indexer learns we queried for pubkey X"
|
||||
* into "indexer learns Amethyst user U queried for pubkey X").
|
||||
*
|
||||
* Set selected for known kind:10050 indexing coverage; `purplepag.es` is
|
||||
* deliberately excluded (metadata indexer, weak kind:10050 coverage).
|
||||
*/
|
||||
object DefaultDmIndexerRelays {
|
||||
val RELAYS: List<String> =
|
||||
listOf(
|
||||
"wss://relay.nos.social",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://purplerelay.com",
|
||||
)
|
||||
}
|
||||
@@ -114,6 +114,24 @@ class User(
|
||||
|
||||
fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays()
|
||||
|
||||
/**
|
||||
* Strict variant of [dmInboxRelays] that returns ONLY the user's NIP-17
|
||||
* inbox relays (kind:10050) and never falls back to the NIP-65 read
|
||||
* marker (kind:10002).
|
||||
*
|
||||
* Per NIP-17 §Publishing, gift wraps MUST land on relays advertised in
|
||||
* the recipient's kind:10050 — the NIP-65 read fallback in
|
||||
* [dmInboxRelays] is a UI-convenience heuristic that leaks the DM
|
||||
* metadata to relays the recipient did not designate for DMs. Any code
|
||||
* that decides "can I actually deliver a NIP-17 wrap to this user"
|
||||
* should call this strict variant; UI hints and probe-time bootstrap
|
||||
* paths may continue to use the lenient one.
|
||||
*
|
||||
* Returns `null` when the recipient has no published kind:10050 (or an
|
||||
* empty one) — callers treat this as "unreachable via NIP-17".
|
||||
*/
|
||||
fun dmInboxRelaysStrict() = dmInboxRelayList()?.relays()?.ifEmpty { null }
|
||||
|
||||
fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: mostUsedNonLocalRelay()
|
||||
|
||||
fun allUsedRelaysOrNull() = relays?.allOrNull()
|
||||
|
||||
+1
@@ -185,6 +185,7 @@ class NostrSignerPermissionLedger(
|
||||
* Deliberately conservative: when a kind's blast radius is unclear, it is left out so the user
|
||||
* is asked rather than surprised.
|
||||
*/
|
||||
@Suppress("DEPRECATION") // TorrentCommentEvent is deprecated (NIP-22) but still a reasonable sign kind
|
||||
val REASONABLE_SIGN_KINDS: Set<Int> =
|
||||
setOf(
|
||||
TextNoteEvent.KIND, // 1 — short text notes & replies
|
||||
|
||||
+30
@@ -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.commons.privacylock
|
||||
|
||||
/**
|
||||
* Routes gated by the privacy lock.
|
||||
*
|
||||
* A single master `PrivacyLockSettings.lockEnabled` flag protects all scopes
|
||||
* together, but each scope keeps its own [PrivacyLockState] so that unlock,
|
||||
* idle-timer, and leave-route transitions apply independently per route.
|
||||
*/
|
||||
enum class LockScope { Messages, Wallet }
|
||||
+2
-2
@@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
interface PrivacyLockSettings {
|
||||
val lockEnabled: StateFlow<Boolean>
|
||||
val inactivityTimer: StateFlow<InactivityTimer>
|
||||
val redactionLevel: StateFlow<DmRedactionLevel>
|
||||
val dmRedactionLevel: StateFlow<DmRedactionLevel>
|
||||
val firstRunCardSeen: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
@@ -68,7 +68,7 @@ interface PrivacyLockSettings {
|
||||
|
||||
fun setInactivityTimer(timer: InactivityTimer)
|
||||
|
||||
fun setRedactionLevel(level: DmRedactionLevel)
|
||||
fun setDmRedactionLevel(level: DmRedactionLevel)
|
||||
|
||||
fun setFirstRunCardSeen(seen: Boolean)
|
||||
|
||||
|
||||
+41
-14
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.privacylock
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -33,19 +35,25 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* App-global state holder for the Messages privacy lock.
|
||||
* App-global state holder for a single privacy-lock [scope].
|
||||
*
|
||||
* One instance per gated route (Messages, Wallet, …) is provided via
|
||||
* [LocalPrivacyLockState] at the App composition root. All instances share
|
||||
* the same [PrivacyLockSettings] — one master `lockEnabled` flag enables
|
||||
* every scope together — but each scope keeps its own [LockState] and its
|
||||
* own idle-timer [Job] so unlock, leave-route, and inactivity transitions
|
||||
* apply independently per route.
|
||||
*
|
||||
* - Single instance per app, provided via [LocalMessagesLockState] at the
|
||||
* App composition root.
|
||||
* - Initial value is seeded synchronously from [settings.lockEnabled.value]
|
||||
* so the first composition sees [LockState.Locked] without flashing
|
||||
* content (deep-link race fix, plan §Security Hardening H1).
|
||||
* - The underlying StateFlow is hot (`MutableStateFlow`); notification path
|
||||
* can read `state.value` synchronously without subscribing.
|
||||
*/
|
||||
class MessagesLockState(
|
||||
class PrivacyLockState(
|
||||
val scope: LockScope,
|
||||
private val settings: PrivacyLockSettings,
|
||||
private val scope: CoroutineScope,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) {
|
||||
private val seed: LockState =
|
||||
if (settings.lockEnabled.value) LockState.Locked else LockState.Disabled
|
||||
@@ -64,12 +72,12 @@ class MessagesLockState(
|
||||
} else if (mutableState.value is LockState.Disabled) {
|
||||
mutableState.value = LockState.Locked
|
||||
}
|
||||
}.launchIn(scope)
|
||||
}.launchIn(coroutineScope)
|
||||
|
||||
combine(settings.lockEnabled, settings.inactivityTimer) { enabled, timer -> enabled to timer }
|
||||
.onEach { _ ->
|
||||
if (mutableState.value is LockState.Unlocked) restartIdleTimer()
|
||||
}.launchIn(scope)
|
||||
}.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
/** Resets the inactivity timer. No-op unless currently Unlocked. */
|
||||
@@ -90,7 +98,7 @@ class MessagesLockState(
|
||||
* Mark the session as authenticated. Transitions from either
|
||||
* [LockState.Locked] (normal unlock path) or [LockState.Disabled]
|
||||
* (first-run banner path — enabling the lock while the user is
|
||||
* actively in Messages should NOT flash the lock screen).
|
||||
* actively in a gated route should NOT flash the lock screen).
|
||||
* No-op if already [LockState.Unlocked]. Starts the idle timer.
|
||||
*/
|
||||
fun onUnlockSuccess() {
|
||||
@@ -105,7 +113,8 @@ class MessagesLockState(
|
||||
|
||||
/**
|
||||
* Triggered when biometric / OS credential is permanently unavailable.
|
||||
* Auto-disables the lock so the user can keep accessing Messages.
|
||||
* Auto-disables the lock (flips every scope to [LockState.Disabled]
|
||||
* via the shared setting) so the user can keep accessing gated routes.
|
||||
*/
|
||||
fun onCredentialUnavailable() {
|
||||
cancelIdleTimer()
|
||||
@@ -118,6 +127,10 @@ class MessagesLockState(
|
||||
* [PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES] failures: base 30 s,
|
||||
* doubling each further failure, capped at 5 min.
|
||||
*
|
||||
* Backoff state is shared across scopes — a mistyped password on the
|
||||
* Wallet gate locks out the Messages gate too (and vice versa). This is
|
||||
* intentional anti-brute-force behaviour.
|
||||
*
|
||||
* @param nowMs current epoch millis (injected for testability).
|
||||
* @return the new [PrivacyLockSettings.lockedUntilEpochMs] value, or
|
||||
* null when no lockout yet applies.
|
||||
@@ -148,7 +161,7 @@ class MessagesLockState(
|
||||
cancelIdleTimer()
|
||||
val millis = settings.inactivityTimer.value.millis ?: return
|
||||
idleTimerJob =
|
||||
scope.launch {
|
||||
coroutineScope.launch {
|
||||
delay(millis)
|
||||
if (mutableState.value is LockState.Unlocked) {
|
||||
mutableState.value = LockState.Locked
|
||||
@@ -162,8 +175,22 @@ class MessagesLockState(
|
||||
}
|
||||
}
|
||||
|
||||
/** Provided once at the App composition root. */
|
||||
val LocalMessagesLockState =
|
||||
compositionLocalOf<MessagesLockState> {
|
||||
error("LocalMessagesLockState not provided — wrap App() with CompositionLocalProvider")
|
||||
/**
|
||||
* Provided once at the App composition root. Map keyed by [LockScope]; every
|
||||
* scope must have an entry (see [lockStateFor] which throws when missing).
|
||||
*/
|
||||
val LocalPrivacyLockState =
|
||||
compositionLocalOf<Map<LockScope, PrivacyLockState>> {
|
||||
error("LocalPrivacyLockState not provided — wrap App() with CompositionLocalProvider")
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience accessor used inside gate composables. Reads the map from the
|
||||
* ambient [LocalPrivacyLockState] and returns the state holder for [scope].
|
||||
* Throws if the scope was not registered at the App root.
|
||||
*/
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun lockStateFor(scope: LockScope): PrivacyLockState =
|
||||
LocalPrivacyLockState.current[scope]
|
||||
?: error("PrivacyLockState for $scope not registered at App root")
|
||||
+158
-9
@@ -33,13 +33,16 @@ 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.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Coordinates metadata and reactions loading for feed items.
|
||||
@@ -73,6 +76,15 @@ class FeedMetadataCoordinator(
|
||||
private val queuedPubkeys = mutableSetOf<HexKey>()
|
||||
private val queuedNoteIds = mutableSetOf<HexKey>()
|
||||
private val queuedBoostedIds = mutableSetOf<HexKey>()
|
||||
private val queuedKind3Pubkeys = mutableSetOf<HexKey>()
|
||||
|
||||
// Batched paths only — pubkeys currently in-flight in a batched REQ.
|
||||
// Prevents rapid re-fire of the same batch. Distinct from queuedPubkeys
|
||||
// and queuedKind3Pubkeys (which record "asked and at least one relay
|
||||
// returned EOSE") so a batch that times out with zero events can be
|
||||
// retried on the next call — see PR #3483 review finding 5.
|
||||
private val inFlightBatchedMetadata = mutableSetOf<HexKey>()
|
||||
private val inFlightBatchedKind3 = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Start processing the subscription queue.
|
||||
@@ -251,14 +263,24 @@ class FeedMetadataCoordinator(
|
||||
/**
|
||||
* Fast-path: batched metadata subscription for visible-viewport authors.
|
||||
* Bypasses rate limiter. Single filter with all authors. Closes after EOSE.
|
||||
*
|
||||
* Pubkeys are moved into [queuedPubkeys] (dedup) only after at least one
|
||||
* relay EOSE'd. On timeout with zero EOSE (index relays all unreachable)
|
||||
* they roll out of [inFlightBatchedMetadata] so a subsequent call can
|
||||
* retry — see PR #3483 review finding 5.
|
||||
*/
|
||||
fun loadMetadataBatched(
|
||||
pubkeys: List<HexKey>,
|
||||
timeoutMs: Long = 5_000L,
|
||||
) {
|
||||
val newPubkeys = pubkeys.filter { it !in queuedPubkeys }.distinct()
|
||||
val newPubkeys =
|
||||
pubkeys
|
||||
.asSequence()
|
||||
.filter { it !in queuedPubkeys && it !in inFlightBatchedMetadata }
|
||||
.distinct()
|
||||
.toList()
|
||||
if (newPubkeys.isEmpty()) return
|
||||
queuedPubkeys.addAll(newPubkeys)
|
||||
inFlightBatchedMetadata.addAll(newPubkeys)
|
||||
|
||||
scope.launch {
|
||||
val filter =
|
||||
@@ -269,8 +291,7 @@ class FeedMetadataCoordinator(
|
||||
)
|
||||
val filterMap = indexRelays.associateWith { listOf(filter) }
|
||||
val subId = newSubId()
|
||||
val eoseReceived = mutableSetOf<NormalizedRelayUrl>()
|
||||
val allEose = CompletableDeferred<Unit>()
|
||||
val gate = BatchEoseGate(scope, target = indexRelays.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
@@ -287,16 +308,96 @@ class FeedMetadataCoordinator(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
eoseReceived.add(relay)
|
||||
if (eoseReceived.size >= indexRelays.size) {
|
||||
allEose.complete(Unit)
|
||||
}
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
withTimeoutOrNull(timeoutMs) { allEose.await() }
|
||||
val eosedRelays = gate.awaitAll(timeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
|
||||
if (eosedRelays > 0) {
|
||||
queuedPubkeys.addAll(newPubkeys)
|
||||
}
|
||||
inFlightBatchedMetadata.removeAll(newPubkeys.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched kind-3 (follow list) subscription. Used by the WoT service
|
||||
* to fetch the follow lists of every account the active user follows,
|
||||
* so friends-of-friends counts can be computed.
|
||||
*
|
||||
* Chunks authors into ≤100 per Filter within a single subscription
|
||||
* so relays with per-filter author caps (nostr-rs-relay defaults to
|
||||
* ~100) don't silently truncate the batch. Aggregates EOSE across
|
||||
* chunks and calls [onEose] once (or after [timeoutMs]).
|
||||
*
|
||||
* Pubkeys are moved into [queuedKind3Pubkeys] (dedup) only after at
|
||||
* least one relay EOSE'd. On timeout with zero EOSE (index relays all
|
||||
* unreachable — common on flaky mobile networks) they roll out of
|
||||
* [inFlightBatchedKind3] so the next `loadKind3Batched` call retries
|
||||
* — see PR #3483 review finding 5.
|
||||
*/
|
||||
fun loadKind3Batched(
|
||||
pubkeys: Collection<HexKey>,
|
||||
timeoutMs: Long = 5_000L,
|
||||
onEose: () -> Unit = {},
|
||||
) {
|
||||
val newPubkeys =
|
||||
pubkeys
|
||||
.asSequence()
|
||||
.filter { it !in queuedKind3Pubkeys && it !in inFlightBatchedKind3 }
|
||||
.distinct()
|
||||
.toList()
|
||||
if (newPubkeys.isEmpty()) {
|
||||
onEose()
|
||||
return
|
||||
}
|
||||
inFlightBatchedKind3.addAll(newPubkeys)
|
||||
|
||||
scope.launch {
|
||||
val filters =
|
||||
newPubkeys.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = listOf(ContactListEvent.KIND),
|
||||
authors = chunk,
|
||||
limit = chunk.size,
|
||||
)
|
||||
}
|
||||
val filterMap = indexRelays.associateWith { filters }
|
||||
val subId = newSubId()
|
||||
val gate = BatchEoseGate(scope, target = indexRelays.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
this@FeedMetadataCoordinator.onEvent?.invoke(event, relay)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
val eosedRelays = gate.awaitAll(timeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
|
||||
if (eosedRelays > 0) {
|
||||
queuedKind3Pubkeys.addAll(newPubkeys)
|
||||
}
|
||||
inFlightBatchedKind3.removeAll(newPubkeys.toSet())
|
||||
|
||||
onEose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,5 +408,53 @@ class FeedMetadataCoordinator(
|
||||
priorityQueue.clear()
|
||||
queuedPubkeys.clear()
|
||||
queuedNoteIds.clear()
|
||||
queuedKind3Pubkeys.clear()
|
||||
inFlightBatchedMetadata.clear()
|
||||
inFlightBatchedKind3.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates EOSE notifications from per-relay `onEose` callbacks
|
||||
* (which the client may dispatch on `Dispatchers.IO`) via a
|
||||
* [Channel]. The consumer coroutine is the sole reader/writer of the
|
||||
* `seen` set, eliminating the race the previous `mutableSetOf` +
|
||||
* shared-state check had — see PR #3483 review finding 6.
|
||||
*
|
||||
* [awaitAll] blocks up to [timeoutMs] and returns the number of
|
||||
* relays that EOSE'd (may be less than [target] on timeout). The
|
||||
* count feeds the retry decision in the batched loaders.
|
||||
*/
|
||||
private class BatchEoseGate(
|
||||
private val scope: CoroutineScope,
|
||||
private val target: Int,
|
||||
) {
|
||||
private val incoming = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
|
||||
private val done = CompletableDeferred<Unit>()
|
||||
|
||||
@Volatile private var lastCount = 0
|
||||
|
||||
fun notifyEose(relay: NormalizedRelayUrl) {
|
||||
incoming.trySend(relay)
|
||||
}
|
||||
|
||||
suspend fun awaitAll(timeoutMs: Long): Int {
|
||||
if (target <= 0) return 0
|
||||
val consumer =
|
||||
scope.launch {
|
||||
val seen = mutableSetOf<NormalizedRelayUrl>()
|
||||
for (relay in incoming) {
|
||||
if (seen.add(relay)) {
|
||||
lastCount = seen.size
|
||||
if (seen.size >= target && !done.isCompleted) {
|
||||
done.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
withTimeoutOrNull(timeoutMs) { done.await() }
|
||||
incoming.close()
|
||||
consumer.join()
|
||||
return lastCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+156
@@ -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.amethyst.commons.relayClient.auth
|
||||
|
||||
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.background
|
||||
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.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.text.font.FontWeight
|
||||
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.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
|
||||
/**
|
||||
* Inline AUTH approval banner.
|
||||
*
|
||||
* Renders one row per pending tier-2 NIP-42 AUTH challenge with three
|
||||
* actions: `[Once]` `[Always]` `[Never]`. Each press calls [onResolve]
|
||||
* with the user's choice, which the parent (typically a coordinator)
|
||||
* uses to complete the underlying [PendingAuthApproval.decision]
|
||||
* deferred and persist the scope.
|
||||
*
|
||||
* Stacks up to 3 entries inline; the rest collapse into a `+N more` row
|
||||
* (a future iteration may expand them on click — keep simple for now).
|
||||
*
|
||||
* The component is platform-agnostic and lives in `commons` so Android
|
||||
* and Desktop can render the same UX once the wire-up is built on each
|
||||
* platform.
|
||||
*/
|
||||
@Composable
|
||||
fun AuthApprovalBanner(
|
||||
pending: List<PendingAuthApproval>,
|
||||
onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = pending.isNotEmpty(),
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
val visible = pending.take(3)
|
||||
val hidden = pending.size - visible.size
|
||||
|
||||
visible.forEach { approval ->
|
||||
AuthApprovalRow(approval = approval, onResolve = onResolve)
|
||||
}
|
||||
|
||||
if (hidden > 0) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "+$hidden more relay${if (hidden == 1) "" else "s"} pending approval",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthApprovalRow(
|
||||
approval: PendingAuthApproval,
|
||||
onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f),
|
||||
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = approval.relayUrl.displayUrl(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (approval.pendingCount > 1) {
|
||||
"requires authentication for ${approval.pendingCount} messages"
|
||||
} else {
|
||||
"requires authentication to deliver this message"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ONCE) }) {
|
||||
Text("Once", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ALWAYS) }) {
|
||||
Text("Always", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.BLOCKED) }) {
|
||||
Text("Never", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* 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.auth
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Persisted scope for an AUTH approval decision.
|
||||
*
|
||||
* `ONCE` is in-memory only — never written to disk. `ALWAYS` and `BLOCKED`
|
||||
* persist via [AuthApprovalStore].
|
||||
*/
|
||||
enum class AuthApprovalScope {
|
||||
/** Approve this session; don't persist. */
|
||||
ONCE,
|
||||
|
||||
/** Approve indefinitely (or until the store's TTL expires the row). */
|
||||
ALWAYS,
|
||||
|
||||
/** Reject indefinitely. Future AUTH challenges from this relay are silently dropped. */
|
||||
BLOCKED,
|
||||
}
|
||||
|
||||
/**
|
||||
* The classifier verdict for a single AUTH challenge.
|
||||
*
|
||||
* `Allow` and `Block` are immediate. `Pending` means the user needs to decide;
|
||||
* the policy hands back a [CompletableDeferred] that the UI banner completes
|
||||
* once the user picks `[Once] [Always] [Never]`.
|
||||
*/
|
||||
sealed interface AuthApprovalDecision {
|
||||
/** Auto-sign the AUTH event for this relay. */
|
||||
data object Allow : AuthApprovalDecision
|
||||
|
||||
/** Silently drop the AUTH challenge. */
|
||||
data object Block : AuthApprovalDecision
|
||||
|
||||
/**
|
||||
* Suspend the signer until the user resolves the prompt.
|
||||
*
|
||||
* @property pending populated with the user's choice when the banner is
|
||||
* actioned. The signer awaits this deferred; if it resolves to
|
||||
* [AuthApprovalScope.BLOCKED] the AUTH is dropped, otherwise signed.
|
||||
*/
|
||||
data class Pending(
|
||||
val pending: CompletableDeferred<AuthApprovalScope>,
|
||||
) : AuthApprovalDecision
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending tier-2 AUTH approval surfaced to the user.
|
||||
*
|
||||
* Created when the policy decides a challenge needs user consent. Subscribers
|
||||
* (an `AccountAuthApprovals` ViewModel — wired in P2.5) render a banner with
|
||||
* `[Once] [Always] [Never]` buttons that resolve [decision] via `complete()`.
|
||||
*
|
||||
* `pendingCount` lets the banner coalesce multiple challenges from the same
|
||||
* relay into one row (`"<relay-url> requires authentication for 3 messages"`)
|
||||
* rather than stacking duplicate banners.
|
||||
*/
|
||||
data class PendingAuthApproval(
|
||||
val relayUrl: NormalizedRelayUrl,
|
||||
val decision: CompletableDeferred<AuthApprovalScope>,
|
||||
val pendingCount: Int = 1,
|
||||
)
|
||||
|
||||
/**
|
||||
* Per-account approval store. Implementations persist `ALWAYS` / `BLOCKED`
|
||||
* grants (typically to a SQLite `auth_approvals` table, wired in P2.4).
|
||||
*
|
||||
* `getScope` returns `null` if no decision is recorded for the relay.
|
||||
*/
|
||||
interface AuthApprovalStore {
|
||||
/** Returns the persisted decision for `relayUrl`, or `null` if unknown. */
|
||||
suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope?
|
||||
|
||||
/**
|
||||
* Record a user decision. `ONCE` decisions are NOT persisted by contract —
|
||||
* the policy caches them in-memory for the current session only.
|
||||
*/
|
||||
suspend fun setScope(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
)
|
||||
|
||||
/** Wipe all persisted approvals. Called on account delete / logout. */
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory [AuthApprovalStore] used as a development scaffold and as the
|
||||
* `ONCE` cache layer on top of a persistent store. Tier-2 banner approvals
|
||||
* with `ONCE` scope live here for the session and are dropped on logout.
|
||||
*/
|
||||
class InMemoryAuthApprovalStore : AuthApprovalStore {
|
||||
private val scopes = mutableMapOf<NormalizedRelayUrl, AuthApprovalScope>()
|
||||
private val lock = Mutex()
|
||||
|
||||
override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? = lock.withLock { scopes[relayUrl] }
|
||||
|
||||
override suspend fun setScope(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
) {
|
||||
lock.withLock { scopes[relayUrl] = scope }
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
lock.withLock { scopes.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The classifier between the relay client's `signWithAllLoggedInUsers` lambda
|
||||
* and the actual signer.
|
||||
*
|
||||
* Two tiers:
|
||||
*
|
||||
* - **Tier 1 (auto-allow):** the relay is in the user's own outbox or
|
||||
* NIP-17 DM-inbox set, or has a persisted `ALWAYS` grant. Sign immediately,
|
||||
* no prompt. These are relays the user has already declared they trust.
|
||||
* - **Tier 2 (prompt):** anything else, with the exception of relays that
|
||||
* carry a persisted `BLOCKED` grant. Surface a [PendingAuthApproval] via
|
||||
* [onPromptRequired] and suspend until the user resolves the
|
||||
* [CompletableDeferred]. If `ONCE`, cache for this session; if `ALWAYS` or
|
||||
* `BLOCKED`, persist via the store.
|
||||
*
|
||||
* No tier-3: every challenge is either auto-allowed, blocked by a persisted
|
||||
* decision, or surfaced to the user. There is no silent third path.
|
||||
*
|
||||
* @property selfApprovedRelays the union of own outbox + DM-inbox + any
|
||||
* account-level pre-approval. Recomputed by the caller on Account state
|
||||
* changes. Tier 1 if the challenger is in this set.
|
||||
* @property store persistence layer (SQLite-backed in production, in-memory in
|
||||
* tests).
|
||||
* @property onPromptRequired called when a [PendingAuthApproval] needs to be
|
||||
* surfaced to the UI. The UI subscribes to this side-channel and completes
|
||||
* the contained [CompletableDeferred] with the user's pick.
|
||||
*/
|
||||
class AuthApprovalPolicy(
|
||||
val selfApprovedRelays: () -> Set<NormalizedRelayUrl>,
|
||||
val store: AuthApprovalStore,
|
||||
val onPromptRequired: (PendingAuthApproval) -> Unit,
|
||||
) {
|
||||
/**
|
||||
* Decide what to do with an AUTH challenge from `relayUrl`.
|
||||
*
|
||||
* @return [AuthApprovalDecision.Allow] for tier-1 / persisted-ALWAYS,
|
||||
* [AuthApprovalDecision.Block] for persisted-BLOCKED,
|
||||
* [AuthApprovalDecision.Pending] (and emits to [onPromptRequired]) for
|
||||
* unknown relays.
|
||||
*/
|
||||
suspend fun classify(relayUrl: NormalizedRelayUrl): AuthApprovalDecision {
|
||||
// Persisted decision wins over tier-1: if user explicitly blocked a
|
||||
// relay that happens to also be in their outbox, respect the block.
|
||||
when (store.getScope(relayUrl)) {
|
||||
AuthApprovalScope.ALWAYS -> return AuthApprovalDecision.Allow
|
||||
AuthApprovalScope.BLOCKED -> return AuthApprovalDecision.Block
|
||||
AuthApprovalScope.ONCE -> return AuthApprovalDecision.Allow
|
||||
null -> Unit
|
||||
}
|
||||
|
||||
if (relayUrl in selfApprovedRelays()) {
|
||||
return AuthApprovalDecision.Allow
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<AuthApprovalScope>()
|
||||
onPromptRequired(PendingAuthApproval(relayUrl, deferred))
|
||||
return AuthApprovalDecision.Pending(deferred)
|
||||
}
|
||||
|
||||
/** Persist (or cache) the user's choice from a [PendingAuthApproval] resolution. */
|
||||
suspend fun recordDecision(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
) {
|
||||
// `ONCE` lives in-memory only (the InMemoryAuthApprovalStore handles
|
||||
// this transparently). `ALWAYS` and `BLOCKED` persist via the store.
|
||||
store.setScope(relayUrl, scope)
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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.nip17Dm
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Resolves a recipient's NIP-17 inbox relays (kind:10050) for DM delivery.
|
||||
*
|
||||
* Three-layer lookup, in order:
|
||||
*
|
||||
* 1. **LocalCache hit** — the caller has already seen the user's kind:10050
|
||||
* via the normal feed subscription pipeline. Cheapest; no I/O.
|
||||
* 2. **In-memory LRU cache** — a prior resolve() succeeded for this pubkey
|
||||
* within the TTL. Avoids re-querying indexers when the user opens a
|
||||
* conversation list and clicks several recipients in sequence.
|
||||
* 3. **Indexer fan-out** — query a curated set of indexer relays for the
|
||||
* user's kind:10050 via [RecipientRelayFetcher]. The client passed in
|
||||
* here MUST be an **unauthenticated** instance (no [RelayAuthenticator]
|
||||
* attached) — otherwise an indexer's AUTH challenge would extract an
|
||||
* identity-key signature from the user, turning the metadata leak
|
||||
* "indexer learns who we want to DM" into "indexer learns user U wants
|
||||
* to DM pubkey X".
|
||||
*
|
||||
* Filters to **kind:10050 only**. Per NIP-17 §Publishing, gift wraps MUST
|
||||
* land on relays in the recipient's kind:10050; this resolver never
|
||||
* substitutes the NIP-65 read marker as a fallback, because doing so leaks
|
||||
* DMs to relays the recipient did not explicitly designate for DMs.
|
||||
*
|
||||
* Empty result is the canonical "we don't know where to send" signal — the
|
||||
* caller should refuse to publish rather than fall back to its own relays
|
||||
* (see [com.vitorpamplona.amethyst.desktop.model.DesktopIAccount.resolveDmInboxRelaysStrict]).
|
||||
*
|
||||
* @property unauthenticatedClient NostrClient WITHOUT a RelayAuthenticator
|
||||
* attached. Use a dedicated instance — do NOT pass the app's primary
|
||||
* client.
|
||||
* @property indexerRelays Curated indexer set. Typically
|
||||
* [com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays].
|
||||
* @property localLookup Callback the resolver invokes first to check the
|
||||
* LocalCache — returns the user's current kind:10050 list or null if
|
||||
* unknown. Allows commons/headless callers to plug in a CLI-safe lookup.
|
||||
* @property cacheTtlMs LRU cache TTL. 1h matches the brainstorm's open
|
||||
* question; configurable here for tests.
|
||||
* @property cacheSize LRU bound. 100 entries × ~200 bytes each is trivial
|
||||
* memory; matches typical active-conversation count for power users.
|
||||
*/
|
||||
class DmInboxRelayResolver(
|
||||
private val unauthenticatedClient: INostrClient,
|
||||
private val indexerRelays: Set<NormalizedRelayUrl>,
|
||||
private val localLookup: (HexKey) -> List<NormalizedRelayUrl>?,
|
||||
private val cacheTtlMs: Long = 60 * 60 * 1_000L,
|
||||
private val cacheSize: Int = 100,
|
||||
private val nowMs: () -> Long = {
|
||||
kotlin.time.Clock.System
|
||||
.now()
|
||||
.toEpochMilliseconds()
|
||||
},
|
||||
) {
|
||||
private data class Entry(
|
||||
val relays: List<NormalizedRelayUrl>,
|
||||
val expiresAtMs: Long,
|
||||
)
|
||||
|
||||
private val cache = linkedMapOf<HexKey, Entry>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Resolve `pubkey`'s NIP-17 inbox relays. Returns empty list if neither
|
||||
* the LocalCache nor the indexer fan-out yielded a kind:10050.
|
||||
*/
|
||||
suspend fun resolve(pubkey: HexKey): List<NormalizedRelayUrl> {
|
||||
localLookup(pubkey)?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
|
||||
val now = nowMs()
|
||||
mutex.withLock {
|
||||
cache[pubkey]?.let { entry ->
|
||||
if (entry.expiresAtMs > now) {
|
||||
// Refresh LRU order on hit
|
||||
cache.remove(pubkey)
|
||||
cache[pubkey] = entry
|
||||
return entry.relays
|
||||
} else {
|
||||
cache.remove(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (indexerRelays.isEmpty()) return emptyList()
|
||||
|
||||
val lists = RecipientRelayFetcher.fetchRelayLists(unauthenticatedClient, pubkey, indexerRelays)
|
||||
// Strict: kind:10050 ONLY. No NIP-65 fallback. Empty = canonical
|
||||
// "unreachable" signal; caller refuses to publish.
|
||||
val relays = lists.dmInbox
|
||||
|
||||
mutex.withLock {
|
||||
cache[pubkey] = Entry(relays, now + cacheTtlMs)
|
||||
while (cache.size > cacheSize) {
|
||||
cache.remove(cache.keys.iterator().next())
|
||||
}
|
||||
}
|
||||
return relays
|
||||
}
|
||||
|
||||
/** Evict a specific entry — e.g. when LocalCache observes a fresh kind:10050. */
|
||||
suspend fun invalidate(pubkey: HexKey) {
|
||||
mutex.withLock { cache.remove(pubkey) }
|
||||
}
|
||||
|
||||
/** Wipe the entire cache — e.g. on account switch. */
|
||||
suspend fun clear() {
|
||||
mutex.withLock { cache.clear() }
|
||||
}
|
||||
}
|
||||
+25
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.commons.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -62,6 +64,10 @@ data class ProfilePictureUrl(
|
||||
* @param loadProfilePicture Whether to load the profile picture (false = show robohash only)
|
||||
* @param loadRobohash Whether to generate robohash (false = show generic icon)
|
||||
* @param useThumbnailCache Whether to use the thumbnail disk cache for faster repeated loads
|
||||
* @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. When null the avatar renders as before (no extra
|
||||
* `Box` wrapper).
|
||||
*/
|
||||
@Composable
|
||||
fun UserAvatar(
|
||||
@@ -73,7 +79,26 @@ fun UserAvatar(
|
||||
loadProfilePicture: Boolean = true,
|
||||
loadRobohash: Boolean = true,
|
||||
useThumbnailCache: Boolean = false,
|
||||
badge: @Composable (BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
if (badge != null) {
|
||||
Box(modifier = modifier.size(size)) {
|
||||
UserAvatar(
|
||||
userHex = userHex,
|
||||
pictureUrl = pictureUrl,
|
||||
size = size,
|
||||
modifier = Modifier,
|
||||
contentDescription = contentDescription,
|
||||
loadProfilePicture = loadProfilePicture,
|
||||
loadRobohash = loadRobohash,
|
||||
useThumbnailCache = useThumbnailCache,
|
||||
badge = null,
|
||||
)
|
||||
badge()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val avatarModifier =
|
||||
remember(size, modifier) {
|
||||
modifier
|
||||
|
||||
+7
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -46,12 +47,17 @@ import org.jetbrains.compose.resources.stringResource
|
||||
/**
|
||||
* A card displaying user search result with avatar, name, and nip05/pubkey.
|
||||
* Shared between Android and Desktop search screens.
|
||||
*
|
||||
* @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].
|
||||
*/
|
||||
@Composable
|
||||
fun UserSearchCard(
|
||||
user: User,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
badge: @Composable (BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
@@ -73,6 +79,7 @@ fun UserSearchCard(
|
||||
pictureUrl = user.profilePicture(),
|
||||
size = 40.dp,
|
||||
contentDescription = stringResource(Res.string.accessibility_user_avatar),
|
||||
badge = badge,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ enum class PromptResult {
|
||||
|
||||
/**
|
||||
* Credential surface permanently unavailable on this device — caller
|
||||
* should invoke [com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState.onCredentialUnavailable].
|
||||
* should invoke [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState.onCredentialUnavailable].
|
||||
*/
|
||||
Unavailable,
|
||||
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.ui.privacylock
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState
|
||||
|
||||
/**
|
||||
* Observes pointer events on the Initial pass — does NOT consume them, so
|
||||
@@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState
|
||||
* since they're not user input — preserves the "walked-away-from-desk"
|
||||
* protection per brainstorm resolved Q.
|
||||
*/
|
||||
fun Modifier.resetIdleOnInteraction(state: MessagesLockState): Modifier =
|
||||
fun Modifier.resetIdleOnInteraction(state: PrivacyLockState): Modifier =
|
||||
this.pointerInput(state) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
|
||||
+121
@@ -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.amethyst.commons.ui.privacylock
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Shared lock-screen surface used by [MessagesLockGate] and [WalletLockGate].
|
||||
* Runs the async [CredentialPrompter] path (biometric / OS credential on
|
||||
* Android + iOS). Desktop platforms use a password-input inline lock screen
|
||||
* instead — see `DesktopMessagesLockGate` / `DesktopWalletLockGate`.
|
||||
*
|
||||
* Kept `internal` so the only public entry points are the per-scope Gates.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LockScreen(
|
||||
scope: LockScope,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
unlockLabel: String,
|
||||
) {
|
||||
val lockState = lockStateFor(scope)
|
||||
val prompter = LocalCredentialPrompter.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(prompter) {
|
||||
if (!prompter.available) {
|
||||
lockState.onCredentialUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Box(modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Box(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = 320.dp),
|
||||
)
|
||||
Box(modifier = Modifier.size(32.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
when (prompter.prompt()) {
|
||||
PromptResult.Success -> lockState.onUnlockSuccess()
|
||||
PromptResult.Unavailable -> lockState.onCredentialUnavailable()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = prompter.available,
|
||||
) {
|
||||
Text(text = unlockLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-91
@@ -20,40 +20,21 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.privacylock
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.privacylock.LocalMessagesLockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockState
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
|
||||
/**
|
||||
* Wraps the Messages route and gates entry behind the credential prompt.
|
||||
*
|
||||
* Branch selection happens SYNCHRONOUSLY in composition — no
|
||||
* [LaunchedEffect] guard — so the chat content composable never enters
|
||||
* composition while [LockState.Locked]. Closes the deep-link race
|
||||
* (plan §Security Hardening H1).
|
||||
* [androidx.compose.runtime.LaunchedEffect] guard — so the chat content
|
||||
* composable never enters composition while [LockState.Locked]. Closes the
|
||||
* deep-link race (plan §Security Hardening H1).
|
||||
*
|
||||
* The gate is an overlay, NOT a wrapper that disposes content. While
|
||||
* locked, the [content] lambda is not invoked at all; on unlock, the
|
||||
@@ -61,12 +42,14 @@ import kotlinx.coroutines.launch
|
||||
* `rememberSaveable` survive a lock cycle (SavedStateRegistry-backed).
|
||||
* For plain `remember` state, drafts are cleared — accept this trade-off.
|
||||
*
|
||||
* The gate also fires [MessagesLockState.onLeaveRoute] from its
|
||||
* [DisposableEffect.onDispose] block, so navigating away locks immediately.
|
||||
* The gate also fires
|
||||
* [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState.onLeaveRoute]
|
||||
* from its [DisposableEffect.onDispose] block, so navigating away locks
|
||||
* immediately.
|
||||
*/
|
||||
@Composable
|
||||
fun MessagesLockGate(content: @Composable () -> Unit) {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val lockState = lockStateFor(LockScope.Messages)
|
||||
val current by lockState.state.collectAsState()
|
||||
|
||||
DisposableEffect(lockState) {
|
||||
@@ -74,70 +57,13 @@ fun MessagesLockGate(content: @Composable () -> Unit) {
|
||||
}
|
||||
|
||||
when (current) {
|
||||
is LockState.Locked -> LockScreen()
|
||||
is LockState.Locked ->
|
||||
LockScreen(
|
||||
scope = LockScope.Messages,
|
||||
title = "Messages locked",
|
||||
subtitle = "Unlock to read or send messages.",
|
||||
unlockLabel = "Unlock",
|
||||
)
|
||||
else -> content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockScreen() {
|
||||
val lockState = LocalMessagesLockState.current
|
||||
val prompter = LocalCredentialPrompter.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(prompter) {
|
||||
if (!prompter.available) {
|
||||
lockState.onCredentialUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Box(modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = "Messages locked",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Box(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "Unlock to read or send messages",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = 320.dp),
|
||||
)
|
||||
Box(modifier = Modifier.size(32.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
when (prompter.prompt()) {
|
||||
PromptResult.Success -> lockState.onUnlockSuccess()
|
||||
PromptResult.Unavailable -> lockState.onCredentialUnavailable()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = prompter.available,
|
||||
) {
|
||||
Text(text = "Unlock")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
@@ -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.ui.privacylock
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.LockState
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
|
||||
|
||||
/**
|
||||
* Wraps the Wallet route and gates entry behind the credential prompt.
|
||||
*
|
||||
* Behaviour mirrors [MessagesLockGate] — see that composable's KDoc for the
|
||||
* deep-link race, draft persistence, and leave-route semantics. Only the
|
||||
* [LockScope] and the lock-screen copy differ.
|
||||
*
|
||||
* Desktop apps use the platform-specific `DesktopWalletLockGate` (password
|
||||
* input inline, no async CredentialPrompter round-trip); Android + iOS
|
||||
* front ends use this composable directly.
|
||||
*/
|
||||
@Composable
|
||||
fun WalletLockGate(content: @Composable () -> Unit) {
|
||||
val lockState = lockStateFor(LockScope.Wallet)
|
||||
val current by lockState.state.collectAsState()
|
||||
|
||||
DisposableEffect(lockState) {
|
||||
onDispose { lockState.onLeaveRoute() }
|
||||
}
|
||||
|
||||
when (current) {
|
||||
is LockState.Locked ->
|
||||
LockScreen(
|
||||
scope = LockScope.Wallet,
|
||||
title = "Wallet locked",
|
||||
subtitle = "Unlock to see your balance and send or receive sats.",
|
||||
unlockLabel = "Unlock",
|
||||
)
|
||||
else -> content()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ fun SigningAwareButton(
|
||||
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
when (signingState.state) {
|
||||
is SigningOpState.Pending -> {
|
||||
is SigningOpState.Pending, is SigningOpState.Progress -> {
|
||||
Box(modifier = modifier.size(32.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
|
||||
+36
-1
@@ -37,11 +37,29 @@ sealed class SigningOpState {
|
||||
|
||||
data object Pending : SigningOpState()
|
||||
|
||||
/**
|
||||
* Signing is in flight AND has a known step count — typically a NIP-17
|
||||
* group send via a remote signer (bunker), where the UI can usefully show
|
||||
* "Encrypting via remote signer ([current] of [total])".
|
||||
*
|
||||
* Treated as Pending for all is-pending checks via [isPending] below;
|
||||
* existing callers that branch on `is Pending` keep working unchanged.
|
||||
* New callers can render the counter when [SigningOpState] is `Progress`.
|
||||
*/
|
||||
data class Progress(
|
||||
val current: Int,
|
||||
val total: Int,
|
||||
val label: String? = null,
|
||||
) : SigningOpState()
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : SigningOpState()
|
||||
}
|
||||
|
||||
/** True when signing is in flight, regardless of whether step counts are known. */
|
||||
fun SigningOpState.isPending(): Boolean = this is SigningOpState.Pending || this is SigningOpState.Progress
|
||||
|
||||
/**
|
||||
* Global signing status — any [SigningState] instance updates this when signing starts/ends.
|
||||
* Observe [globalState] from a screen-level composable to show a persistent status bar.
|
||||
@@ -86,7 +104,7 @@ class SigningState {
|
||||
private set
|
||||
|
||||
suspend fun <T> execute(block: suspend () -> T): T? {
|
||||
if (state is SigningOpState.Pending) return null
|
||||
if (state.isPending()) return null
|
||||
state = SigningOpState.Pending
|
||||
GlobalSigningStatus.onPending()
|
||||
errorMessage = null
|
||||
@@ -114,6 +132,23 @@ class SigningState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the in-flight signing state with a progress counter. Use during
|
||||
* multi-step operations (NIP-17 group send via bunker, batch zaps) to
|
||||
* show the user how far the in-flight op has progressed.
|
||||
*
|
||||
* Only takes effect while [state] is Pending or Progress — no-op
|
||||
* otherwise so callers don't have to gate on Idle/Error themselves.
|
||||
*/
|
||||
fun updateProgress(
|
||||
current: Int,
|
||||
total: Int,
|
||||
label: String? = null,
|
||||
) {
|
||||
if (!state.isPending()) return
|
||||
state = SigningOpState.Progress(current, total, label)
|
||||
}
|
||||
|
||||
private fun setError(message: String) {
|
||||
errorMessage = message
|
||||
state = SigningOpState.Error(message)
|
||||
|
||||
+15
@@ -87,6 +87,21 @@ fun SigningStatusBar(
|
||||
}
|
||||
}
|
||||
|
||||
is SigningOpState.Progress -> {
|
||||
Snackbar(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
containerColor = MaterialTheme.colorScheme.inverseSurface,
|
||||
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
val label = opState.label ?: "Signing"
|
||||
Text(
|
||||
text = "$label (${opState.current} of ${opState.total})",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is SigningOpState.Error -> {
|
||||
Snackbar(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
|
||||
+72
-10
@@ -25,6 +25,8 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
@@ -41,6 +43,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Slim shared state for DM message composition.
|
||||
@@ -56,6 +59,19 @@ class ChatNewMessageState(
|
||||
val account: IAccount,
|
||||
val cache: ICacheProvider,
|
||||
val scope: CoroutineScope,
|
||||
/**
|
||||
* Optional resolver for probing kind:10050 via curated indexer relays
|
||||
* when a peer's DM inbox isn't in the local cache. When provided,
|
||||
* [updateRecipientRelayStatus] falls through to the resolver on a cache
|
||||
* miss so the pre-send UI stops falsely reporting "recipient has no DM
|
||||
* relay list" for accounts whose 10050 sits on an indexer we haven't
|
||||
* subscribed to yet.
|
||||
*
|
||||
* When null (default, e.g. Android's ChatNewMessageViewModel which
|
||||
* hasn't been wired to a resolver yet), behaviour matches the
|
||||
* cache-only strict check.
|
||||
*/
|
||||
private val dmInboxResolver: (suspend (HexKey) -> List<NormalizedRelayUrl>?)? = null,
|
||||
) {
|
||||
private val _message = MutableStateFlow(TextFieldValue(""))
|
||||
val message: StateFlow<TextFieldValue> = _message.asStateFlow()
|
||||
@@ -86,20 +102,66 @@ class ChatNewMessageState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all recipients have DM relay lists.
|
||||
* Messages can only be sent via NIP-17, so recipients must have
|
||||
* either a DM inbox relay list (kind 10050) or NIP-65 inbox relays.
|
||||
* Check whether every participant in the current room is reachable via
|
||||
* NIP-17 — i.e. has a published kind:10050 that lists at least one
|
||||
* relay.
|
||||
*
|
||||
* Uses the **strict** check ([com.vitorpamplona.amethyst.commons.model.User.dmInboxRelaysStrict],
|
||||
* kind:10050 only) instead of the lenient [dmInboxRelays] that falls
|
||||
* back to the NIP-65 read marker — the send path also uses strict
|
||||
* resolution (see `DesktopIAccount.resolveDmInboxRelaysStrict`), and
|
||||
* disagreement here caused the pre-send UI to green-light sends that
|
||||
* would then fail at send time.
|
||||
*
|
||||
* Two-phase check:
|
||||
*
|
||||
* 1. Synchronous cache check — every peer's kind:10050 sits in
|
||||
* [cache]. If all present with at least one relay, unblock
|
||||
* immediately.
|
||||
* 2. Async resolver probe (only when [dmInboxResolver] is provided) —
|
||||
* for peers whose 10050 isn't cached, kick off a curated-indexer
|
||||
* fan-out. If any peer's relays turn up, update the flag to
|
||||
* unblock the composer without requiring the user to restart the
|
||||
* conversation view.
|
||||
*
|
||||
* The blocking flag is set optimistically during the probe so the
|
||||
* user still sees the warning until we've confirmed the peer is
|
||||
* genuinely unreachable via NIP-17. This preserves the "don't allow
|
||||
* silent-fail sends" invariant.
|
||||
*/
|
||||
fun updateRecipientRelayStatus() {
|
||||
val currentRoom = _room.value
|
||||
if (currentRoom != null) {
|
||||
_recipientsMissingDmRelays.value =
|
||||
currentRoom.users.any { hexKey ->
|
||||
val user = cache.getOrCreateUser(hexKey)
|
||||
user?.dmInboxRelays().isNullOrEmpty()
|
||||
}
|
||||
} else {
|
||||
if (currentRoom == null) {
|
||||
_recipientsMissingDmRelays.value = false
|
||||
return
|
||||
}
|
||||
|
||||
val missing =
|
||||
currentRoom.users.filter { hexKey ->
|
||||
val user = cache.getOrCreateUser(hexKey)
|
||||
user?.dmInboxRelaysStrict().isNullOrEmpty()
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
_recipientsMissingDmRelays.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Cache miss → block optimistically, then probe indexers for the
|
||||
// missing peers. If any of them turn up a kind:10050, unblock.
|
||||
_recipientsMissingDmRelays.value = true
|
||||
val resolver = dmInboxResolver ?: return
|
||||
|
||||
scope.launch {
|
||||
val stillMissing =
|
||||
missing.any { hexKey ->
|
||||
val fanOut = resolver(hexKey)
|
||||
fanOut.isNullOrEmpty()
|
||||
}
|
||||
// The room may have changed while we were probing; only apply
|
||||
// the result if we're still looking at the same conversation.
|
||||
if (_room.value == currentRoom) {
|
||||
_recipientsMissingDmRelays.value = stillMissing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+46
@@ -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.commons.wot
|
||||
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
|
||||
/**
|
||||
* Compose-observable score service. Provided by the Desktop app at App
|
||||
* root when a user is logged in. Left null on Android and while logged
|
||||
* out — leaf composables branch on `LocalWoTService.current == null` to
|
||||
* skip the WoT rendering path.
|
||||
*
|
||||
* The badge-hide predicates (self, already-followed) are read from
|
||||
* `commons.moderation.LocalSpamExemptKeys` — the same set already
|
||||
* provided by the hashtag-spam filter.
|
||||
*/
|
||||
val LocalWoTService: ProvidableCompositionLocal<WoTService?> =
|
||||
compositionLocalOf { null }
|
||||
|
||||
/**
|
||||
* Whether the WoT service has finished its initial batch fetch (or the
|
||||
* 2s startup timeout has elapsed). Read once at the App root via
|
||||
* [WoTService.isReady] and provided down as a scalar so leaf composables
|
||||
* don't each spawn a Flow collector.
|
||||
*/
|
||||
val LocalWoTReady: ProvidableCompositionLocal<Boolean> =
|
||||
compositionLocalOf { false }
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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.wot
|
||||
|
||||
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.nip65RelayList.AdvertisedRelayListEvent
|
||||
|
||||
/**
|
||||
* Platform-agnostic interface between [OutboxDispatcher] and the platform's
|
||||
* event cache. Desktop and `amy` each provide their own implementation —
|
||||
* DesktopLocalCache on the app side, a minimal in-memory adapter over the
|
||||
* amy local store on the CLI side.
|
||||
*
|
||||
* The dispatcher only needs three capabilities:
|
||||
*
|
||||
* 1. Peek at what kind-10002 events are already stored so it can skip
|
||||
* Phase-1 discovery for authors whose write-relay list is already
|
||||
* known (from hydration or a previous session's fetch).
|
||||
* 2. Ingest a kind-10002 that just came back from an index relay so
|
||||
* subsequent lookups don't re-fetch it.
|
||||
* 3. Ingest a kind-0 or kind-3 that just came back from an outbox
|
||||
* relay so the platform cache/UI can pick it up through the usual
|
||||
* consume path.
|
||||
*
|
||||
* Every method must be idempotent — the dispatcher may re-fire the same
|
||||
* event through the gateway if two relays happen to return the same
|
||||
* addressable event.
|
||||
*/
|
||||
interface OutboxCacheGateway {
|
||||
/**
|
||||
* Returns the currently-cached kind-10002 event for [pubkey], or null
|
||||
* if the platform cache doesn't have one yet.
|
||||
*/
|
||||
fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent?
|
||||
|
||||
/**
|
||||
* Called for every kind-10002 the dispatcher receives during Phase 1.
|
||||
* The gateway should route it through its normal consume path so the
|
||||
* event is stored, deduped by createdAt, and picked up by any state
|
||||
* holders observing the addressable-notes cache.
|
||||
*/
|
||||
fun onOutboxDiscovered(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl,
|
||||
)
|
||||
|
||||
/**
|
||||
* Called for every kind-0 (metadata) or kind-3 (contact list) the
|
||||
* dispatcher receives during Phase 2 or Phase 3. The gateway should
|
||||
* route it through its normal consume path — this is how new profile
|
||||
* metadata and follow lists reach downstream consumers like the WoT
|
||||
* service and the UI.
|
||||
*/
|
||||
fun onDiscoveredEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl,
|
||||
)
|
||||
}
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
/*
|
||||
* 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.wot
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
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.reqs.SubscriptionListener
|
||||
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.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.RelayListRecommendationProcessor
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Fetches kind-0 (profile metadata) and kind-3 (contact list) events for a
|
||||
* set of authors using the NIP-65 **outbox model**:
|
||||
*
|
||||
* 1. **Phase 1 — discover.** Ask the configured index relays (Purple Pages,
|
||||
* Coracle, nos.lol, …) for the kind-10002 of each author. Merge with
|
||||
* already-cached 10002s from [OutboxCacheGateway].
|
||||
*
|
||||
* 2. **Phase 2 — pick + fetch.** Feed the author → write-relays map into
|
||||
* [RelayListRecommendationProcessor.reliableRelaySetFor] to get a
|
||||
* minimal, popularity-based set of relays that covers every author.
|
||||
* Open one subscription per recommended relay, filtered to that
|
||||
* relay's authors, for kind-0 and/or kind-3.
|
||||
*
|
||||
* 3. **Phase 3 — fallback.** For any author whose kind-10002 the network
|
||||
* never returned, fall back to the index-relay REQ (preserves the
|
||||
* current behaviour so a coldish account doesn't lose signal).
|
||||
*
|
||||
* The dispatcher is single-scoped (one instance per account) so its dedup
|
||||
* set survives across follow-set diffs. Call [clear] on account switch.
|
||||
*
|
||||
* @param client shared [INostrClient] used for every subscription
|
||||
* @param scope account-lifetime scope; cancelling it cancels in-flight REQs
|
||||
* @param indexRelays lazy accessor so a change through the settings UI
|
||||
* takes effect on next fetch without recreating the
|
||||
* dispatcher
|
||||
* @param gateway platform-specific cache adapter (see [OutboxCacheGateway])
|
||||
* @param perRelayTimeoutMs how long each REQ waits for its EOSE. Under
|
||||
* the plan (2026-07-06): 4 s.
|
||||
* @param overallTimeoutMs cap on the whole two-phase fetch. Belt against
|
||||
* a phase getting stuck. Under the plan: 8 s.
|
||||
* @param maxOutboxRelaysPerAuthor bound author → write-relays to the first N
|
||||
* relays after the [RelayListRecommendationProcessor]
|
||||
* chooses them, to keep fan-out predictable
|
||||
*/
|
||||
class OutboxDispatcher(
|
||||
private val client: INostrClient,
|
||||
private val scope: CoroutineScope,
|
||||
private val indexRelays: () -> Set<NormalizedRelayUrl>,
|
||||
private val gateway: OutboxCacheGateway,
|
||||
private val perRelayTimeoutMs: Long = 4_000L,
|
||||
private val overallTimeoutMs: Long = 20_000L,
|
||||
@Suppress("UNUSED_PARAMETER") maxOutboxRelaysPerAuthor: Int = 5,
|
||||
) {
|
||||
/**
|
||||
* Pubkeys we've already successfully fetched kind-3 for this session
|
||||
* (Phase 1 or Phase 2 returned events for them). Skipping a second
|
||||
* fetch is safe because a churn event from a subsequent kind-3
|
||||
* republication still reaches [OutboxCacheGateway.onDiscoveredEvent]
|
||||
* via other subscriptions (feed, notifications).
|
||||
*/
|
||||
private val kind3Succeeded = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Pubkeys we've already successfully fetched kind-0 for this session.
|
||||
*/
|
||||
private val kind0Succeeded = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Currently-in-flight authors — prevents rapid re-fire of the same
|
||||
* fetch. Distinct from [kind3Succeeded]/[kind0Succeeded]: a zero-EOSE
|
||||
* timeout rolls out of this set (allowing retry) instead of
|
||||
* permanently marking the pubkey as done.
|
||||
*/
|
||||
private val kind3InFlight = mutableSetOf<HexKey>()
|
||||
private val kind0InFlight = mutableSetOf<HexKey>()
|
||||
|
||||
/**
|
||||
* Outcome counters. All values are aggregated across every phase of
|
||||
* one [fetchKind3Only] / [fetchKind0And3] call. Callers log them for
|
||||
* observability; `amy wot sync --json` also emits them so a caller
|
||||
* can measure whether the outbox path is doing the work vs the
|
||||
* fallback path.
|
||||
*/
|
||||
data class Result(
|
||||
val authorsRequested: Int,
|
||||
val kind10002Received: Int,
|
||||
val kind3Received: Int,
|
||||
val kind0Received: Int,
|
||||
val outboxCoveredAuthors: Int,
|
||||
val fallbackAuthors: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetch kind-3 for every pubkey in [authors] via each author's outbox
|
||||
* relay when known, falling back to index relays otherwise. Suspends
|
||||
* until every phase EOSEs or times out.
|
||||
*/
|
||||
suspend fun fetchKind3Only(authors: Set<HexKey>): Result = run(authors, includeKind0 = false, includeKind3 = true)
|
||||
|
||||
/**
|
||||
* Fetch kind-3 AND kind-0 for every pubkey in [authors]. Same phase
|
||||
* pipeline; a single per-outbox-relay subscription pulls both kinds
|
||||
* so we don't double the connection count.
|
||||
*/
|
||||
suspend fun fetchKind0And3(authors: Set<HexKey>): Result = run(authors, includeKind0 = true, includeKind3 = true)
|
||||
|
||||
/**
|
||||
* Fetch kind-0 only. Used by the metadata preloader when it decides
|
||||
* to bypass the index-relay batch for a specific author (e.g. a
|
||||
* profile screen visit where the author's outbox is already cached).
|
||||
*/
|
||||
suspend fun fetchKind0Only(authors: Set<HexKey>): Result = run(authors, includeKind0 = true, includeKind3 = false)
|
||||
|
||||
/**
|
||||
* Drop every dedup marker. Call on account switch so a fresh account
|
||||
* doesn't inherit the previous account's "already fetched" state.
|
||||
*/
|
||||
fun clear() {
|
||||
kind3Succeeded.clear()
|
||||
kind0Succeeded.clear()
|
||||
kind3InFlight.clear()
|
||||
kind0InFlight.clear()
|
||||
}
|
||||
|
||||
private suspend fun run(
|
||||
authors: Set<HexKey>,
|
||||
includeKind0: Boolean,
|
||||
includeKind3: Boolean,
|
||||
): Result {
|
||||
if (authors.isEmpty()) return zeroResult(0)
|
||||
|
||||
val newForKind3 =
|
||||
if (includeKind3) authors.filter { it !in kind3Succeeded && it !in kind3InFlight }.toSet() else emptySet()
|
||||
val newForKind0 =
|
||||
if (includeKind0) authors.filter { it !in kind0Succeeded && it !in kind0InFlight }.toSet() else emptySet()
|
||||
|
||||
if (newForKind3.isEmpty() && newForKind0.isEmpty()) {
|
||||
Log.d("OutboxDispatcher") { "skip: all authors deduped (succeeded or in-flight)" }
|
||||
return zeroResult(authors.size)
|
||||
}
|
||||
|
||||
kind3InFlight.addAll(newForKind3)
|
||||
kind0InFlight.addAll(newForKind0)
|
||||
|
||||
return try {
|
||||
val result =
|
||||
withTimeoutOrNull(overallTimeoutMs) {
|
||||
doRun(authors, newForKind3, newForKind0, includeKind0, includeKind3)
|
||||
}
|
||||
if (result == null) {
|
||||
Log.w("OutboxDispatcher") { "overall timeout ${overallTimeoutMs}ms exceeded — returning zero result" }
|
||||
zeroResult(authors.size)
|
||||
} else {
|
||||
result
|
||||
}
|
||||
} finally {
|
||||
kind3InFlight.removeAll(newForKind3)
|
||||
kind0InFlight.removeAll(newForKind0)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doRun(
|
||||
allAuthors: Set<HexKey>,
|
||||
newForKind3: Set<HexKey>,
|
||||
newForKind0: Set<HexKey>,
|
||||
includeKind0: Boolean,
|
||||
includeKind3: Boolean,
|
||||
): Result {
|
||||
val relayCounts = FetchCounters()
|
||||
val relaysConfigured = indexRelays()
|
||||
val newTargets = (newForKind3 + newForKind0)
|
||||
|
||||
// Split into "have cached 10002" vs "need Phase 1".
|
||||
val cachedOutbox = mutableMapOf<HexKey, Set<NormalizedRelayUrl>>()
|
||||
val toDiscover = mutableSetOf<HexKey>()
|
||||
for (author in newTargets) {
|
||||
val write =
|
||||
gateway
|
||||
.cachedOutbox(author)
|
||||
?.writeRelaysNorm()
|
||||
.orEmpty()
|
||||
.toSet()
|
||||
if (write.isNotEmpty()) cachedOutbox[author] = write else toDiscover.add(author)
|
||||
}
|
||||
|
||||
Log.d("OutboxDispatcher") {
|
||||
"start authors=${allAuthors.size} newKind3=${newForKind3.size} newKind0=${newForKind0.size} " +
|
||||
"cachedOutbox=${cachedOutbox.size} toDiscover=${toDiscover.size} " +
|
||||
"indexRelays=${relaysConfigured.size}"
|
||||
}
|
||||
|
||||
// Phase 1 — discover kind-10002 on the index relays. runPhase1
|
||||
// returns pubkey → list of (event, relay) so we can pick the
|
||||
// newest event (some relays return outdated 10002s).
|
||||
val discovered = mutableMapOf<HexKey, Set<NormalizedRelayUrl>>()
|
||||
if (toDiscover.isNotEmpty() && relaysConfigured.isNotEmpty()) {
|
||||
val (phase1Events, phase1EosedCount) = runPhase1(toDiscover, relaysConfigured)
|
||||
phase1Events.forEach { (pubkey, results) ->
|
||||
val newest = results.maxByOrNull { it.first.createdAt } ?: return@forEach
|
||||
gateway.onOutboxDiscovered(newest.first, newest.second)
|
||||
val write =
|
||||
newest.first
|
||||
.writeRelaysNorm()
|
||||
.orEmpty()
|
||||
.toSet()
|
||||
if (write.isNotEmpty()) discovered[pubkey] = write
|
||||
}
|
||||
relayCounts.kind10002 += phase1Events.values.sumOf { it.size }
|
||||
Log.d("OutboxDispatcher") {
|
||||
"phase1 done eosed=$phase1EosedCount/${relaysConfigured.size} " +
|
||||
"10002-events=${relayCounts.kind10002} discovered=${discovered.size}"
|
||||
}
|
||||
}
|
||||
|
||||
val outboxMap = cachedOutbox + discovered
|
||||
val authorsWithOutbox = outboxMap.keys
|
||||
val fallbackAuthors = newTargets - authorsWithOutbox
|
||||
|
||||
// Phase 2 — per-outbox-relay REQ, kind-3 and/or kind-0. All
|
||||
// recommended relays are subscribed in a single call so the pool
|
||||
// fans out in parallel; a per-relay 4 s timeout bounds the wait
|
||||
// regardless of how many relays the recommendation set contains.
|
||||
val kind3BeforePhase2 = relayCounts.kind3
|
||||
val kind0BeforePhase2 = relayCounts.kind0
|
||||
if (outboxMap.isNotEmpty() && (includeKind0 || includeKind3)) {
|
||||
val recommendations = RelayListRecommendationProcessor.reliableRelaySetFor(outboxMap)
|
||||
val phase2FilterMap =
|
||||
recommendations
|
||||
.mapNotNull { rec ->
|
||||
val authorsForThisRelay =
|
||||
rec.users.intersect(
|
||||
if (includeKind0 && includeKind3) {
|
||||
newTargets
|
||||
} else if (includeKind3) {
|
||||
newForKind3
|
||||
} else {
|
||||
newForKind0
|
||||
},
|
||||
)
|
||||
if (authorsForThisRelay.isEmpty()) return@mapNotNull null
|
||||
val kinds =
|
||||
buildList {
|
||||
if (includeKind0 && authorsForThisRelay.any { it in newForKind0 }) add(MetadataEvent.KIND)
|
||||
if (includeKind3 && authorsForThisRelay.any { it in newForKind3 }) add(ContactListEvent.KIND)
|
||||
}
|
||||
if (kinds.isEmpty()) return@mapNotNull null
|
||||
rec.relay to
|
||||
authorsForThisRelay.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = kinds,
|
||||
authors = chunk,
|
||||
limit = chunk.size * kinds.size,
|
||||
)
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
Log.d("OutboxDispatcher") { "phase2 recommendations=${recommendations.size} relays-with-work=${phase2FilterMap.size}" }
|
||||
if (phase2FilterMap.isNotEmpty()) {
|
||||
runPhase2Or3(phase2FilterMap, counters = relayCounts)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("OutboxDispatcher") {
|
||||
"phase2 done kind3=${relayCounts.kind3 - kind3BeforePhase2} kind0=${relayCounts.kind0 - kind0BeforePhase2}"
|
||||
}
|
||||
|
||||
// Phase 3 — index-relay fallback for authors with no 10002.
|
||||
val kind3BeforePhase3 = relayCounts.kind3
|
||||
val kind0BeforePhase3 = relayCounts.kind0
|
||||
if (fallbackAuthors.isNotEmpty() && relaysConfigured.isNotEmpty()) {
|
||||
val kinds =
|
||||
buildList {
|
||||
if (includeKind0 && fallbackAuthors.any { it in newForKind0 }) add(MetadataEvent.KIND)
|
||||
if (includeKind3 && fallbackAuthors.any { it in newForKind3 }) add(ContactListEvent.KIND)
|
||||
}
|
||||
if (kinds.isNotEmpty()) {
|
||||
Log.d("OutboxDispatcher") { "phase3 fallback authors=${fallbackAuthors.size} kinds=$kinds relays=${relaysConfigured.size}" }
|
||||
val filters =
|
||||
fallbackAuthors.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = kinds,
|
||||
authors = chunk,
|
||||
limit = chunk.size * kinds.size,
|
||||
)
|
||||
}
|
||||
val phase3FilterMap = relaysConfigured.associateWith { filters }
|
||||
runPhase2Or3(phase3FilterMap, counters = relayCounts)
|
||||
Log.d("OutboxDispatcher") {
|
||||
"phase3 done kind3=${relayCounts.kind3 - kind3BeforePhase3} kind0=${relayCounts.kind0 - kind0BeforePhase3}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Promote to succeeded — a completed run means we've asked; even if
|
||||
// an author had no publishable data we don't need to keep pounding
|
||||
// relays every follow-set change.
|
||||
kind3Succeeded.addAll(newForKind3)
|
||||
kind0Succeeded.addAll(newForKind0)
|
||||
|
||||
return Result(
|
||||
authorsRequested = allAuthors.size,
|
||||
kind10002Received = relayCounts.kind10002,
|
||||
kind3Received = relayCounts.kind3,
|
||||
kind0Received = relayCounts.kind0,
|
||||
outboxCoveredAuthors = authorsWithOutbox.size,
|
||||
fallbackAuthors = fallbackAuthors.size,
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private class FetchCounters {
|
||||
var kind10002 = 0
|
||||
var kind3 = 0
|
||||
var kind0 = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1 helper. Returns a map of pubkey → list of (event, relay) so
|
||||
* caller can pick the newest, plus a boolean-per-relay EOSE indicator
|
||||
* (currently ignored but recorded for future retry telemetry).
|
||||
*/
|
||||
private suspend fun runPhase1(
|
||||
pubkeys: Set<HexKey>,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
): Pair<Map<HexKey, List<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>>, Int> {
|
||||
val filters =
|
||||
pubkeys.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = listOf(AdvertisedRelayListEvent.KIND),
|
||||
authors = chunk,
|
||||
limit = chunk.size,
|
||||
)
|
||||
}
|
||||
val filterMap = relays.associateWith { filters }
|
||||
|
||||
val received = mutableMapOf<HexKey, MutableList<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>>()
|
||||
val gate = BatchEoseGate(scope, target = relays.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (event is AdvertisedRelayListEvent && event.pubKey in pubkeys) {
|
||||
received
|
||||
.getOrPut(event.pubKey) { mutableListOf() }
|
||||
.add(event to relay)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
val subId = newSubId()
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
val eosedCount = gate.awaitAll(perRelayTimeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
|
||||
return received to eosedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 or Phase 3 helper. Opens a single subscription that
|
||||
* fans out to every relay in [filterMap] (Phase 2 uses per-outbox-
|
||||
* relay filters; Phase 3 uses the index-relay set with a shared
|
||||
* fallback filter). All relays are subscribed in parallel — the
|
||||
* per-relay timeout bounds the total wait regardless of relay count.
|
||||
*/
|
||||
private suspend fun runPhase2Or3(
|
||||
filterMap: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
counters: FetchCounters,
|
||||
) {
|
||||
val gate = BatchEoseGate(scope, target = filterMap.size)
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
when (event.kind) {
|
||||
MetadataEvent.KIND -> counters.kind0++
|
||||
ContactListEvent.KIND -> counters.kind3++
|
||||
}
|
||||
gateway.onDiscoveredEvent(event, relay)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gate.notifyEose(relay)
|
||||
}
|
||||
}
|
||||
|
||||
val subId = newSubId()
|
||||
client.subscribe(subId, filterMap, listener)
|
||||
gate.awaitAll(perRelayTimeoutMs)
|
||||
client.unsubscribe(subId)
|
||||
}
|
||||
|
||||
private fun zeroResult(requested: Int) =
|
||||
Result(
|
||||
authorsRequested = requested,
|
||||
kind10002Received = 0,
|
||||
kind3Received = 0,
|
||||
kind0Received = 0,
|
||||
outboxCoveredAuthors = 0,
|
||||
fallbackAuthors = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* KMP-safe EOSE aggregator (same as FeedMetadataCoordinator's local
|
||||
* one — duplicated locally instead of exported to keep the fix scope
|
||||
* minimal). Per-relay `onEose` callbacks may run on any dispatcher
|
||||
* (typically `Dispatchers.IO`) so we funnel them through a Channel
|
||||
* and let a single consumer coroutine own the `seen` set.
|
||||
*/
|
||||
private class BatchEoseGate(
|
||||
private val scope: CoroutineScope,
|
||||
private val target: Int,
|
||||
) {
|
||||
private val incoming = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
|
||||
private val done = CompletableDeferred<Unit>()
|
||||
|
||||
@Volatile private var lastCount = 0
|
||||
|
||||
fun notifyEose(relay: NormalizedRelayUrl) {
|
||||
incoming.trySend(relay)
|
||||
}
|
||||
|
||||
suspend fun awaitAll(timeoutMs: Long): Int {
|
||||
if (target <= 0) return 0
|
||||
val consumer =
|
||||
scope.launch {
|
||||
val seen = mutableSetOf<NormalizedRelayUrl>()
|
||||
for (relay in incoming) {
|
||||
if (seen.add(relay)) {
|
||||
lastCount = seen.size
|
||||
if (seen.size >= target && !done.isCompleted) {
|
||||
done.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
withTimeoutOrNull(timeoutMs) { done.await() }
|
||||
incoming.close()
|
||||
consumer.join()
|
||||
return lastCount
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.commons.wot
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Friends-of-friends trust score computed from the active user's follow
|
||||
* graph. For every pubkey X the score is the count of accounts in the
|
||||
* active user's follow set who also follow X.
|
||||
*
|
||||
* ## Reactivity model
|
||||
*
|
||||
* Scores are exposed via a Compose-observable [SnapshotStateMap]. Consumers
|
||||
* that read a **single key** (`scores[pubkey]`) recompose only when that
|
||||
* key changes — this is `SnapshotStateMap`'s built-in per-key observation
|
||||
* and applies whether or not the writer wraps in a snapshot block.
|
||||
* Consumers that iterate the map or read `size` recompose on **any**
|
||||
* mutation.
|
||||
*
|
||||
* The writer wraps each op in [Snapshot.withMutableSnapshot] to *coalesce*
|
||||
* an op's writes into a single Compose commit — so a Kind3 op that
|
||||
* touches N reverse-index targets emits one invalidation, not N. It does
|
||||
* not confer additional per-key isolation on top of `SnapshotStateMap`'s
|
||||
* own semantics.
|
||||
*
|
||||
* ## Concurrency
|
||||
*
|
||||
* All internal state is mutated from a single writer coroutine
|
||||
* ([writerLoop]) on [writerDispatcher] (default [Dispatchers.Default]), so
|
||||
* concurrent [applyKind3] / [onFollowSetChange] / [markReadyOnce] calls
|
||||
* from different threads are serialized without extra locking.
|
||||
*
|
||||
* ## Lifecycle
|
||||
*
|
||||
* Call [close] on account switch / logout so the writer coroutine exits
|
||||
* and the ops channel is released. Post-close ops are silently dropped.
|
||||
*/
|
||||
@Stable
|
||||
class WoTService(
|
||||
private val scope: CoroutineScope,
|
||||
/** Dispatcher for the internal writer coroutine. Tests override with `Dispatchers.Unconfined` for synchronous behavior. */
|
||||
private val writerDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : AutoCloseable {
|
||||
/**
|
||||
* Sparse per-pubkey score map. Entries with count 0 are removed
|
||||
* (not stored as 0) to keep the Compose subscriber tracking tight.
|
||||
* Callers should read as `scores[pubkey] ?: 0`.
|
||||
*/
|
||||
private val _scores: SnapshotStateMap<HexKey, Int> = mutableStateMapOf()
|
||||
val scores: SnapshotStateMap<HexKey, Int> get() = _scores
|
||||
|
||||
// Reverse index: target pubkey → set of my-follows who follow them.
|
||||
private val reverseIndex = HashMap<HexKey, MutableSet<HexKey>>()
|
||||
|
||||
// Per-follower cached follow set (excluding self / follower itself).
|
||||
// Enables diff-based updates when a follower republishes their kind-3.
|
||||
private val perFollowerSnapshot = HashMap<HexKey, Set<HexKey>>()
|
||||
|
||||
private var myFollows: Set<HexKey> = emptySet()
|
||||
private var selfPubkey: HexKey? = null
|
||||
private var readyMarked = false
|
||||
private var disabled = false
|
||||
|
||||
private val _isReady = MutableStateFlow(false)
|
||||
val isReady: StateFlow<Boolean> = _isReady.asStateFlow()
|
||||
|
||||
private val _isDisabled = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* True when the active user's follow set exceeds [MAX_FOLLOWS] and WoT
|
||||
* scoring has been shut off. Callers that dispatch the batch kind-3
|
||||
* REQ must gate on this — a disabled service silently accepts and
|
||||
* ignores all subsequent [applyKind3] calls, so a caller that keeps
|
||||
* flooding kind-3s wastes bandwidth for nothing.
|
||||
*/
|
||||
val isDisabled: StateFlow<Boolean> = _isDisabled.asStateFlow()
|
||||
|
||||
private val ops = Channel<Op>(capacity = Channel.UNLIMITED)
|
||||
|
||||
init {
|
||||
scope.launch(writerDispatcher) { writerLoop() }
|
||||
}
|
||||
|
||||
/** Update the active user's follow set (and self pubkey). */
|
||||
fun onFollowSetChange(
|
||||
newFollows: Set<HexKey>,
|
||||
newSelf: HexKey?,
|
||||
) {
|
||||
ops.trySend(Op.FollowSet(newFollows, newSelf))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingest a kind-3 event for a followed pubkey. Ignored when the
|
||||
* event's author isn't in the current follow set. Follow lists are
|
||||
* capped at [MAX_FOLLOWS_PER_EVENT] to bound CPU cost against a
|
||||
* hostile publisher.
|
||||
*/
|
||||
fun applyKind3(
|
||||
follower: HexKey,
|
||||
follows: Set<HexKey>,
|
||||
) {
|
||||
val bounded =
|
||||
if (follows.size > MAX_FOLLOWS_PER_EVENT) {
|
||||
follows.take(MAX_FOLLOWS_PER_EVENT).toSet()
|
||||
} else {
|
||||
follows
|
||||
}
|
||||
ops.trySend(Op.Kind3(follower, bounded))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the service as ready to render badges. Idempotent — subsequent
|
||||
* calls are no-ops. Trigger from the first EOSE on the batch kind-3
|
||||
* REQ, or from a startup-timeout fallback, whichever fires first.
|
||||
*/
|
||||
fun markReadyOnce() {
|
||||
ops.trySend(Op.MarkReady)
|
||||
}
|
||||
|
||||
/** Clear all state. Used on logout / account switch. */
|
||||
fun clear() {
|
||||
ops.trySend(Op.Clear)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain [Map] snapshot of current scores for headless
|
||||
* callers (e.g. the amy CLI) that don't run inside a Compose
|
||||
* composition. O(N) copy from the underlying [SnapshotStateMap].
|
||||
*/
|
||||
fun scoresSnapshot(): Map<HexKey, Int> = HashMap(_scores)
|
||||
|
||||
private sealed interface Op {
|
||||
data class FollowSet(
|
||||
val newFollows: Set<HexKey>,
|
||||
val newSelf: HexKey?,
|
||||
) : Op
|
||||
|
||||
data class Kind3(
|
||||
val follower: HexKey,
|
||||
val follows: Set<HexKey>,
|
||||
) : Op
|
||||
|
||||
data object MarkReady : Op
|
||||
|
||||
data object Clear : Op
|
||||
}
|
||||
|
||||
private suspend fun writerLoop() {
|
||||
for (op in ops) {
|
||||
Snapshot.withMutableSnapshot {
|
||||
when (op) {
|
||||
is Op.FollowSet -> handleFollowSet(op.newFollows, op.newSelf)
|
||||
is Op.Kind3 -> handleKind3(op.follower, op.follows)
|
||||
Op.MarkReady -> handleMarkReady()
|
||||
Op.Clear -> handleClear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFollowSet(
|
||||
newFollows: Set<HexKey>,
|
||||
newSelf: HexKey?,
|
||||
) {
|
||||
// Guardrail — massive follow lists don't produce a useful WoT signal.
|
||||
// Do this BEFORE assigning myFollows so applyKind3's `follower in
|
||||
// myFollows` gate doesn't accidentally credit anyone once the
|
||||
// caller keeps pumping kind-3s in (a caller that fails to gate on
|
||||
// isDisabled would otherwise fully repopulate reverseIndex/_scores
|
||||
// and defeat the guardrail — see PR #3483 review finding 2).
|
||||
if (newFollows.size > MAX_FOLLOWS) {
|
||||
reverseIndex.clear()
|
||||
perFollowerSnapshot.clear()
|
||||
_scores.clear()
|
||||
myFollows = emptySet()
|
||||
selfPubkey = newSelf
|
||||
disabled = true
|
||||
_isDisabled.value = true
|
||||
handleMarkReady()
|
||||
return
|
||||
}
|
||||
|
||||
val removed = myFollows - newFollows
|
||||
myFollows = newFollows
|
||||
selfPubkey = newSelf
|
||||
// Follow set is back within limits (or was already) — re-enable if
|
||||
// we had previously flipped disabled=true.
|
||||
if (disabled) {
|
||||
disabled = false
|
||||
_isDisabled.value = false
|
||||
}
|
||||
|
||||
// Uncredit any follower we're no longer following.
|
||||
removed.forEach { follower ->
|
||||
val prevFollows = perFollowerSnapshot.remove(follower) ?: return@forEach
|
||||
prevFollows.forEach { target ->
|
||||
val set = reverseIndex[target] ?: return@forEach
|
||||
set.remove(follower)
|
||||
if (set.isEmpty()) reverseIndex.remove(target)
|
||||
updateScore(target)
|
||||
}
|
||||
}
|
||||
// Added followers will be credited when their kind-3 arrives via applyKind3.
|
||||
}
|
||||
|
||||
private fun handleKind3(
|
||||
follower: HexKey,
|
||||
follows: Set<HexKey>,
|
||||
) {
|
||||
if (disabled) return
|
||||
if (follower !in myFollows) return
|
||||
|
||||
val old = perFollowerSnapshot[follower] ?: emptySet()
|
||||
val excluded = setOfNotNull(follower, selfPubkey)
|
||||
val effective = follows - excluded
|
||||
val added = effective - old
|
||||
val removed = old - effective
|
||||
perFollowerSnapshot[follower] = effective
|
||||
|
||||
added.forEach { target ->
|
||||
reverseIndex.getOrPut(target) { hashSetOf() }.add(follower)
|
||||
updateScore(target)
|
||||
}
|
||||
removed.forEach { target ->
|
||||
val set = reverseIndex[target] ?: return@forEach
|
||||
set.remove(follower)
|
||||
if (set.isEmpty()) reverseIndex.remove(target)
|
||||
updateScore(target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMarkReady() {
|
||||
if (!readyMarked) {
|
||||
readyMarked = true
|
||||
_isReady.value = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClear() {
|
||||
reverseIndex.clear()
|
||||
perFollowerSnapshot.clear()
|
||||
_scores.clear()
|
||||
myFollows = emptySet()
|
||||
selfPubkey = null
|
||||
readyMarked = false
|
||||
_isReady.value = false
|
||||
disabled = false
|
||||
_isDisabled.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the writer coroutine and release the ops channel. Call from
|
||||
* account-switch / logout paths. Post-close [applyKind3] / [onFollowSetChange]
|
||||
* / [markReadyOnce] / [clear] calls are silently dropped (the `trySend`
|
||||
* on a closed [Channel] fails without throwing).
|
||||
*
|
||||
* Idempotent; safe to call multiple times.
|
||||
*/
|
||||
override fun close() {
|
||||
ops.close()
|
||||
}
|
||||
|
||||
private fun updateScore(target: HexKey) {
|
||||
val n = reverseIndex[target]?.size ?: 0
|
||||
if (n > 0) _scores[target] = n else _scores.remove(target)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Skip WoT entirely for accounts following more than this many pubkeys. */
|
||||
const val MAX_FOLLOWS = 2000
|
||||
|
||||
/** Cap follows per kind-3 event to bound CPU cost against a hostile publisher. */
|
||||
const val MAX_FOLLOWS_PER_EVENT = 5000
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user