Files
amethyst/quartz
Claude 633903b5c1 fix: relay-group audit — timeline, roster, membership, list-safety
Fixes found in a full audit of the NIP-29 relay-groups feature across
quartz/commons/amethyst/cli.

Correctness (app):
- Own group messages never appeared in the timeline until an app restart:
  the optimistic send is consumed with a null relay, so attachToRelayGroup
  bailed on the relay==null guard, and the host relay's echo (new==false)
  was skipped by the "only attach when newly consumed" gate. Attach now runs
  on every arrival, gated on the note being loaded, and the null-relay case
  attaches to the already-open channel(s) for that group id. Also avoids the
  wrong-relay phantom by only fabricating a channel from real provenance.
- Roster subscription was frozen after an in-place join/leave (state keyed
  on the stable account, never re-derived); it now invalidates on every
  liveRelayGroupList change, so a fresh join's 39002 admission is fetched.
- membershipOf demoted a 39001 admin with an empty/unknown role to MEMBER,
  hiding moderation; presence in the admins list now means at least MODERATOR.
- Members roster showed permanent truncated-hex names (one-shot
  getUserIfExists cached null); uses checkGetOrCreateUser so UsernameDisplay
  fills in when kind:0 arrives.

Protocol / data:
- GroupTag had no value equality → joined-group Sets never deduped and the
  StateFlow re-emitted on every identical re-arrival. Equality is now the
  (id, relay) pair, excluding the cosmetic name.
- create/edit emitted non-canonical ["public"]/["open"] status tags; NIP-29
  flags are presence-only, so only private/closed are emitted when set.
- Metadata/member/admin supersede guards use <= so an equal-createdAt
  duplicate isn't reprocessed (first-arrival wins); updatedMetadataAt is now
  private-set. Relay-group channels are now included in the prune loops.

CLI:
- join/leave/create updated the kind:10009 list from a network-only drain;
  a slow/empty fetch could publish a fresh list containing ONLY the new
  group, wiping the rest. Now reads the local store (source of truth) too.
- edit re-asserted both visibility axes from flag presence, so --closed on a
  private group leaked it public. It now reads current 39000 and merges,
  with --public/--open counter-flags; only the specified axis changes.
- create now tracks the new group in kind:10009 (parity with join/Android).

UI polish:
- Invite dialog no longer mints a 9009 for open groups and won't copy a code
  it never displayed. Browse "popular" list normalizes URLs before filtering.

Tests: GroupTag identity, unknown-role-admin-moderates, equal-createdAt
no-resupersede added; all quartz+commons NIP-29 suites and the amy
relaygroup harness pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 02:27:18 +00: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)
                }
        }
    }
}