Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779

This commit is contained in:
Claude
2026-07-11 00:21:29 +00:00
13 changed files with 223 additions and 59 deletions
@@ -2878,6 +2878,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>) {
@@ -2930,6 +2935,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 ---
@@ -2976,6 +2989,10 @@ class Account(
Log.d("MarmotDbg") {
"sendMarmotGroupMessage: built outer kind:${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}"
}
// Link the envelope to the inner message we just encrypted so relay
// OK acceptances drill down to the note the chat renders (see
// LocalCache.addRelayToNoteAndInners).
outbound.signedEvent.innerEventId = innerEvent.id
cache.justConsumeMyOwnEvent(outbound.signedEvent)
// Sending a message moves the group out of "New Requests" into
// "Known" — do this eagerly before relay round-trip so the UI
@@ -239,6 +239,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
@@ -839,7 +840,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.
@@ -1493,6 +1498,11 @@ object LocalCache : ILocalCache, ICacheProvider {
): Boolean {
val note = getOrCreateNote(event.id)
if (relay != null) {
getOrCreateUser(event.pubKey).addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return false
@@ -1523,6 +1533,11 @@ object LocalCache : ILocalCache, ICacheProvider {
): Boolean {
val note = getOrCreateNote(event.id)
if (relay != null) {
getOrCreateUser(event.pubKey).addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return false
@@ -1554,6 +1569,14 @@ object LocalCache : ILocalCache, ICacheProvider {
): Boolean {
val note = getOrCreateNote(event.id)
// Approval notes are badge-rendered directly in community feeds
// (BadgeBox has no repost-style indirection for them), so without
// this attribution their relay list stays empty forever.
if (relay != null) {
getOrCreateUser(event.pubKey).addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return false
@@ -3177,7 +3200,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.
@@ -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)
}
}
}
}
}
@@ -27,6 +27,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -153,6 +154,18 @@ fun isNotificationRoute(uri: String) = uri.startsWith("notifications", true) ||
fun isHashtagRoute(uri: String) = uri.startsWith("hashtag?id=") || uri.startsWith("nostr:hashtag?id=")
/**
* A markdown link target that is a bare hashtag fragment, e.g. the `#NostrMultiplayerGames` in
* `[Games](#NostrMultiplayerGames)`. Returns the tag without the leading `#`, or null when the
* uri is anything else (full URLs with anchors don't start with `#`). Reuses the same character
* class that linkifies #hashtags in plain text, so validity matches how tags parse everywhere else.
*/
fun fragmentHashtagOrNull(uri: String): String? {
val match = RichTextParser.hashTagsPattern.matchEntire(uri) ?: return null
if (!match.groups[2]?.value.isNullOrEmpty()) return null
return match.groups[1]?.value
}
fun isUrlRoute(uri: String) = uri.startsWith("url?id=") || uri.startsWith("nostr:url?id=")
fun isConnectedAppRoute(uri: String) = uri.startsWith("connectedapp?coordinate=") || uri.startsWith("nostr:connectedapp?coordinate=")
@@ -199,6 +212,9 @@ fun uriToRoute(
if (isHashtagRoute(uri)) {
return Route.Hashtag(uri.removePrefix(NOSTR_URI_PREFIX).removePrefix("hashtag?id=").lowercase())
}
fragmentHashtagOrNull(uri)?.let {
return Route.Hashtag(it.lowercase())
}
if (isUrlRoute(uri)) {
return urlRoute(uri)
}
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.components.DisplayFullNote
import com.vitorpamplona.amethyst.ui.components.DisplayUser
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.fragmentHashtagOrNull
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink
@@ -74,7 +75,10 @@ class MarkdownMediaRenderer(
title: String?,
uri: String,
): Boolean =
if (canPreview && uri.startsWith("http")) {
if (fragmentHashtagOrNull(uri) != null) {
// claims [text](#hashtag) links so renderLinkPreview can retarget them to the hashtag feed
true
} else if (canPreview && uri.startsWith("http")) {
title.isNullOrBlank() || title == uri
} else {
false
@@ -115,6 +119,12 @@ class MarkdownMediaRenderer(
uri: String,
richTextStringBuilder: RichTextString.Builder,
) {
val fragmentHashtag = fragmentHashtagOrNull(uri)
if (fragmentHashtag != null) {
renderAsCompleteLink(title ?: uri, "nostr:hashtag?id=$fragmentHashtag", richTextStringBuilder)
return
}
val content = parser.createMediaContent(uri, imetaByUrl, startOfText, callbackUri)
if (canPreview) {
@@ -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()
@@ -189,7 +189,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
@@ -675,10 +677,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(
@@ -623,6 +623,18 @@ class GroupEventHandler(
}
}
// Link the envelope to its inner note and copy over the
// relays that delivered/accepted the kind-445 so far, so
// the chat row shows relay icons. Later acceptances drill
// down on their own via LocalCache.addRelayToNoteAndInners
// once innerEventId is set. The outer note is looked up by
// event.id — NOT eventNote/publicNote, which belong to the
// *triggering* event when this runs from retryPendingFor.
event.innerEventId = innerEvent.id
cache.getNoteIfExists(event.id)?.let { outerNote ->
cache.copyRelaysFromTo(outerNote, innerEvent.id)
}
// Track the message in the Marmot group chatroom
account.marmotGroupList.addMessage(result.groupId, innerNote)
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class UriToRouteTest {
private val account = mockk<Account>()
@Test
fun fragmentHashtagRoutesToHashtagFeed() {
assertEquals(Route.Hashtag("nostrmultiplayergames"), uriToRoute("#NostrMultiplayerGames", account))
}
@Test
fun fragmentHashtagKeepsUnicodeTags() {
assertEquals(Route.Hashtag("日本語"), uriToRoute("#日本語", account))
}
@Test
fun invalidFragmentHashtagsAreNotRoutes() {
assertNull(uriToRoute("#", account))
assertNull(uriToRoute("# ", account))
assertNull(uriToRoute("#two words", account))
}
@Test
fun fullUrlsWithAnchorsAreNotHashtagRoutes() {
assertNull(uriToRoute("https://example.com/page#anchor", account))
}
@Test
fun hashtagQueryRoutesStillWork() {
assertEquals(Route.Hashtag("foo"), uriToRoute("hashtag?id=foo", account))
assertEquals(Route.Hashtag("foo"), uriToRoute("nostr:hashtag?id=foo", account))
}
@Test
fun fragmentHashtagOrNullExtractsTheTag() {
assertEquals("NostrMultiplayerGames", fragmentHashtagOrNull("#NostrMultiplayerGames"))
assertNull(fragmentHashtagOrNull("#"))
assertNull(fragmentHashtagOrNull("##double"))
assertNull(fragmentHashtagOrNull("#tag!"))
assertNull(fragmentHashtagOrNull("https://example.com/page#anchor"))
assertNull(fragmentHashtagOrNull("nostr:hashtag?id=foo"))
}
}
@@ -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) =
@@ -25,7 +25,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.concurrent.Volatile
/**
* Marmot Group Event (MIP-03) kind 445.
@@ -59,7 +61,19 @@ class GroupEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
HasInnerEvent {
// Set when the app layer learns the envelope ↔ inner mapping: on decrypt
// for inbound events, at build time for outbound ones. Lets relay
// attribution (OK acceptances, duplicate deliveries) drill from the
// kind-445 envelope down to the inner note the chat UI renders.
// `@Volatile`: written by the decrypt coroutine, read by relay socket
// threads.
@kotlinx.serialization.Transient
@kotlin.jvm.Transient
@Volatile
override var innerEventId: HexKey? = null
/**
* Base64-encoded encrypted content: nonce(12 bytes) || ciphertext.
* Decrypt with ChaCha20-Poly1305 using the MLS exporter-derived key.
@@ -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 {
@@ -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 {