feat(buzz): workspace + DM discovery via 44100/39000, matching the live relay

The earlier discovery layer read the NIP-29 joined list (kind-10009) and
kind-41001 — neither of which the deployed relay uses, so a joined workspace
rendered nothing. Rework it to the model live testing confirmed.

Enabling layer — persist joined workspaces:
- commons BuzzWorkspaces: process-wide set of joined workspace relays (Buzz
  membership is server-side, so there's no join event to rebuild from). Joining
  also marks the relay a Buzz dialect. Unit-tested.
- BuzzWorkspacePreferences: device-global DataStore that restores the set at
  startup (so the app connects + authenticates + discovers on cold start) and
  mirrors changes. Eager init in AppModules. BuzzInviteScreen now `join`s.

Quartz:
- BuzzChannelMetadata: read the relay's `t` channel-type tag ("stream"/"forum"/
  "dm") and a DM's inlined `p` participants off kind-39000.

Discovery (both hubs now source from the relay's real signals):
- BuzzWorkspacesViewModel: fetch + live-subscribe kind-44100 member-added
  notifications (#p=me) across joined relays → my channels; fetch each channel's
  39000 metadata; keep the non-DM ones. BuzzWorkspacesScreen unions this with the
  NIP-29 joined list.
- BuzzDmListViewModel: same 44100 discovery, kept where 39000 `t`=dm (participants
  from the metadata `p` tags), minus the 30622 hidden set — replaces the dead
  kind-41001 path.
- Account.openBuzzDm returns the relay-assigned channel id from the OK response
  (`response:{channel_id}`); BuzzNewDmViewModel opens the chat from it instead of
  polling 41001.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-22 17:46:05 +00:00
parent 2c1ec97bb4
commit ef1b5b6bc2
11 changed files with 583 additions and 136 deletions
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
@@ -275,6 +276,11 @@ class AppModules(
// lazy) so it loads before the first Buzz-relay AUTH and mirrors later changes to disk.
val buzzAttestationPrefs = BuzzAttestationPreferences(appContext, applicationIOScope)
// Restore + persist the joined Buzz workspace relays across restarts (device-global). Eager so
// the app knows which relays to sync as workspaces on cold start (Buzz membership is
// server-side; there is no join event to rebuild the set from).
val buzzWorkspacePrefs = BuzzWorkspacePreferences(appContext, applicationIOScope)
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
@@ -213,6 +213,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -2929,9 +2930,17 @@ class Account(
suspend fun openBuzzDm(
relay: NormalizedRelayUrl,
participants: List<HexKey>,
) {
val template = DmOpenEvent.build(participants)
signAndSendPrivatelyOrBroadcast(template) { listOf(relay) }
): String? {
val signed = signer.sign(DmOpenEvent.build(participants))
// The relay confirms the DM synchronously in the OK as `response:{"channel_id":"…"}` —
// the authoritative, relay-assigned channel UUID (the deployed relay does not emit a
// queryable kind-41001). Read it straight from the ack so the caller can open the chat.
val results = client.publishAndCollectResults(signed, setOf(relay))
val okMessage = results.values.firstOrNull { it.accepted }?.message ?: return null
return okMessage
.substringAfter("\"channel_id\":\"", "")
.substringBefore('"')
.takeIf { it.isNotBlank() }
}
/** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */
@@ -0,0 +1,88 @@
/*
* 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.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of joined `block/buzz` workspaces ([BuzzWorkspaces]),
* so the app knows which relays to connect + NIP-42-authenticate + run member-channel discovery
* against on a cold start — Buzz membership is server-side (granted by the HTTP invite claim),
* with no NIP-51/kind-10009 join event to rebuild the set from. Uses the app-wide
* [sharedPreferencesDataStore] like [BuzzAttestationPreferences] (not per-account: a joined
* relay is workspace-wide, and restoring only marks relays to sync — the relay still gates every
* read/write by the authenticated key).
*
* On construction it loads the saved relay URLs into the singleton (re-normalizing each, dropping
* any that no longer parse), then mirrors every later change back to disk. Construct once, eagerly.
*/
@Stable
class BuzzWorkspacePreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
init {
scope.launch {
restoreFromDisk()
// Persist on every change AFTER the initial restore (drop(1) skips the value present
// at collection start, which restoreFromDisk already wrote).
BuzzWorkspaces.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
val relays = raw.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
if (relays.isNotEmpty()) BuzzWorkspaces.restore(relays)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzWorkspacePrefs") { "Error reading joined workspaces: ${e.message}" }
}
}
private suspend fun persist(relays: Set<NormalizedRelayUrl>) {
try {
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY] = relays.map { it.url }.toSet()
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("BuzzWorkspacePrefs") { "Error writing joined workspaces: ${e.message}" }
}
}
companion object {
private val KEY = stringSetPreferencesKey("buzz.joinedWorkspaces")
}
}
@@ -25,18 +25,22 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -47,27 +51,34 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap
/**
* Backing ViewModel for [BuzzDmListScreen] — the user's Buzz direct-message inbox.
*
* A Buzz DM is a relay-authoritative NIP-29 group whose `h`/id is a relay-generated UUID,
* so the message timeline reuses the whole relay-group chat stack; this ViewModel only
* owns *discovery* and the inbox projection. It:
* - fetches + live-subscribes the relay-signed [DmCreatedEvent] (`kind:41001`, `#p` = me)
* and per-viewer [DmVisibilityEvent] (`kind:30622`, `#p` = me) across the Buzz-dialect
* relays — [LocalCache] consumes them and feeds [BuzzDmRegistry];
* - fetches each discovered DM's NIP-29 directory (39000-39003, `#d` = channel id) so the
* relay-signed roster is present, which is what the shared chat composer gates on
* (a DM isn't in the joined-group list, so nothing else would fetch it);
* - projects [BuzzDmRegistry] (minus the viewer's hidden set) into [rows], sorted by last
* message time and enriched with the other participants for name/avatar rendering.
* A Buzz DM is a relay-authoritative NIP-29 group whose `h`/id is a relay-generated UUID, so the
* message timeline reuses the whole relay-group chat stack; this ViewModel owns only *discovery*
* and the inbox projection. Discovery mirrors how the deployed relay actually models DMs (it does
* NOT emit a queryable kind-41001): the relay addresses each member a kind-44100 member-added
* notification (`#p` = me, `h` = channel), and marks a channel a DM via the `t` tag on its
* kind-39000 metadata (with the participants inlined as `p` tags). So it:
* - fetches + live-subscribes 44100 (`#p` = me) across the joined Buzz relays to learn the
* channels the user is in;
* - fetches each channel's directory (39000-39003) and keeps the ones whose metadata says
* `t` = `dm` — that same 39000 also carries the roster the shared chat composer's member gate
* needs, and the DM participants;
* - subscribes the per-viewer [DmVisibilityEvent] (`kind:30622`) so a hidden DM (tracked in
* [BuzzDmRegistry]) drops out;
* - projects the visible DMs into [rows], sorted by last message time.
*/
class BuzzDmListViewModel : ViewModel() {
@Volatile private var account: Account? = null
private val refreshMutex = Mutex()
private var liveJob: Job? = null
/** channelId -> relay it was discovered on (from the 44100 provenance). */
private val memberChannels = ConcurrentHashMap<String, NormalizedRelayUrl>()
private val _rows = MutableStateFlow<List<DmRow>>(emptyList())
val rows: StateFlow<List<DmRow>> = _rows.asStateFlow()
@@ -79,14 +90,16 @@ class BuzzDmListViewModel : ViewModel() {
data class DmRow(
val channelId: String,
val relayUrl: NormalizedRelayUrl,
/** All participants (from the 41001), including me. */
/** All participants (from the 39000 metadata `p` tags), including me. */
val allParticipants: List<HexKey>,
/** Participants other than me — who the DM is "with". */
val others: List<HexKey>,
/** Newest message time (or the DM's created_at when it has no messages yet). */
/** Newest message time (or 0 when the DM has no messages yet). */
val lastActivity: Long,
)
private fun relays(): Set<NormalizedRelayUrl> = BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value
fun bindAccountIfMissing(account: Account) {
if (this.account != null) return
this.account = account
@@ -100,8 +113,8 @@ class BuzzDmListViewModel : ViewModel() {
refreshMutex.withLock {
_isLoading.value = true
try {
fetchDiscovery(account)
fetchRosters(account)
discoverMemberChannels(account)
fetchMetadata(account)
rebuildRows(account)
} finally {
_isLoading.value = false
@@ -110,108 +123,96 @@ class BuzzDmListViewModel : ViewModel() {
}
}
/**
* One-shot paged fetch of the DM confirmations + visibility snapshots addressed to me
* (`#p` = me) from every Buzz-dialect relay. Events land in [LocalCache] → [BuzzDmRegistry].
*/
private suspend fun fetchDiscovery(account: Account) {
/** Fetch kind-44100 (`#p` = me) + the visibility snapshot (30622) across the joined relays. */
private suspend fun discoverMemberChannels(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
val relays = BuzzRelayDialect.flow.value
val relays = relays()
if (relays.isEmpty()) return
val filters =
listOf(
Filter(kinds = listOf(DmCreatedEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
)
account.client.fetchAllPagesFromPool(relays.associateWith { filters }) { _, _ -> }
account.client.fetchAllPagesFromPool(relays.associateWith { filters }) { event, relay ->
(event as? MemberAddedNotificationEvent)?.channel()?.let { memberChannels[it] = relay }
}
}
/**
* Second phase: for the DMs just discovered, fetch each channel's NIP-29 directory
* (39000-39003) from its own relay so the relay-signed roster populates. Without it the
* shared chat composer's member gate would hide the input field on a DM.
*/
private suspend fun fetchRosters(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
/** Fetch the NIP-29 directory (39000-39003) of every discovered channel so its `t`/roster load. */
private suspend fun fetchMetadata(account: Account) {
val byRelay =
BuzzDmRegistry
.visibleFor(myPubkey)
.groupBy { it.relay }
.mapValues { (_, dms) ->
listOf(
Filter(
kinds = RELAY_GROUP_METADATA_KINDS,
tags = mapOf("d" to dms.map { it.channelId }),
),
)
}
memberChannels.entries
.groupBy({ it.value }, { it.key })
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
if (byRelay.isEmpty()) return
account.client.fetchAllPagesFromPool(byRelay) { _, _ -> }
}
/** Project the discovered DM channels (metadata `t` = `dm`), minus my hidden set, newest-first. */
private fun rebuildRows(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
val hidden = BuzzDmRegistry.hiddenFor(myPubkey)
_rows.value =
BuzzDmRegistry
.visibleFor(myPubkey)
.map { dm ->
memberChannels.entries
.mapNotNull { (channelId, relay) ->
if (channelId in hidden) return@mapNotNull null
val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(channelId, relay))
val metadata = channel.event ?: return@mapNotNull null
if (!metadata.isBuzzDm()) return@mapNotNull null
val participants = metadata.buzzParticipants()
DmRow(
channelId = dm.channelId,
relayUrl = dm.relay,
allParticipants = dm.participants,
others = dm.participants.filter { it != myPubkey },
lastActivity = lastActivityFor(dm.channelId, dm.createdAt),
channelId = channelId,
relayUrl = relay,
allParticipants = participants,
others = participants.filter { it != myPubkey },
lastActivity = lastActivityFor(channelId),
)
}.sortedByDescending { it.lastActivity }
}
/** Newest message `created_at` for [channelId] from [LocalCache], or [fallback] when empty. */
private fun lastActivityFor(
channelId: String,
fallback: Long,
): Long =
/** Newest message `created_at` for [channelId] from [LocalCache], or 0 when the DM is empty. */
private fun lastActivityFor(channelId: String): Long =
LocalCache
.filter(
Filter(
kinds = listOf(ChatEvent.KIND, StreamMessageV2Event.KIND),
tags = mapOf("h" to listOf(channelId)),
),
).maxOfOrNull { it.createdAt() ?: 0L }
?.takeIf { it > 0L }
?: fallback
).maxOfOrNull { it.createdAt() ?: 0L } ?: 0L
/**
* Keeps a live REQ open for new DM confirmations / visibility changes and re-projects
* the inbox whenever the registry moves. Idempotent; torn down with the ViewModel.
* Keeps a live 44100 + 30622 REQ open (so new DMs / hide changes arrive) and re-projects the
* inbox when the registry or dialect set moves. Idempotent; torn down with the ViewModel.
*/
private fun startLive() {
val account = account ?: return
if (liveJob != null) return
val myPubkey = account.userProfile().pubkeyHex
val relays = BuzzRelayDialect.flow.value
liveJob =
viewModelScope.launch(Dispatchers.IO) {
// (a) Keep the discovery REQs open so LocalCache keeps feeding the registry.
relays.forEach { relay ->
relays().forEach { relay ->
launch {
account.client
.subscribeAsFlow(
relay,
Filter(kinds = listOf(DmCreatedEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
).collect { /* consumed globally by CacheClientConnector */ }
val filter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
account.client.subscribeAsFlow(relay, filter).collect { events ->
var changed = false
events.filterIsInstance<MemberAddedNotificationEvent>().forEach { e ->
e.channel()?.let { if (memberChannels.put(it, relay) == null) changed = true }
}
if (changed) {
fetchMetadata(account)
rebuildRows(account)
}
}
}
launch {
account.client
.subscribeAsFlow(
relay,
Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
).collect { }
val filter = Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
account.client.subscribeAsFlow(relay, filter).collect { /* consumed → BuzzDmRegistry.hidden */ }
}
}
// (b) Re-project whenever the registry (conversations or my hidden set) changes.
// Re-project when my hidden set (30622) or the joined-relay set changes.
launch {
combine(BuzzDmRegistry.conversations, BuzzDmRegistry.hidden) { _, _ -> }
combine(BuzzDmRegistry.hidden, BuzzWorkspaces.flow, BuzzRelayDialect.flow) { _, _, _ -> }
.collect { rebuildRows(account) }
}
}
@@ -50,7 +50,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
@@ -149,10 +149,11 @@ fun BuzzInviteScreen(
Button(
onClick = {
// Recognize the workspace's relay as Buzz-dialect so its events are materialized
// as workspace channels once membership is granted, then hand off to the in-app
// window.nostr browser to accept terms + sign the claim.
RelayUrlNormalizer.normalizeOrNull(invite.relayUrl())?.let { BuzzRelayDialect.mark(it) }
// Remember the workspace's relay as joined (persisted; also marks it a Buzz
// dialect) so the app connects + authenticates + discovers its channels once
// membership is granted, then hand off to the in-app window.nostr browser to
// accept terms + sign the claim.
RelayUrlNormalizer.normalizeOrNull(invite.relayUrl())?.let { BuzzWorkspaces.join(it) }
FavoriteAppLauncher.launchUrl(context, link)
},
enabled = !expired,
@@ -22,20 +22,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -45,9 +41,9 @@ import kotlinx.coroutines.withContext
/**
* Backing ViewModel for [BuzzNewDmScreen]. It gathers 1-8 other participants and a Buzz
* workspace relay, publishes a kind-41010 open command, then waits for the relay-signed
* kind-41001 confirmation to land in [BuzzDmRegistry] so it can hand the caller the
* relay-assigned [GroupId] (we never mint the DM's UUID ourselves).
* workspace relay, publishes a kind-41010 open command, and reads the relay's synchronous OK
* (`response:{"channel_id":"…"}`) for the assigned [GroupId] — we never mint the DM's UUID
* ourselves, and the deployed relay does not emit a queryable kind-41001 to poll for.
*/
class BuzzNewDmViewModel : ViewModel() {
@Volatile private var account: Account? = null
@@ -78,7 +74,7 @@ class BuzzNewDmViewModel : ViewModel() {
if (this.account != null) return
this.account = account
val buzz =
BuzzRelayDialect.flow.value
(BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value)
.toList()
.sortedBy { it.url }
_relays.value = buzz
@@ -110,9 +106,9 @@ class BuzzNewDmViewModel : ViewModel() {
}
/**
* Publishes the 41010 and awaits the 41001 confirmation, then invokes [onOpened] with
* the relay-assigned [GroupId]. On timeout it still calls [onOpened] with null so the
* screen can fall back to the inbox (the DM will surface there once it confirms).
* Publishes the 41010 and reads the relay's synchronous OK confirmation for the assigned
* channel id, then invokes [onOpened] with the [GroupId]. On a null id (the relay didn't
* confirm in the ack) it calls [onOpened] with null so the screen falls back to the inbox.
*/
fun start(onOpened: (GroupId?) -> Unit) {
val account = account ?: return
@@ -126,49 +122,16 @@ class BuzzNewDmViewModel : ViewModel() {
_status.value = Status.Error("Add at least one person")
return
}
val expected = (others + account.userProfile().pubkeyHex).toSet()
_status.value = Status.Sending
viewModelScope.launch(Dispatchers.IO) {
try {
account.openBuzzDm(relay, others)
val groupId = awaitConfirmation(account, relay, expected)
val channelId = account.openBuzzDm(relay, others)
val groupId = channelId?.let { GroupId(it, relay) }
withContext(Dispatchers.Main) { onOpened(groupId) }
} catch (e: IllegalArgumentException) {
_status.value = Status.Error(e.message ?: "Could not open the DM")
}
}
}
/**
* Polls for the relay-signed 41001 whose participant set matches [expected] on [relay],
* re-fetching `#p` = me between checks so a fresh confirmation is pulled in. Returns the
* matched [GroupId], or null after [CONFIRM_TIMEOUT_MS].
*/
private suspend fun awaitConfirmation(
account: Account,
relay: NormalizedRelayUrl,
expected: Set<HexKey>,
): GroupId? {
val myPubkey = account.userProfile().pubkeyHex
val filters = listOf(Filter(kinds = listOf(DmCreatedEvent.KIND), tags = mapOf("p" to listOf(myPubkey))))
val deadline = CONFIRM_TIMEOUT_MS
var waited = 0L
while (waited < deadline) {
account.client.fetchAllPagesFromPool(mapOf(relay to filters)) { _, _ -> }
val match =
BuzzDmRegistry.conversations.value.values.firstOrNull {
it.relay == relay && it.participants.toSet() == expected
}
if (match != null) return GroupId(match.channelId, relay)
delay(POLL_INTERVAL_MS)
waited += POLL_INTERVAL_MS
}
return null
}
companion object {
private const val CONFIRM_TIMEOUT_MS = 6_000L
private const val POLL_INTERVAL_MS = 500L
}
}
@@ -54,6 +54,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -94,17 +95,30 @@ fun BuzzWorkspacesScreen(
.collectAsStateWithLifecycle()
val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle()
// Buzz membership is server-side (redeeming an invite claims relay membership) — there is no
// NIP-29 join event — so the joined-group list alone misses workspace channels. Discover them
// from the relay's kind-44100 member-added notifications instead, and union the two sources.
val viewModel: BuzzWorkspacesViewModel =
viewModel(key = "BuzzWorkspaces-" + accountViewModel.account.userProfile().pubkeyHex)
viewModel.bindAccountIfMissing(accountViewModel.account)
val discovered by viewModel.channelsByRelay.collectAsStateWithLifecycle()
// A Buzz workspace IS a relay (a tenant, per buzz-core's `relay_url_authority`), and its
// channels are the NIP-29 groups on it — so group the joined Buzz-dialect groups by relay
// into workspace → channels, the Concord community→channels shape.
// channels are the NIP-29 groups on it — so group by relay into workspace → channels, the
// Concord community→channels shape. Sources: (1) joined NIP-29 groups on Buzz-dialect relays,
// (2) member channels discovered via kind-44100 (already non-DM).
val workspaces =
remember(joined, buzzRelays) {
joined
.mapNotNull { tag ->
remember(joined, buzzRelays, discovered) {
val fromJoined =
joined.mapNotNull { tag ->
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@mapNotNull null
if (relay !in buzzRelays) return@mapNotNull null
GroupId(tag.groupId, relay)
}.groupBy { it.relayUrl }
}
val fromDiscovery = discovered.values.flatten()
(fromJoined + fromDiscovery)
.distinctBy { it.relayUrl.url + "/" + it.id }
.groupBy { it.relayUrl }
.toList()
.sortedBy { it.first.url }
.map { (relay, channels) -> relay to channels.sortedBy { it.id } }
@@ -0,0 +1,163 @@
/*
* 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.screen.loggedIn.buzz
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap
/**
* Discovers the workspace channels the user is a member of on each joined Buzz relay, so the
* Workspaces hub shows them even though Buzz membership is server-side (no NIP-29 join event).
*
* The relay addresses each member a kind-44100 member-added notification (`#p` = me, `h` =
* channel), so this fetches + live-subscribes 44100 across the joined relays ([BuzzWorkspaces]),
* then fetches each channel's kind-39000 metadata to read its Buzz `t` channel type. Channels
* whose type is **not** `dm` are workspace channels (DMs surface in the DM inbox instead). Rows
* are grouped by relay = workspace; the screen unions this with the NIP-29 joined-group list so
* both membership models render.
*/
class BuzzWorkspacesViewModel : ViewModel() {
@Volatile private var account: Account? = null
private val refreshMutex = Mutex()
private var liveJob: Job? = null
/** channelId -> relay it was discovered on (from the 44100 provenance). */
private val memberChannels = ConcurrentHashMap<String, NormalizedRelayUrl>()
private val _channelsByRelay = MutableStateFlow<Map<NormalizedRelayUrl, List<GroupId>>>(emptyMap())
/** Non-DM member channels grouped by workspace relay; the hub collects this. */
val channelsByRelay: StateFlow<Map<NormalizedRelayUrl, List<GroupId>>> = _channelsByRelay.asStateFlow()
private fun relays(): Set<NormalizedRelayUrl> = BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value
fun bindAccountIfMissing(account: Account) {
if (this.account != null) return
this.account = account
refresh()
startLive()
}
fun refresh() {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
refreshMutex.withLock {
discoverMemberChannels(account)
fetchMetadata(account)
rebuild()
}
}
}
/** Fetch kind-44100 (`#p` = me) across the joined relays, recording each channel's relay. */
private suspend fun discoverMemberChannels(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
val relays = relays()
if (relays.isEmpty()) return
val filter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
account.client.fetchAllPagesFromPool(relays.associateWith { listOf(filter) }) { event, relay ->
(event as? MemberAddedNotificationEvent)?.channel()?.let { memberChannels[it] = relay }
}
}
/** Fetch the NIP-29 directory (39000-39003) of every discovered channel so its `t` type loads. */
private suspend fun fetchMetadata(account: Account) {
val byRelay =
memberChannels.entries
.groupBy({ it.value }, { it.key })
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
if (byRelay.isEmpty()) return
account.client.fetchAllPagesFromPool(byRelay) { _, _ -> }
}
/** Project the discovered non-DM channels, grouped by relay. */
private fun rebuild() {
_channelsByRelay.value =
memberChannels.entries
.mapNotNull { (channelId, relay) ->
val groupId = GroupId(channelId, relay)
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
// Keep only non-DM channels here; DMs render in the DM inbox. A channel whose
// metadata hasn't arrived yet (type unknown) is optimistically shown as a
// workspace channel — a later refresh moves it out once a `t:dm` is seen.
if (channel.event?.isBuzzDm() == true) null else relay to groupId
}.groupBy({ it.first }, { it.second })
.mapValues { (_, ids) -> ids.sortedBy { it.id } }
}
/** Keep a live 44100 subscription open (so new channels appear) and re-project on registry moves. */
private fun startLive() {
val account = account ?: return
if (liveJob != null) return
val myPubkey = account.userProfile().pubkeyHex
liveJob =
viewModelScope.launch(Dispatchers.IO) {
relays().forEach { relay ->
launch {
val filter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
account.client.subscribeAsFlow(relay, filter).collect { events ->
var changed = false
events.filterIsInstance<MemberAddedNotificationEvent>().forEach { e ->
e.channel()?.let { if (memberChannels.put(it, relay) == null) changed = true }
}
if (changed) {
fetchMetadata(account)
rebuild()
}
}
}
}
// Re-project when the joined-workspace set or dialect marks change.
launch {
combine(BuzzWorkspaces.flow, BuzzRelayDialect.flow) { _, _ -> }.collect { refresh() }
}
}
}
override fun onCleared() {
liveJob?.cancel()
liveJob = null
super.onCleared()
}
}
@@ -0,0 +1,82 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* The set of `block/buzz` workspaces the user has **joined** — one relay (tenant) each.
*
* Distinct from [BuzzRelayDialect], which merely marks relays *observed* to speak the Buzz
* dialect: a workspace here is one the user actively joined (redeemed an invite for), so the
* app must connect + NIP-42-authenticate + run the member-channel discovery against it, even
* on a cold start before any Buzz event has arrived to trigger dialect detection. Buzz
* membership is granted server-side by the HTTP invite claim — there is no NIP-51/kind-10009
* join event to key off — so this joined set is the client's own bookkeeping of which relays
* to sync as workspaces.
*
* Persisted across launches by the platform (`BuzzWorkspacePreferences` on Android mirrors it
* to a device-global store and restores it at startup). Like [BuzzRelayDialect] it is a
* process-wide singleton; joining also marks the relay as a Buzz dialect.
*/
object BuzzWorkspaces {
private val joined = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/** The joined workspace relays; discovery subscriptions and the workspaces hub collect this. */
val flow: StateFlow<Set<NormalizedRelayUrl>> = joined
fun isJoined(relay: NormalizedRelayUrl): Boolean = relay in joined.value
/** Records [relay] as a joined workspace (and a Buzz dialect). Returns true when it was new. */
fun join(relay: NormalizedRelayUrl): Boolean {
BuzzRelayDialect.mark(relay)
while (true) {
val current = joined.value
if (relay in current) return false
if (joined.compareAndSet(current, current + relay)) return true
}
}
/** Removes [relay] from the joined set (leaving a workspace). */
fun leave(relay: NormalizedRelayUrl) {
while (true) {
val current = joined.value
if (relay !in current) return
if (joined.compareAndSet(current, current - relay)) return
}
}
/**
* Replaces the whole joined set with [relays] — used to restore from disk at startup. Each is
* also marked a Buzz dialect so its events materialize as workspace channels immediately.
*/
fun restore(relays: Set<NormalizedRelayUrl>) {
relays.forEach { BuzzRelayDialect.mark(it) }
joined.value = relays
}
/** Test-only: clears the joined set so unit tests don't leak state into each other. */
fun clearForTesting() {
joined.value = emptySet()
}
}
@@ -0,0 +1,72 @@
/*
* 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.commons.model.buzz
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class BuzzWorkspacesTest {
private val a = RelayUrlNormalizer.normalize("wss://a.buzz.example")
private val b = RelayUrlNormalizer.normalize("wss://b.buzz.example")
@BeforeTest fun setup() {
BuzzWorkspaces.clearForTesting()
BuzzRelayDialect.clearForTesting()
}
@AfterTest fun teardown() {
BuzzWorkspaces.clearForTesting()
BuzzRelayDialect.clearForTesting()
}
@Test
fun joiningRecordsAndMarksDialect() {
assertTrue(BuzzWorkspaces.join(a))
assertTrue(BuzzWorkspaces.isJoined(a))
assertEquals(setOf(a), BuzzWorkspaces.flow.value)
// Joining also marks the relay a Buzz dialect so its events render as workspace channels.
assertTrue(BuzzRelayDialect.isBuzz(a))
// Re-joining is a no-op (returns false).
assertFalse(BuzzWorkspaces.join(a))
}
@Test
fun leaveRemoves() {
BuzzWorkspaces.join(a)
BuzzWorkspaces.join(b)
BuzzWorkspaces.leave(a)
assertEquals(setOf(b), BuzzWorkspaces.flow.value)
assertFalse(BuzzWorkspaces.isJoined(a))
}
@Test
fun restoreReplacesAndMarksAll() {
BuzzWorkspaces.join(a)
BuzzWorkspaces.restore(setOf(b))
assertEquals(setOf(b), BuzzWorkspaces.flow.value)
assertTrue(BuzzRelayDialect.isBuzz(b))
}
}
@@ -0,0 +1,48 @@
/*
* 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.quartz.buzz.workspace
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/*
* Buzz-specific readers over a NIP-29 group's relay-signed metadata (kind:39000).
*
* Buzz relays enrich the standard metadata so clients can classify a channel without a
* separate fetch: a single `t` tag carries the channel type ("stream" / "forum" / "dm" — note
* this collides with NIP-29's use of `t` for topic hashtags, so on a Buzz relay
* GroupMetadataEvent.hashtags is really the channel type), and a DM's participants are inlined
* as `p` tags on the 39000 itself. Ground truth: buzz-relay/src/handlers/side_effects.rs
* (emit_group_discovery_events).
*/
/** The Buzz channel type from the relay's `t` tag ("stream" / "forum" / "dm"), or null. */
fun GroupMetadataEvent.buzzChannelType(): String? = tags.firstTagValue("t")
/** True when the relay marks this channel a DM (`t` = "dm"). */
fun GroupMetadataEvent.isBuzzDm(): Boolean = buzzChannelType() == BUZZ_CHANNEL_TYPE_DM
/** The DM participant pubkeys inlined as `p` tags on a Buzz DM's 39000 (empty for non-DMs). */
fun GroupMetadataEvent.buzzParticipants(): List<HexKey> = tags.mapNotNull(PTag::parseKey)
const val BUZZ_CHANNEL_TYPE_DM = "dm"