Files
amethyst/quartz
Claude 62943d8f0a fix(music): preserve tags on edit/toggle, lock concurrent toggles, search, mute
Audit pass turned up several real bugs in the music feature. Rolled the
fixes into one commit since they all touch the publish/edit path.

Data-loss bugs (must-fix)

  - NewMusicTrackViewModel.publish() in edit mode rebuilt the event via
    MusicTrackEvent.build() with only the composer-visible fields, silently
    dropping every other tag the original event carried — video URL,
    released, track_number, format, bitrate, sample_rate, language,
    explicit, extra `t` genre tags, zap splits, anything custom. Adds a
    MusicTrackEvent.edit(earlierVersion, ...) companion that clones the
    existing TagArray and only mutates composer-managed fields, mirroring
    PinListEvent/BookmarkListEvent's `add`/`remove`/`resign` pattern.
  - AddToMusicPlaylistViewModel.toggle() and .createWithTrack() had the
    same problem for playlists. Adds MusicPlaylistEvent.addTrack /
    removeTrack companions that preserve the rest of the tag array. The
    VM now uses these instead of round-tripping through build().

Correctness bugs (must-fix)

  - MusicPlaylistEvent.build() emitted only one of `public`/`private`
    while isPublic() defaulted to `true`-when-absent. A private playlist
    therefore round-tripped as isPublic() && isPrivate(). isPublic() now
    falls back to !isPrivate(), and build() still emits the explicit pair
    so unrelated clients reading either flag agree on visibility.
  - AddToMusicPlaylistViewModel's `isWorking` flag wasn't a concurrency
    primitive — fast taps on different rows could race and the loser's
    broadcast would replace the winner's. Adds a Mutex around toggle /
    createWithTrack so they serialize.
  - The initial rescan() ran on the composition thread (init() is called
    from the sheet's body). Both initial and live re-scans now run on
    Dispatchers.IO inside the same Job, and the live collector filters
    bundles by kind so we don't re-walk LocalCache for every Text Note.
  - NewMusicTrackViewModel.init() set `isEditing = true` based purely on
    `editDTag != null`, so a stale dTag pointing at no cached event left
    the user on a Delete button that no-op'd. `isEditing` now derives
    from loadedEvent and falls back to create-mode when the lookup misses.
  - MusicTrackHeader synthesized mimeType "video/${format ?: "mp4"}" when
    a `video` URL was present — but `format` is the AUDIO format per
    spec, so a track with both `video=...mp4` and `format=mp3` emitted
    "video/mp3". Pass null when videoUrl wins and let ExoPlayer sniff.
  - MusicTracksFeedFilter only consulted params.match(), which checks
    the follow list but not mute/spammer/word lists. Muted authors leaked
    through. Mirror LongsFeedFilter and AND `account.isAcceptable(note)`.

Search & idiomatic fixes (should-fix)

  - MusicTrackEvent + MusicPlaylistEvent now implement SearchableEvent so
    the local SQLite FTS indexes title/artist/album/description rather
    than only the JSON content. Searching "Pink Floyd" by artist now
    matches the local cache instead of waiting for relay results.
  - MusicTracksFeedFilter.feedKey() now class-prefixes with "music-" so it
    can't collide with other feeds that key off the same Home follow list.
  - MusicPlaylistEvent.build() renamed `description`/`shortDescription` to
    `content`/`description` so the parameter names match the spec.

Nits

  - Drop trackCount() — callers prefer `trackAddresses().size`.
  - Drop six unused strings (composer placeholders for not-yet-wired
    Blossom audio/cover upload UI).
  - Wrap preview runBlocking { justConsume(...) } in remember{} so it
    runs once per preview key instead of every recompose.
  - Use a hex-shaped id for preview events instead of "track_xxx_yyy".
2026-05-27 11:10:09 +00:00
..
2026-04-30 17:37:29 -04:00

Quartz Guide for Clients

Here's how to structure a new Twitter-like client.

Architecture

Set up a Context class to wire Quartz components together. Usually there is only one instance of this class.

object AppGraph {
    // application-wide scope
    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    // the local db
    val sqlite = EventStore(dbName = "demo-events.db")

    // the local cache that keeps only one copy of each event in memory
    val interned = InterningEventStore(sqlite)

    // the observable db, that you can produce flows that auto update
    val db = ObservableEventStore(interned)

    // the client to access relays
    val client = NostrClient(websocketBuilder = KtorWebSocket.Builder())

    // sends all events, regardless of the subscription, to the local db
    val collector = EventCollector(client) { event, _ ->
        runCatching {
            db.insert(event)
        }
    }

    // update this variable when a user logs in, starts with a guest
    var signer: NostrSigner = NostrSignerInternal(KeyPair())

    init {
        // Periodic NIP-40 sweep — drops expired events from SQLite and
        // emits StoreChange.DeleteExpired so live projections drop them
        // too. Without this the on-disk store grows monotonically.
        scope.launch {
            while (isActive) {
                delay(15.minutes)
                runCatching { db.deleteExpiredEvents() }
            }
        }
    }
}

Then use a view model to subscribe to relays and the local db at the same time, like this:

class NotesFeed(
    private val db: ObservableEventStore,
    private val client: NostrClient,
) {
    private val subId = newSubId()
    private val filter = Filter(kinds = listOf(TextNoteEvent.KIND), limit = 100)
    private val relays =
        setOf(
            "wss://relay.damus.io".normalizeRelayUrl(),
            "wss://nos.lol".normalizeRelayUrl(),
            "wss://relay.nostr.band".normalizeRelayUrl(),
        )

    val notes: Flow<ProjectionState<TextNoteEvent>> =
        db
            .project<TextNoteEvent>(filter)
            .filterItems { it.value.isNewThread() }
            .onStart { client.subscribe(subId, relays.associateWith { listOf(filter) }) }
            .onCompletion { client.unsubscribe(subId) }
}

class FeedViewModel(
    private val db: ObservableEventStore,
    private val client: NostrClient,
) : ViewModel() {
    val notesFeed = NotesFeed(db, client)

    val feed = notesFeed
        .flow
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProjectionState.Loading)

    fun send(text: String, signer: NostrSigner) {
        viewModelScope.launch {
            val signed = signer.sign<TextNoteEvent>(TextNoteEvent.build(text))
            // Hits the bus → projection picks it up alongside any inbound relay copy.
            db.insert(signed)
            client.publish(signed, relays)
        }
    }
}

Notice that the notes flow is ready for the UI and automatically subscribes and unsubscribes to any group of relays and filters the user wants. Similarly, the send function updates both the local db and the relay.

NostrClient connects on-demand: the first subscribe(...) or publish(...) to a relay triggers the socket. There's no need to call client.connect() at startup — it's only useful for resuming after a prior disconnect().

Building a reactive feed UI

A feed screen reads from the view model's feed flow, which only updates when new events arrive or are deleted due to kind 5 deletions, vanish requests or expirations.

fun main() {
    application {
        val state = rememberWindowState(size = DpSize(560.dp, 720.dp))
        Window(onCloseRequest = ::exitApplication, state = state, title = "Nostr Kind 1 Demo") {
            MaterialTheme {
                val viewModel = remember {
                    FeedViewModel(AppGraph.db, AppGraph.client, AppGraph.signer)
                }

                val noteState by viewModel.feed.collectAsStateWithLifecycle()
                when (noteState) {
                    is ProjectionState.Loading -> LoadingFeed()
                    is ProjectionState.Loaded -> Feed(noteState.items)
                }
            }
        }
    }
}

@Composable
private fun LoadingFeed() {
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        CircularProgressIndicator()
    }
}

@Composable
private fun Feed(items: List<MutableStateFlow<TextNoteEvent>>) {
    LazyColumn(modifier = Modifier.fillMaxSize()) {
        items(items = items, key = { it.value.id }) { handle ->
            NoteRow(handle)
            HorizontalDivider()
        }
    }
}

@Composable
private fun NoteRow(handle: MutableStateFlow<TextNoteEvent>) {
    val event by handle.collectAsStateWithLifecycle()
    Text(
        text = event.content,
        style = MaterialTheme.typography.bodyMedium,
        modifier = Modifier.padding(top = 4.dp),
    )
}

Notice how each how also subscribe for changes. This is important to receive updates from replaceable and addressable events.

Appendix A

Quartz doesn't offer a Ktor websocket, but you can use this one as reference.

/**
 * Ktor-based [WebSocket] for talking to a Nostr relay.
 *
 * Quartz exposes [WebsocketBuilder] as the only seam between its relay-pool
 * and the underlying transport, so all this class has to do is open a Ktor
 * websocket session, forward incoming text frames to [out], and let Quartz
 * drive sends.
 */
class KtorWebSocket(
    private val url: NormalizedRelayUrl,
    private val httpClient: HttpClient,
    private val out: WebSocketListener,
) : WebSocket {
    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
    private var session: DefaultWebSocketSession? = null
    private var readerJob: Job? = null

    override fun needsReconnect(): Boolean = session == null

    override fun connect() {
        readerJob =
            scope.launch {
                try {
                    val s = httpClient.webSocketSession(urlString = url.url)
                    session = s
                    out.onOpen(0, false)

                    for (frame in s.incoming) {
                        if (frame is Frame.Text) {
                            out.onMessage(frame.readText())
                        }
                    }

                    val reason = s.closeReason.await()
                    out.onClosed(
                        code =
                            reason?.code?.toInt() ?: CloseReason.Codes.NORMAL.code
                                .toInt(),
                        reason = reason?.message ?: "",
                    )
                } catch (t: Throwable) {
                    out.onFailure(t, null, null)
                } finally {
                    session = null
                }
            }
    }

    override fun disconnect() {
        val s = session
        session = null
        readerJob?.cancel()
        readerJob = null
        if (s != null) {
            runBlocking { s.close(CloseReason(CloseReason.Codes.NORMAL, "client disconnect")) }
        }
        scope.cancel()
    }

    override fun send(msg: String): Boolean {
        val s = session ?: return false
        scope.launch { s.send(msg) }
        return true
    }

    /**
     * The factory Quartz hands to [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient].
     * One [HttpClient] is shared by every relay in the pool.
     */
    class Builder(
        private val httpClient: HttpClient = defaultClient(),
    ) : WebsocketBuilder {
        override fun build(
            url: NormalizedRelayUrl,
            out: WebSocketListener,
        ): WebSocket = KtorWebSocket(url, httpClient, out)

        companion object {
            fun defaultClient() =
                HttpClient(CIO) {
                    install(WebSockets)
                }
        }
    }
}