Files
amethyst/quartz
Vitor PamplonaandClaude Opus 5 bb87d7755a feat(buzz): ask before showing channels somebody added you to
On a Buzz relay, channel membership is server-side: another member can add you,
the relay writes you into the kind-39002 roster, and you can read and post
immediately. The relay then addresses you a kind-44100 naming who did it.

Amethyst funnelled every 44100 into BuzzDmChannels — treating it as a DM — which
silently subscribed you to that channel's messages, while the Messages list
(which reads the self-published kind-10009) showed no row for it. A channel could
therefore be joined, streaming, and invisible at the same time: the channel
screen offered no Join button and accepted posts, the RelayGroups screen listed
it from the relay's 39000 directory, messages arrived — and Messages had nothing.

Nothing here is auto-accepted any more. 44100 carries `{"type","channel_id",
"actor"}`, and the relay emits the SAME kind for a self-join with `actor == you`,
so the actor is the only thing separating "I joined this" from "somebody put me
here". Channels are classified by the `t` tag on their 39000 (stream/forum/dm/
workflow — read through a dedicated accessor because on buzz the type shares the
tag name with real hashtags): only `t = dm` belongs in the DM list, everything
else becomes a pending invite that subscribes to nothing.

The prompt appears on both surfaces, driven by one state holder so they cannot
disagree — Notifications, in the same header slot as the missing-inbox-relay
prompt, and Messages > New Requests, beside the pending DMs it is the exact
analogue of. Rendered as a list row rather than a modal: these arrive in bursts
when somebody sets up a workspace, and a blocking dialog on cold start would be
miserable. It is also the spam surface, so Ignore stays cheap.

Three actions, and Ignore is deliberately not Leave:

- Show    -> writes the group into kind-10009 (Account.follow), after which the
             ordinary joined-group path owns it and it syncs to other devices.
             No kind-9021: the relay already has you in the roster, so this
             records only your decision to surface it.
- Ignore  -> local, reversible display choice. You stay in the roster and can
             still open and post.
- Leave   -> kind-9022 LeaveRequestEvent, the one that actually removes you.

A kind-44101 removal now withdraws any pending prompt, so the relay taking the
membership away cannot leave a card offering an action that would fail.

The invites section is passed as the chatroom feed's header rather than stacked
beside it: the collapsing top bar draws over that area, so a header outside the
list renders underneath it. It shows in the empty state too, otherwise an account
with no pending DMs would have no way to reach the prompt.

Verified end to end on device: "straycat added you to personalized-knowledge-
graphs" rendered on both surfaces, and Show republished kind-10009 with the
channel appended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:44:36 -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)
                }
        }
    }
}