mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
fix: propagate relay acceptances into gift-wrap chains for chat relay icons
Chat rows render the inner rumor note of a NIP-17 message, but relay attribution only landed there through narrow windows, so accepted relays often never showed as icons: - Duplicate deliveries were stranded on the wrap: a gift wrap re-delivered by a second relay hit the duplicate branch of consumeRegularEvent, which tagged the outer wrap note only and never re-processed the event. Extract the OK-path drilling (wrap -> seal -> rumor) into LocalCache.addRelayToNoteAndInners and call it from both the OK confirmation path (markAsSeen) and the duplicate EVENT path, replacing CacheClientConnector's private copy. - Cross-thread visibility: Note.event, Note.relays, Note.flowSet and the innerEventId of GiftWrapEvent/SealedRumorEvent are written by decrypt/index coroutines and read lock-free on relay socket threads; a stale read parks an acceptance on the outer envelope permanently. Mark them @Volatile. - Orphaned UI flows: RenderClosedRelayList/RenderAllRelayList and createMustShowExpandButtonFlows captured note.flow().relays.stateFlow once in remember/stateIn; MemoryTrimmingService.cleanObservers destroys the unobserved NoteFlowSet while the lifecycle is stopped, so resumed rows never saw another relay update. Wrap in a cold flow that re-resolves flow() on every collection start. - Indexing latency: sent DMs waited for the ~1s newEventBundles batcher before the self-wrap was unwrapped and the message reached the chatroom, parking early OKs on the wrap. broadcastPrivately and sendNip04PrivateMessage now run the EventProcessor on the freshly consumed note immediately; the batched re-delivery is idempotent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YEiq1NMK3q12KGQ2yYhPEp
This commit is contained in:
@@ -2521,6 +2521,11 @@ class Account(
|
||||
|
||||
cache.justConsumeMyOwnEvent(newEvent)
|
||||
client.publish(newEvent, outboxRelays.flow.value + destinationRelays)
|
||||
|
||||
// Index into the chatroom immediately (same rationale as
|
||||
// broadcastPrivately) instead of waiting for the newEventBundles
|
||||
// batcher; the later batched re-delivery is deduped by the chatroom.
|
||||
cache.getNoteIfExists(newEvent.id)?.let { newNotesPreProcessor.consume(it) }
|
||||
}
|
||||
|
||||
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
|
||||
@@ -2573,6 +2578,14 @@ class Account(
|
||||
val relayList = computeRelayListToBroadcast(wrap)
|
||||
client.publish(wrap, relayList)
|
||||
}
|
||||
|
||||
// Unwrap and index the self-copy right away instead of waiting for the
|
||||
// newEventBundles batcher (up to ~1s): the sent message reaches the
|
||||
// chatroom before the first relay OK, so acceptances land directly on
|
||||
// the rumor note the chat renders instead of parking on the wrap. The
|
||||
// batcher re-delivers this note later; the processor's replay path and
|
||||
// the chatroom add are both idempotent.
|
||||
mineNote?.let { newNotesPreProcessor.consume(it) }
|
||||
}
|
||||
|
||||
// --- Marmot Group Messaging ---
|
||||
|
||||
@@ -237,6 +237,7 @@ import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
@@ -807,7 +808,11 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
// A gift wrap re-delivered by another relay is a duplicate (returns
|
||||
// false below and is never re-processed), so drill into the already
|
||||
// unwrapped chain here — otherwise the relay never reaches the
|
||||
// rumor note that the chat UI actually renders.
|
||||
addRelayToNoteAndInners(note, relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
@@ -3145,7 +3150,28 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
note?.addRelay(relay)
|
||||
note?.let { addRelayToNoteAndInners(it, relay) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [relay] to [note] and to every already-unwrapped inner note of its
|
||||
* gift-wrap chain (wrap → seal → rumor). The chat UI renders the inner
|
||||
* rumor, so a relay recorded only on the outer envelope never surfaces as
|
||||
* an icon. Inner notes that don't exist yet are not lost: the unwrap path
|
||||
* copies the envelope's relays down via [copyRelaysFromTo] when it runs.
|
||||
*/
|
||||
fun addRelayToNoteAndInners(
|
||||
note: Note,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
note.addRelay(relay)
|
||||
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is HasInnerEvent) {
|
||||
noteEvent.innerEventId?.let { innerId ->
|
||||
getNoteIfExists(innerId)?.let { addRelayToNoteAndInners(it, relay) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
|
||||
+3
-45
@@ -21,14 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.EventCollector
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayInsertConfirmationCollector
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
|
||||
class CacheClientConnector(
|
||||
val client: INostrClient,
|
||||
@@ -39,53 +34,16 @@ class CacheClientConnector(
|
||||
cache.justConsume(event, relay, false)
|
||||
}
|
||||
|
||||
// markAsSeen drills into the gift-wrap chain (wrap → seal → rumor) via
|
||||
// LocalCache.addRelayToNoteAndInners, so an OK acceptance for a wrap also
|
||||
// tags the inner rumor note the chat UI renders.
|
||||
val confirmationWatcher =
|
||||
RelayInsertConfirmationCollector(client) { eventId, relay ->
|
||||
cache.markAsSeen(eventId, relay.url)
|
||||
markAsSeen(eventId, relay.url)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
receiver.destroy()
|
||||
confirmationWatcher.destroy()
|
||||
}
|
||||
|
||||
private fun markAsSeen(
|
||||
eventId: HexKey,
|
||||
info: NormalizedRelayUrl,
|
||||
) {
|
||||
val note = LocalCache.getNoteIfExists(eventId)
|
||||
if (note != null) {
|
||||
note.addRelay(info)
|
||||
markAsSeenInner(note, info)
|
||||
}
|
||||
}
|
||||
|
||||
private fun markAsSeenInner(
|
||||
note: Note,
|
||||
info: NormalizedRelayUrl,
|
||||
) {
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is GiftWrapEvent) {
|
||||
val innerEvent = noteEvent.innerEventId
|
||||
if (innerEvent != null) {
|
||||
val innerNote = cache.getNoteIfExists(innerEvent)
|
||||
if (innerNote != null) {
|
||||
innerNote.addRelay(info)
|
||||
markAsSeenInner(innerNote, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (noteEvent is SealedRumorEvent) {
|
||||
val innerEvent = noteEvent.innerEventId
|
||||
if (innerEvent != null) {
|
||||
val innerNote = cache.getNoteIfExists(innerEvent)
|
||||
if (innerNote != null) {
|
||||
innerNote.addRelay(info)
|
||||
markAsSeenInner(innerNote, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.sample
|
||||
|
||||
@@ -94,11 +96,13 @@ fun RenderAllRelayList(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Cold wrapper: `flow()` must be re-resolved on every collection start.
|
||||
// A memory trim destroys the NoteFlowSet while the lifecycle is stopped;
|
||||
// a stateFlow captured in remember would then be orphaned and never see
|
||||
// another relay update.
|
||||
val flow =
|
||||
remember(baseNote) {
|
||||
baseNote
|
||||
.flow()
|
||||
.relays.stateFlow
|
||||
flow { emitAll(baseNote.flow().relays.stateFlow) }
|
||||
.sample(500)
|
||||
.map { it.note.relays }
|
||||
.distinctUntilChanged()
|
||||
@@ -122,11 +126,10 @@ fun RenderClosedRelayList(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Cold wrapper for the same trim-survival reason as RenderAllRelayList.
|
||||
val flow =
|
||||
remember(baseNote) {
|
||||
baseNote
|
||||
.flow()
|
||||
.relays.stateFlow
|
||||
flow { emitAll(baseNote.flow().relays.stateFlow) }
|
||||
.sample(500)
|
||||
.map { it.note.relays.take(3) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
+6
-4
@@ -188,7 +188,9 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.combineTransform
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
@@ -652,10 +654,10 @@ class AccountViewModel(
|
||||
|
||||
fun createMustShowExpandButtonFlows(note: Note): StateFlow<Boolean> =
|
||||
noteMustShowExpandButtonFlows.get(note)
|
||||
?: note
|
||||
.flow()
|
||||
.relays
|
||||
.stateFlow
|
||||
// Cold wrapper: WhileSubscribed drops the upstream when idle and a
|
||||
// memory trim may destroy the NoteFlowSet in between; re-resolving
|
||||
// `flow()` on every restart keeps this cached StateFlow alive.
|
||||
?: flow { emitAll(note.flow().relays.stateFlow) }
|
||||
.map { it.note.relays.size > 3 }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
|
||||
@@ -115,6 +115,10 @@ open class Note(
|
||||
|
||||
// These fields are only available after the Text Note event is received.
|
||||
// They are immutable after that.
|
||||
// `@Volatile`: written by the decrypt/index pipeline (IO coroutines) and
|
||||
// read by the relay socket thread (OK confirmations drilling into the
|
||||
// gift-wrap chain) — a stale read strands a relay on the outer wrap.
|
||||
@Volatile
|
||||
var event: Event? = null
|
||||
var author: User? = null
|
||||
var replyTo: List<Note>? = null
|
||||
@@ -248,6 +252,9 @@ open class Note(
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
// `@Volatile`: written under [syncLock] but read lock-free from the relay
|
||||
// socket thread and from [LocalCache.copyRelaysFromTo] on IO coroutines.
|
||||
@Volatile
|
||||
var relays = listOf<NormalizedRelayUrl>()
|
||||
private set
|
||||
|
||||
@@ -1321,6 +1328,9 @@ open class Note(
|
||||
return false
|
||||
}
|
||||
|
||||
// `@Volatile`: created/destroyed under [syncLock] but read lock-free by
|
||||
// every `flowSet?.x?.invalidateData()` call on writer threads.
|
||||
@Volatile
|
||||
var flowSet: NoteFlowSet? = null
|
||||
|
||||
fun createOrDestroyFlowSync(create: Boolean) =
|
||||
|
||||
+4
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
@Immutable
|
||||
class SealedRumorEvent(
|
||||
@@ -40,8 +41,11 @@ class SealedRumorEvent(
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
HasInnerEvent {
|
||||
// `@Volatile`: set by the decrypting coroutine in [unsealThrowing], read
|
||||
// by relay socket threads walking the wrap → seal → rumor chain.
|
||||
@kotlinx.serialization.Transient
|
||||
@kotlin.jvm.Transient
|
||||
@Volatile
|
||||
override var innerEventId: HexKey? = null
|
||||
|
||||
fun copyNoContent(): SealedRumorEvent {
|
||||
|
||||
+4
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
@Immutable
|
||||
open class GiftWrapEvent(
|
||||
@@ -46,8 +47,11 @@ open class GiftWrapEvent(
|
||||
kind: Int = KIND,
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig),
|
||||
HasInnerEvent {
|
||||
// `@Volatile`: set by the decrypting coroutine in [unwrapThrowing], read
|
||||
// by relay socket threads walking the wrap → seal → rumor chain.
|
||||
@kotlinx.serialization.Transient
|
||||
@kotlin.jvm.Transient
|
||||
@Volatile
|
||||
override var innerEventId: HexKey? = null
|
||||
|
||||
open fun copyNoContent(): GiftWrapEvent {
|
||||
|
||||
Reference in New Issue
Block a user