fix(buzz): warm-auth DM/console reads + reconnect-on-join to authenticate the socket, and user-search for New DM

The Buzz relay gates its `#p=me` reads (44100 member-added, 30622 DM
visibility) and the 39002 group roster behind NIP-42. Two gaps kept
those empty even after joining a workspace:

- DM list and Agent Console used a plain paged fetch, which returns
  empty on an `auth-required` CLOSED. Switch both to the warm-auth
  `fetchAllWithHooks(pendingOnAuthRequired = true)` path the import
  already used, so they authenticate on the CLOSED and retry.

- NIP-42 sends its AUTH challenge once, on connect. When the socket was
  already open before the user joined (the relay is in their lists and
  connected at startup), that challenge was spent while the relay was
  still not first-party, leaving the persistent group-roster subscription
  refused — so the channel showed a "Join" lock. A join makes the relay
  first-party (AuthCoordinator.isFirstParty); force a reconnect on a new
  join so the relay re-challenges and the connection authenticates,
  unlocking the roster and every other #p=me read on the shared socket.

Also reworks the New Buzz DM recipient picker: instead of only accepting
a raw npub/hex, it now offers a typeahead user search
(LocalCache.findUsersStartingWith) that surfaces this workspace's channel
members first. Pasting an npub/hex still works as an escape hatch for
someone not yet in the local cache.

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 23:35:32 +00:00
parent 94d5f55f93
commit e83fa73cc3
5 changed files with 165 additions and 23 deletions
@@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
import com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
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
@@ -106,8 +106,11 @@ class AgentConsoleViewModel : ViewModel() {
this.scopeRelay = relay
this.account = account
relay?.let {
BuzzWorkspaces.join(it)
val newlyJoined = BuzzWorkspaces.join(it)
viewModelScope.launch { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
// A join makes the relay first-party; if the socket was already open its one-shot AUTH
// challenge was spent unauthenticated, so reconnect to re-challenge and authenticate.
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
}
refresh()
}
@@ -144,7 +147,13 @@ class AgentConsoleViewModel : ViewModel() {
Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(myPubkey)),
)
account.client.fetchAllPagesFromPool(relays.associateWith { filters }) { _, _ -> }
// The turn-metric read is `#p`-gated, so the Buzz relay requires NIP-42 auth — warm-auth
// (pendingOnAuthRequired) so it authenticates on the `auth-required` CLOSED and retries.
account.client.fetchAllWithHooks(
filters = relays.associateWith { filters },
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
}
private suspend fun reloadFromCache(account: Account) {
@@ -37,7 +37,7 @@ 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.accessories.fetchAllWithHooks
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
@@ -118,8 +118,11 @@ class BuzzDmListViewModel : ViewModel() {
this.scopeRelay = relay
this.account = account
relay?.let {
BuzzWorkspaces.join(it)
val newlyJoined = BuzzWorkspaces.join(it)
viewModelScope.launch { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
// A join makes the relay first-party; if the socket was already open its one-shot AUTH
// challenge was spent unauthenticated, so reconnect to re-challenge and authenticate.
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
}
refresh()
startLive()
@@ -141,7 +144,12 @@ class BuzzDmListViewModel : ViewModel() {
}
}
/** Fetch kind-44100 (`#p` = me) + the visibility snapshot (30622) across the joined relays. */
/**
* Fetch kind-44100 (`#p` = me) + the visibility snapshot (30622) across the joined relays. These
* reads are `#p`-gated so the Buzz relay requires NIP-42 auth — use the warm-auth fetch
* (`pendingOnAuthRequired`) so it authenticates on the `auth-required` CLOSED and retries, rather
* than returning empty (this is why the import lists channels but a plain fetch wouldn't).
*/
private suspend fun discoverMemberChannels(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
val relays = relays()
@@ -151,8 +159,13 @@ class BuzzDmListViewModel : ViewModel() {
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 }) { event, relay ->
account.client.fetchAllWithHooks(
filters = relays.associateWith { filters },
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { relay, event ->
(event as? MemberAddedNotificationEvent)?.channel()?.let { memberChannels[it] = relay }
false
}
}
@@ -163,7 +176,7 @@ class BuzzDmListViewModel : ViewModel() {
.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) { _, _ -> }
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
}
/** Project the discovered DM channels (metadata `t` = `dm`), minus my hidden set, newest-first. */
@@ -20,16 +20,21 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
@@ -45,7 +50,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
@@ -68,9 +75,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
/**
* Start a new Buzz DM: pick a workspace relay, add 1-8 people (npub or hex), and open. On
* the relay's confirmation the screen jumps straight into the shared [Route.RelayGroup]
* chat for the new conversation; on timeout it falls back to the DM inbox.
* Start a new Buzz DM: pick a workspace relay, add 1-8 people (search by name — this workspace's
* members rank first — or paste an npub/hex), and open. On the relay's confirmation the screen jumps
* straight into the shared [Route.RelayGroup] chat for the new conversation; on timeout it falls
* back to the DM inbox.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -86,8 +94,9 @@ fun BuzzNewDmScreen(
val selectedRelay by viewModel.relay.collectAsStateWithLifecycle()
val participants by viewModel.participants.collectAsStateWithLifecycle()
val status by viewModel.status.collectAsStateWithLifecycle()
val query by viewModel.query.collectAsStateWithLifecycle()
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
var input by remember { mutableStateOf("") }
var inputError by remember { mutableStateOf<String?>(null) }
val sending = status is BuzzNewDmViewModel.Status.Sending
@@ -131,22 +140,26 @@ fun BuzzNewDmScreen(
}
}
OutlinedTextField(
value = input,
value = query,
onValueChange = {
input = it
viewModel.updateQuery(it)
inputError = null
},
modifier = Modifier.fillMaxWidth(),
label = { Text(stringRes(R.string.buzz_dm_add_hint)) },
leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp)) },
singleLine = true,
isError = inputError != null,
supportingText = inputError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
// Enter accepts a pasted npub/hex directly — the escape hatch for someone
// not yet in the local cache, so they'd never surface in the search list.
onDone = {
val err = viewModel.addParticipant(input)
if (err == null) input = "" else inputError = err
if (query.isNotBlank()) {
inputError = viewModel.addRawKey(query)
}
},
),
)
@@ -155,7 +168,12 @@ fun BuzzNewDmScreen(
Text(it.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.weight(1f))
// Search results — workspace members first — fill the space above the pinned Start button.
LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
items(suggestions, key = { it }) { hex ->
SuggestionRow(hex, accountViewModel, nav) { viewModel.addParticipant(hex) }
}
}
Button(
onClick = {
@@ -184,6 +202,32 @@ fun BuzzNewDmScreen(
}
}
/** One tappable search result — avatar + resolved name — that adds the user as a recipient. */
@Composable
private fun SuggestionRow(
hex: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
onClick: () -> Unit,
) {
val user: User = remember(hex) { LocalCache.getOrCreateUser(hex) }
val name by observeUserName(user, accountViewModel)
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserPicture(hex, 34.dp, accountViewModel = accountViewModel, nav = nav)
Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge)
}
}
/** A removable chip for one added recipient — avatar + resolved name + a clear affordance. */
@Composable
private fun ParticipantChip(
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
@@ -31,6 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -56,6 +59,15 @@ class BuzzNewDmViewModel : ViewModel() {
private val _participants = MutableStateFlow<List<HexKey>>(emptyList())
val participants: StateFlow<List<HexKey>> = _participants.asStateFlow()
/** The current typeahead query and its resolved candidate pubkeys (members of this workspace first). */
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val _suggestions = MutableStateFlow<List<HexKey>>(emptyList())
val suggestions: StateFlow<List<HexKey>> = _suggestions.asStateFlow()
private var searchJob: Job? = null
private val _status = MutableStateFlow<Status>(Status.Idle)
val status: StateFlow<Status> = _status.asStateFlow()
@@ -86,21 +98,76 @@ class BuzzNewDmViewModel : ViewModel() {
}
/**
* Resolves [input] (npub or 64-char hex) to a pubkey and adds it. Returns an error
* string to surface, or null on success. Rejects me, duplicates, non-keys and the
* 8-participant ceiling.
* Updates the typeahead [text] and refreshes [suggestions] off the main thread. Members of
* this workspace's channels are surfaced first (they're the people you'd DM here), then the
* rest of the general user search. Already-added recipients and yourself are filtered out.
*/
fun addParticipant(input: String): String? {
fun updateQuery(text: String) {
_query.value = text
val account = account ?: return
searchJob?.cancel()
if (text.isBlank()) {
_suggestions.value = emptyList()
return
}
searchJob =
viewModelScope.launch(Dispatchers.IO) {
delay(150) // debounce keystrokes before touching the cache
val members = workspaceMemberKeys()
val me = account.userProfile().pubkeyHex
val already = _participants.value.toSet()
val ranked =
LocalCache
.findUsersStartingWith(text.trim(), account)
.asSequence()
.map { it.pubkeyHex }
.filter { it != me && it !in already }
// Stable sort keeps findUsersStartingWith's own relevance order within each bucket.
.sortedByDescending { it in members }
.take(12)
.toList()
_suggestions.value = ranked
}
}
/** The union of member + admin pubkeys across every channel this workspace relay hosts locally. */
private fun workspaceMemberKeys(): Set<HexKey> {
val relay = _relay.value ?: return emptySet()
val keys = HashSet<HexKey>()
LocalCache.getRelayGroupChannelsOnRelay(relay).forEach { channel ->
keys.addAll(channel.members)
channel.admins.forEach { keys.add(it.pubKey) }
}
return keys
}
/**
* Adds an already-resolved [hex] pubkey (a tapped search result). Returns an error string to
* surface, or null on success. Rejects me, duplicates, invalid keys and the 8-participant ceiling.
* Also clears the query so the suggestion list collapses after a pick.
*/
fun addParticipant(hex: HexKey): String? {
val account = account ?: return "Not ready"
val hex = decodePublicKeyAsHexOrNull(input.trim())?.takeIf { it.isValid() } ?: return "Not a valid npub or hex key"
if (!hex.isValid()) return "Not a valid key"
if (hex == account.userProfile().pubkeyHex) return "That's you"
val current = _participants.value
if (hex in current) return "Already added"
if (current.size >= DmOpenEvent.MAX_PARTICIPANTS) return "At most ${DmOpenEvent.MAX_PARTICIPANTS} others"
_participants.value = current + hex
_query.value = ""
_suggestions.value = emptyList()
return null
}
/**
* Resolves a pasted npub/hex [input] and adds it — the escape hatch for someone not yet in the
* local cache (so they never surface in [updateQuery]'s search). Returns an error or null.
*/
fun addRawKey(input: String): String? {
val hex = decodePublicKeyAsHexOrNull(input.trim())?.takeIf { it.isValid() } ?: return "Not a valid npub or hex key"
return addParticipant(hex)
}
fun removeParticipant(hex: HexKey) {
_participants.update { it - hex }
}
@@ -98,9 +98,18 @@ class BuzzRelayImportViewModel : ViewModel() {
// The user came here to import from THIS relay: remember it as a joined workspace (persisted,
// marks the Buzz dialect) and pre-approve NIP-42 auth so the `#p=me` read below is served.
BuzzWorkspaces.join(normalized)
val newlyJoined = BuzzWorkspaces.join(normalized)
viewModelScope.launch { account.relayAuthLedger.setDecision(normalized.url, RelayAuthDecision.ALLOW) }
// NIP-42 sends its AUTH challenge once, on connect. If the socket was already open before this
// join (the common case — the relay is in the user's lists and connected at startup), that
// challenge was spent while the relay was still NOT first-party, so the connection is
// unauthenticated and the persistent group-roster (39002) subscription is refused. Joining
// makes the relay first-party (see AuthCoordinator.isFirstParty); force a reconnect so the
// relay re-challenges and the connection authenticates — unlocking the roster (Join gate) and
// every other `#p=me`-gated read on the shared socket.
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
// Seed "already added" from the current kind-10009 list so channels the user already has
// render as added rather than offering a duplicate Add.
_added.value =