feat(buzz): auto-auth joined workspaces, post-join UX, DM add-member, leave + UI polish

- Auto-authenticate relays for Buzz workspaces the user explicitly joined:
  their read-only #p=me channel/DM discovery is otherwise not first-party, so
  the p-gated 44100/30622 reads were never served and workspaces stayed empty.
- Invite screen: two-state hand-off — join + pre-approve NIP-42, launch the
  in-app window.nostr browser, then point back to the workspaces hub.
- DM inbox: add-member action (npub/hex dialog → kind-41011) alongside hide.
- Workspaces hub: leave-workspace overflow on each header.
- Elevate the Buzz surface with a shared BuzzBrand gradient design kit — hero
  masthead with live workspace/channel stats, cohesive across screens.
- Drop the dead kind-41001 DM-conversation path: the deployed relay never
  emits a queryable 41001, so BuzzDmRegistry is trimmed to the 30622 hidden
  set and LocalCache stores DmCreatedEvent without registry bookkeeping.

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 19:06:46 +00:00
parent f8bb53848b
commit 4d0e3d5b46
9 changed files with 344 additions and 163 deletions
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmConversation
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
@@ -2138,23 +2137,6 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
private fun consume(
event: DmCreatedEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
// A relay-signed DM confirmation (41001): store it AND record the "this UUID is a
// DM" fact into the process-wide registry so the DM list can surface it and the
// workspace list can exclude it. The channel timeline reuses the relay-group stack.
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val channelId = event.dmId().ifBlank { return@also }
// The 41001 is relay-authored, so provenance is always the workspace relay.
val provenance = relay ?: return@also
BuzzDmRegistry.record(
BuzzDmConversation(channelId, event.participants(), event.createdAt, provenance),
)
}
private fun consume(
event: DmVisibilityEvent,
relay: NormalizedRelayUrl?,
@@ -4656,7 +4638,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is StreamMessageBookmarkedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamMessageScheduledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamReminderEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmCreatedEvent -> consume(event, relay, wasVerified)
is DmCreatedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmOpenEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
@@ -210,12 +211,17 @@ class AuthCoordinator(
account: Account,
relayUrl: NormalizedRelayUrl,
): Boolean =
RelayAuthFirstParty.hasReason(
me = account.pubKey,
relayUrl = relayUrl,
pendingEvents = client.activeOutboxEvents(relayUrl),
myRelays = account.trustedRelays.flow.value,
)
// A Buzz workspace the user explicitly joined is a first-party reason to authenticate: its
// channel/DM discovery is read-only (`#p` = me), which is otherwise deliberately NOT
// first-party, so without this the p-gated 44100/30622 reads would never be served and the
// workspace would stay empty.
BuzzWorkspaces.isJoined(relayUrl) ||
RelayAuthFirstParty.hasReason(
me = account.pubKey,
relayUrl = relayUrl,
pendingEvents = client.activeOutboxEvents(relayUrl),
myRelays = account.trustedRelays.flow.value,
)
fun destroy() {
receiver.destroy()
@@ -0,0 +1,56 @@
/*
* 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.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
/**
* The shared visual language for the Buzz surface one energetic diagonal gradient derived from
* the active Material theme (so it stays coherent in light and dark, and follows any custom theme)
* rather than hard-coded brand colors. Used on the workspace hero, the console/DM action cards and
* the invite screen so every Buzz screen reads as one exciting, cohesive product.
*/
object BuzzBrand {
/** The signature diagonal wash: primary → tertiary → secondary, top-start to bottom-end. */
@Composable
@ReadOnlyComposable
fun heroBrush(): Brush {
val scheme = MaterialTheme.colorScheme
return Brush.linearGradient(listOf(scheme.primary, scheme.tertiary, scheme.secondary))
}
/** A softer container wash for cards that sit on the surface, not the hero. */
@Composable
@ReadOnlyComposable
fun cardBrush(): Brush {
val scheme = MaterialTheme.colorScheme
return Brush.linearGradient(listOf(scheme.primaryContainer, scheme.tertiaryContainer))
}
/** Content color that stays legible on top of [heroBrush]. */
@Composable
@ReadOnlyComposable
fun onHero(): Color = MaterialTheme.colorScheme.onPrimary
}
@@ -37,14 +37,17 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -73,7 +76,9 @@ import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.launch
@@ -129,8 +134,22 @@ private fun DmRowCard(
) {
val groupId = remember(row.channelId, row.relayUrl) { GroupId(row.channelId, row.relayUrl) }
var menuOpen by remember { mutableStateOf(false) }
var addMemberOpen by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (addMemberOpen) {
AddDmMemberDialog(
onDismiss = { addMemberOpen = false },
onAdd = { hex ->
addMemberOpen = false
scope.launch {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
accountViewModel.account.addBuzzDmMember(channel, hex)
}
},
)
}
Card(
onClick = { nav.nav(Route.RelayGroup(groupId.id, groupId.relayUrl.url)) },
modifier = Modifier.fillMaxWidth(),
@@ -170,8 +189,29 @@ private fun DmRowCard(
.padding(4.dp),
)
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(stringRes(R.string.buzz_dm_add_member)) },
leadingIcon = {
Icon(
symbol = MaterialSymbols.PersonAdd,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
},
onClick = {
menuOpen = false
addMemberOpen = true
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.buzz_dm_hide)) },
leadingIcon = {
Icon(
symbol = MaterialSymbols.VisibilityOff,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
},
onClick = {
menuOpen = false
scope.launch {
@@ -266,6 +306,56 @@ private fun DmAvatars(
}
}
/**
* Dialog to add another person to an existing group DM: accepts an npub or 64-char hex key,
* validates it locally, and hands the resolved hex to [onAdd] (which publishes kind-41011).
*/
@Composable
private fun AddDmMemberDialog(
onDismiss: () -> Unit,
onAdd: (HexKey) -> Unit,
) {
var input by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
val invalidMsg = stringRes(R.string.buzz_dm_add_member_invalid)
fun submit() {
val hex = decodePublicKeyAsHexOrNull(input.trim())?.takeIf { it.isValid() }
if (hex == null) {
error = invalidMsg
} else {
onAdd(hex)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.buzz_dm_add_member_title)) },
text = {
OutlinedTextField(
value = input,
onValueChange = {
input = it
error = null
},
modifier = Modifier.fillMaxWidth(),
label = { Text(stringRes(R.string.buzz_dm_add_hint)) },
singleLine = true,
isError = error != null,
supportingText = error?.let { { Text(it) } },
)
},
confirmButton = {
TextButton(onClick = { submit() }, enabled = input.isNotBlank()) {
Text(stringRes(R.string.buzz_dm_add_member))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
/** Inviting empty state for a fresh DM inbox. */
@Composable
private fun EmptyDmInbox(modifier: Modifier = Modifier) {
@@ -39,26 +39,34 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.launch
/**
* Landing screen for a Buzz workspace invite link (`https://<host>/invite/<token>`), reached
@@ -79,6 +87,8 @@ fun BuzzInviteScreen(
) {
val invite = remember(link) { BuzzInviteLink.parse(link) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
var launched by remember { mutableStateOf(false) }
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_invite_title), nav) },
@@ -147,21 +157,45 @@ fun BuzzInviteScreen(
Spacer(Modifier.weight(1f))
Button(
onClick = {
// 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,
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.AutoMirrored.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(10.dp))
Text(stringRes(R.string.buzz_invite_continue))
if (launched) {
// After the browser hand-off: gently point back to the workspace, which now
// authenticates + discovers its channels (the claim granted membership).
Text(
text = stringRes(R.string.buzz_invite_after_browser),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.size(4.dp))
Button(
onClick = { nav.newStack(Route.BuzzWorkspaces) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.AutoAwesome, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(10.dp))
Text(stringRes(R.string.buzz_invite_open_workspace))
}
} else {
Button(
onClick = {
// Remember the workspace as joined (persisted; marks it a Buzz dialect) and
// pre-approve NIP-42 auth to its relay so the read-only channel/DM discovery
// authenticates without a prompt, then hand off to the in-app window.nostr
// browser to accept terms + sign the claim.
RelayUrlNormalizer.normalizeOrNull(invite.relayUrl())?.let { relay ->
BuzzWorkspaces.join(relay)
scope.launch { accountViewModel.account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) }
}
FavoriteAppLauncher.launchUrl(context, link)
launched = true
},
enabled = !expired,
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.AutoMirrored.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(10.dp))
Text(stringRes(R.string.buzz_invite_continue))
}
}
}
}
@@ -39,17 +39,22 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
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.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -59,6 +64,7 @@ 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.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
@@ -145,11 +151,15 @@ fun BuzzWorkspacesScreen(
},
accountViewModel = accountViewModel,
) { padding ->
val totalChannels = remember(workspaces) { workspaces.sumOf { it.second.size } }
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { BuzzHeroHeader(workspaceCount = workspaces.size, channelCount = totalChannels) }
item { AgentConsoleHeroCard(onClick = { nav.nav(Route.AgentConsole) }) }
item { DirectMessagesCard(onClick = { nav.nav(Route.BuzzDmList) }) }
@@ -176,6 +186,7 @@ fun BuzzWorkspacesScreen(
expanded = isOpen,
onToggle = { expanded[relayKey] = !isOpen },
onBrowse = { nav.nav(Route.RelayGroupServer(relayKey)) },
onLeave = { BuzzWorkspaces.leave(relay) },
)
}
if (isOpen) {
@@ -189,6 +200,52 @@ fun BuzzWorkspacesScreen(
}
}
/**
* The tab's masthead: a full-bleed [BuzzBrand.heroBrush] gradient banner with the Workspaces
* title, a one-line tagline, and a live "N workspaces · M channels" stat, so the surface reads
* as one energetic product the moment it opens.
*/
@Composable
private fun BuzzHeroHeader(
workspaceCount: Int,
channelCount: Int,
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(BuzzBrand.heroBrush())
.padding(20.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(
symbol = MaterialSymbols.AutoAwesome,
contentDescription = null,
tint = BuzzBrand.onHero(),
modifier = Modifier.size(30.dp),
)
Text(
text = stringRes(R.string.buzz_workspaces_hero_tagline),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = BuzzBrand.onHero(),
)
if (workspaceCount > 0) {
Text(
text =
pluralStringResource(R.plurals.buzz_workspaces_count, workspaceCount, workspaceCount) +
" · " +
pluralStringResource(R.plurals.buzz_workspaces_channel_count, channelCount, channelCount),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = BuzzBrand.onHero().copy(alpha = 0.9f),
)
}
}
}
}
/** A bold accent card leading to the Buzz-only Agent Console — the tab's signature surface. */
@Composable
private fun AgentConsoleHeroCard(onClick: () -> Unit) {
@@ -293,8 +350,9 @@ private fun DirectMessagesCard(onClick: () -> Unit) {
/**
* A workspace header (one per Buzz relay): its host, a channel count, an expand chevron to
* fold its channels, and a "browse all" affordance to the relay's full directory. Tapping
* the row toggles; the folder icon opens the relay's channel directory.
* fold its channels, a "browse all" affordance to the relay's full directory, and an overflow
* to leave the workspace. Tapping the row toggles; the add icon opens the relay's channel
* directory; the overflow leaves.
*/
@Composable
private fun WorkspaceHeader(
@@ -303,7 +361,9 @@ private fun WorkspaceHeader(
expanded: Boolean,
onToggle: () -> Unit,
onBrowse: () -> Unit,
onLeave: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
Row(
modifier =
Modifier
@@ -345,6 +405,36 @@ private fun WorkspaceHeader(
.padding(4.dp)
.size(18.dp),
)
Box {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.buzz_workspaces_leave),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.clip(CircleShape)
.clickable { menuOpen = true }
.padding(4.dp)
.size(18.dp),
)
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(stringRes(R.string.buzz_workspaces_leave)) },
leadingIcon = {
Icon(
symbol = MaterialSymbols.AutoMirrored.Logout,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
},
onClick = {
menuOpen = false
onLeave()
},
)
}
}
}
}
+15
View File
@@ -350,6 +350,14 @@
<item quantity="one">%1$d channel</item>
<item quantity="other">%1$d channels</item>
</plurals>
<plurals name="buzz_workspaces_count">
<item quantity="one">%1$d workspace</item>
<item quantity="other">%1$d workspaces</item>
</plurals>
<plurals name="buzz_workspaces_channel_count">
<item quantity="one">%1$d channel</item>
<item quantity="other">%1$d channels</item>
</plurals>
<plurals name="concord_member_count">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
@@ -3300,6 +3308,8 @@
<string name="buzz_typing_two">%1$s and %2$s are typing…</string>
<string name="buzz_typing_many">Several people are typing…</string>
<string name="buzz_workspaces_title">Workspaces</string>
<string name="buzz_workspaces_hero_tagline">Where humans and agents build together</string>
<string name="buzz_workspaces_leave">Leave workspace</string>
<string name="buzz_workspaces_section">Your workspaces</string>
<string name="buzz_workspaces_empty_title">No workspaces yet</string>
<string name="buzz_workspaces_empty_body">Buzz workspaces are relays where humans and AI agents build together. Join one to see it here.</string>
@@ -3316,6 +3326,9 @@
<string name="buzz_dm_empty_body">Start a private conversation with anyone on a Buzz workspace.</string>
<string name="buzz_dm_more">More</string>
<string name="buzz_dm_hide">Hide conversation</string>
<string name="buzz_dm_add_member">Add member</string>
<string name="buzz_dm_add_member_title">Add someone to this DM</string>
<string name="buzz_dm_add_member_invalid">Not a valid npub or hex key</string>
<string name="buzz_dm_just_you">Just you</string>
<string name="buzz_dm_workspace">Workspace</string>
<string name="buzz_dm_recipients">To</string>
@@ -3331,6 +3344,8 @@
<string name="buzz_invite_continue">Continue in browser</string>
<string name="buzz_invite_invalid">This doesn\'t look like a valid Buzz invite link.</string>
<string name="buzz_invite_expired">This invite has expired. Ask for a new one.</string>
<string name="buzz_invite_after_browser">Finish accepting the terms in the browser, then come back — your workspace will be waiting.</string>
<string name="buzz_invite_open_workspace">Open my workspaces</string>
<string name="chat_delivery_details_title">Message Delivery</string>
<string name="close">Close</string>
@@ -23,70 +23,31 @@ package com.vitorpamplona.amethyst.commons.model.buzz
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* One materialized Buzz DM conversation, as confirmed by the relay-signed
* `DmCreatedEvent` (`kind:41001`). A Buzz DM is a relay-authoritative NIP-29 group
* whose `h`/group id is a relay-generated UUID; its timeline (kind-9/40002 messages) is
* read and written through the very same relay-group chat stack as any workspace
* channel. This record is the missing "this UUID is a DM" fact that stack needs.
*/
data class BuzzDmConversation(
/** The DM channel id — the relay-generated group UUID (`d` tag of the 41001). */
val channelId: String,
/** Every participant (`p` tags of the 41001), including me. */
val participants: List<HexKey>,
/** The 41001 `created_at`, used as a tie-break when no messages exist yet. */
val createdAt: Long,
/** The workspace relay this DM lives on (the 41001's provenance relay). */
val relay: NormalizedRelayUrl,
)
/**
* Process-wide registry of Buzz DM conversations, fed by `LocalCache` as it consumes the
* relay's confirmations:
* - [record] on each `DmCreatedEvent` (`kind:41001`, `#p` = me) the conversation set.
* - [recordHidden] on each per-viewer `DmVisibilityEvent` (`kind:30622`) the viewer's
* hidden-DM set, so a hidden DM drops out of the list until re-opened.
* Per-viewer set of **hidden** Buzz DM channels, fed by the relay-signed per-viewer DM Visibility
* snapshot ([DmVisibilityEvent], `kind:30622`) as `LocalCache` consumes it.
*
* The channel id alone is a sound key (Buzz `h_grammar: uuid-v4-lowercase`), matching
* `BuzzWorkspaceStates`. Hidden sets are kept per-viewer because the 30622 snapshot is
* `#p`-gated to its owner and the process can switch accounts. Mutations are lock-guarded
* because consume runs across several relay reader threads.
* DM *discovery* itself doesn't live here: the deployed relay enumerates a member's channels via
* kind-44100 member-added notifications and marks DMs with the `t` tag on their kind-39000
* metadata (it does not emit a queryable kind-41001), so the DM inbox reads those directly. This
* registry only tracks which of those a given viewer has hidden, so a hidden DM drops out until
* it's re-opened.
*
* Like [BuzzRelayDialect] / `BuzzWorkspaceStates`, a singleton (one copy per process).
* Hidden sets are kept per-viewer because the 30622 snapshot is `#p`-gated to its owner and the
* process can switch accounts. Mutations are lock-guarded because consume runs across several relay
* reader threads. Like [BuzzRelayDialect] / `BuzzWorkspaces`, a process-wide singleton.
*/
object BuzzDmRegistry {
private val lock = KmpLock()
private val conversationsById = HashMap<String, BuzzDmConversation>()
private val hiddenByViewer = HashMap<HexKey, Set<String>>()
private val mutableConversations = MutableStateFlow<Map<String, BuzzDmConversation>>(emptyMap())
private val mutableHidden = MutableStateFlow<Map<HexKey, Set<String>>>(emptyMap())
/** All known DM conversations, keyed by channel id; UI collects this. */
val conversations: StateFlow<Map<String, BuzzDmConversation>> = mutableConversations
/** Per-viewer hidden-DM channel ids; UI collects this to filter the list. */
/** Per-viewer hidden-DM channel ids; the DM inbox collects this to filter itself. */
val hidden: StateFlow<Map<HexKey, Set<String>>> = mutableHidden
/**
* Records a materialized DM. Keeps the newest confirmation per channel (a re-open can
* re-emit the 41001 with a later `created_at`), so re-materialization never regresses
* the participant set.
*/
fun record(conversation: BuzzDmConversation) =
lock.withLock {
val prev = conversationsById[conversation.channelId]
if (prev == null || conversation.createdAt >= prev.createdAt) {
conversationsById[conversation.channelId] = conversation
mutableConversations.value = conversationsById.toMap()
}
}
/** Replaces [viewer]'s hidden-DM set with [channelIds] (the whole 30622 snapshot). */
fun recordHidden(
viewer: HexKey,
@@ -104,27 +65,10 @@ object BuzzDmRegistry {
/** The channel ids [viewer] has hidden (possibly empty). */
fun hiddenFor(viewer: HexKey): Set<String> = mutableHidden.value[viewer] ?: emptySet()
/** True when [channelId] is a known DM channel — lets the workspace list exclude DMs. */
fun isDm(channelId: String): Boolean = channelId in mutableConversations.value
/**
* [viewer]'s visible DM conversations (all known minus the viewer's hidden set),
* newest-DM-first. The UI may re-sort by last message time; this order is the sound
* fallback when a freshly-opened DM has no messages yet.
*/
fun visibleFor(viewer: HexKey): List<BuzzDmConversation> {
val hiddenSet = hiddenFor(viewer)
return mutableConversations.value.values
.filter { it.channelId !in hiddenSet }
.sortedByDescending { it.createdAt }
}
/** Test-only: clears all registry state so unit tests don't leak into each other. */
fun clearForTesting() =
lock.withLock {
conversationsById.clear()
hiddenByViewer.clear()
mutableConversations.value = emptyMap()
mutableHidden.value = emptyMap()
}
}
@@ -20,80 +20,44 @@
*/
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 BuzzDmRegistryTest {
private val relay = RelayUrlNormalizer.normalize("wss://buzz.example")
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private val carol = "c".repeat(64)
private fun dm(
id: String,
createdAt: Long,
participants: List<String> = listOf(alice, bob),
) = BuzzDmConversation(id, participants, createdAt, relay)
@BeforeTest fun setup() = BuzzDmRegistry.clearForTesting()
@AfterTest fun teardown() = BuzzDmRegistry.clearForTesting()
@Test
fun recordsAConversationAndFlagsItAsDm() {
BuzzDmRegistry.record(dm("chan-1", createdAt = 100))
assertTrue(BuzzDmRegistry.isDm("chan-1"))
assertFalse(BuzzDmRegistry.isDm("chan-unknown"))
assertEquals(listOf(alice, bob), BuzzDmRegistry.conversations.value["chan-1"]?.participants)
}
@Test
fun keepsTheNewestConfirmationPerChannel() {
BuzzDmRegistry.record(dm("chan-1", createdAt = 100, participants = listOf(alice, bob)))
// A re-open re-materializes with a later created_at and an expanded participant set.
BuzzDmRegistry.record(dm("chan-1", createdAt = 200, participants = listOf(alice, bob, carol)))
assertEquals(200, BuzzDmRegistry.conversations.value["chan-1"]?.createdAt)
assertEquals(listOf(alice, bob, carol), BuzzDmRegistry.conversations.value["chan-1"]?.participants)
// An older confirmation arriving late never regresses the record.
BuzzDmRegistry.record(dm("chan-1", createdAt = 50, participants = listOf(alice)))
assertEquals(200, BuzzDmRegistry.conversations.value["chan-1"]?.createdAt)
}
@Test
fun hiddenChannelsDropOutOfTheViewerList() {
BuzzDmRegistry.record(dm("chan-1", createdAt = 100))
BuzzDmRegistry.record(dm("chan-2", createdAt = 200))
BuzzDmRegistry.recordHidden(alice, setOf("chan-1"))
val visible = BuzzDmRegistry.visibleFor(alice)
assertEquals(listOf("chan-2"), visible.map { it.channelId })
assertEquals(setOf("chan-1"), BuzzDmRegistry.hiddenFor(alice))
}
@Test
fun visibleListIsNewestFirst() {
BuzzDmRegistry.record(dm("older", createdAt = 100))
BuzzDmRegistry.record(dm("newer", createdAt = 300))
BuzzDmRegistry.record(dm("middle", createdAt = 200))
assertEquals(listOf("newer", "middle", "older"), BuzzDmRegistry.visibleFor(bob).map { it.channelId })
fun recordsAndReadsAHiddenSet() {
BuzzDmRegistry.recordHidden(alice, setOf("chan-1", "chan-2"))
assertEquals(setOf("chan-1", "chan-2"), BuzzDmRegistry.hiddenFor(alice))
}
@Test
fun hiddenSetIsPerViewer() {
BuzzDmRegistry.record(dm("chan-1", createdAt = 100))
BuzzDmRegistry.recordHidden(alice, setOf("chan-1"))
// Bob has not hidden it, so it stays visible for him.
assertTrue(BuzzDmRegistry.visibleFor(bob).any { it.channelId == "chan-1" })
assertTrue(BuzzDmRegistry.visibleFor(alice).none { it.channelId == "chan-1" })
assertEquals(setOf("chan-1"), BuzzDmRegistry.hiddenFor(alice))
// Bob has hidden nothing.
assertEquals(emptySet(), BuzzDmRegistry.hiddenFor(bob))
}
// Clearing alice's hide (empty snapshot) brings it back.
@Test
fun anEmptySnapshotClearsTheViewer() {
BuzzDmRegistry.recordHidden(alice, setOf("chan-1"))
BuzzDmRegistry.recordHidden(alice, emptySet())
assertTrue(BuzzDmRegistry.visibleFor(alice).any { it.channelId == "chan-1" })
assertEquals(emptySet(), BuzzDmRegistry.hiddenFor(alice))
}
@Test
fun aLaterSnapshotReplacesTheWhole() {
BuzzDmRegistry.recordHidden(alice, setOf("chan-1", "chan-2"))
BuzzDmRegistry.recordHidden(alice, setOf("chan-3"))
assertEquals(setOf("chan-3"), BuzzDmRegistry.hiddenFor(alice))
}
}