mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
+12
-3
@@ -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) {
|
||||
|
||||
+18
-5
@@ -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. */
|
||||
|
||||
+53
-9
@@ -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(
|
||||
|
||||
+72
-5
@@ -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 }
|
||||
}
|
||||
|
||||
+10
-1
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user