feat(buzz): wire Buzz direct messages end-to-end (app + amy)

A Buzz DM is a relay-authoritative NIP-29 group whose h/id is a
relay-generated UUID, so its timeline reuses the whole relay-group chat
stack unchanged. This adds the missing discovery + product layer:

- commons: BuzzDmRegistry — process-wide registry fed by LocalCache from
  the relay-signed DmCreatedEvent (41001) and per-viewer DmVisibilityEvent
  (30622); tracks conversations (channel id -> participants/relay) and the
  viewer's hidden set. Unit-tested.
- LocalCache: record 41001/30622 into the registry on consume (was
  store-only).
- Account: openBuzzDm (41010), hideBuzzDm (41012), addBuzzDmMember (41011).
  The relay assigns the channel UUID and confirms via 41001 — we never
  mint it.
- Android: BuzzDmListViewModel (two-phase fetch: discover 41001/30622 #p=me,
  then fetch each DM's 39000-39003 roster so the shared composer's member
  gate passes), BuzzDmListScreen (inbox), BuzzNewDmScreen (publish 41010,
  await the 41001, jump into the shared RelayGroupChatScreen). Reached from
  a Direct Messages card on the Workspaces tab.
- CLI: amy buzz dm list/open/hide/add-member, mirroring buzz-cli.

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 14:09:45 +00:00
parent c788c3caa5
commit c79e7d361f
15 changed files with 1442 additions and 5 deletions
@@ -153,6 +153,9 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
@@ -2915,6 +2918,37 @@ class Account(
client.publish(signed, setOf(channel.groupId.relayUrl))
}
/**
* Open (or re-surface) a Buzz DM with [participants] on [relay] via a kind-41010
* command. [participants] are the OTHER 1-8 people — the relay adds me, derives the
* canonical channel UUID, and confirms with a relay-signed [DmCreatedEvent]
* (kind-41001) that lands in [com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry].
* We never assign the channel id ourselves, so callers discover the materialized DM
* by watching that registry rather than from this call's return.
*/
suspend fun openBuzzDm(
relay: NormalizedRelayUrl,
participants: List<HexKey>,
) {
val template = DmOpenEvent.build(participants)
signAndSendPrivatelyOrBroadcast(template) { listOf(relay) }
}
/** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */
suspend fun hideBuzzDm(channel: RelayGroupChannel) {
val template = DmHideEvent.build(channel.groupId.id)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Add [member] to an existing group DM with a kind-41011 command (creates a new DM set). */
suspend fun addBuzzDmMember(
channel: RelayGroupChannel,
member: HexKey,
) {
val template = DmAddMemberEvent.build(channel.groupId.id, member)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
val template = LeaveRequestEvent.build(channel.groupId.id)
@@ -28,6 +28,8 @@ 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
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
@@ -2136,6 +2138,36 @@ 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?,
wasVerified: Boolean,
): Boolean =
// The relay-signed, `#p`-gated per-viewer hidden-DM snapshot (30622). Store the
// addressable event AND mirror the viewer's hidden set into the registry so a
// hidden DM drops out of that viewer's list until it's re-opened.
consumeBaseReplaceable(event, relay, wasVerified).also {
val viewer = event.viewer().ifBlank { return@also }
BuzzDmRegistry.recordHidden(viewer, event.hiddenChannels().toSet())
}
private fun consume(
event: CanvasEvent,
relay: NormalizedRelayUrl?,
@@ -4615,7 +4647,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is WorkflowDefEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EventReminderEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is PushLeaseEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is DmVisibilityEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is DmVisibilityEvent -> consume(event, relay, wasVerified)
is WindowBoundsEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ArchivedIdentitiesListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
@@ -4624,7 +4656,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 -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmCreatedEvent -> consume(event, relay, wasVerified)
is DmOpenEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
@@ -103,7 +103,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentAttestationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentConsoleScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentPersonaEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzCanvasScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzNewDmScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspacesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
@@ -460,6 +462,8 @@ fun BuildNavigation(
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
composableFromEnd<Route.BuzzWorkspaces> { BuzzWorkspacesScreen(accountViewModel, nav) }
composableFromEnd<Route.BuzzDmList> { BuzzDmListScreen(accountViewModel, nav) }
composableFromEnd<Route.BuzzNewDm> { BuzzNewDmScreen(accountViewModel, nav) }
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
@@ -721,6 +721,10 @@ sealed class Route {
@Serializable object BuzzWorkspaces : Route()
@Serializable object BuzzDmList : Route()
@Serializable object BuzzNewDm : Route()
@Serializable object RelayGroupBrowse : Route()
// Concord Channels (encrypted communities). Addressed by community id + channel id
@@ -0,0 +1,304 @@
/*
* 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.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.offset
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.CircleShape
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.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.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
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
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.note.UserPicture
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.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.launch
/**
* The **Buzz Direct Messages** inbox. A Buzz DM is a relay-authoritative NIP-29 group
* whose id is a UUID, so tapping a row opens the very same [Route.RelayGroup] chat screen
* every workspace channel uses this screen only lists the conversations (discovered from
* the relay's kind-41001 confirmations via [BuzzDmListViewModel]) and starts new ones.
*/
@Composable
fun BuzzDmListScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val pubkey = accountViewModel.account.userProfile().pubkeyHex
val viewModel: BuzzDmListViewModel = viewModel(key = "BuzzDmList-$pubkey")
viewModel.bindAccountIfMissing(accountViewModel.account)
val rows by viewModel.rows.collectAsStateWithLifecycle()
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_dm_title), nav) },
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text(stringRes(R.string.buzz_dm_new)) },
icon = { Icon(symbol = MaterialSymbols.Add, contentDescription = null) },
onClick = { nav.nav(Route.BuzzNewDm) },
)
},
) { padding ->
if (rows.isEmpty()) {
EmptyDmInbox(modifier = Modifier.padding(padding))
} else {
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(rows, key = { it.channelId }) { row ->
DmRowCard(row, accountViewModel, nav)
}
}
}
}
}
/** One conversation: a participant avatar (stacked for group DMs), names, host, last-seen, kebab. */
@Composable
private fun DmRowCard(
row: BuzzDmListViewModel.DmRow,
accountViewModel: AccountViewModel,
nav: INav,
) {
val groupId = remember(row.channelId, row.relayUrl) { GroupId(row.channelId, row.relayUrl) }
var menuOpen by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Card(
onClick = { nav.nav(Route.RelayGroup(groupId.id, groupId.relayUrl.url)) },
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DmAvatars(row.others, accountViewModel, nav)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
DmTitle(row.others, accountViewModel)
Text(
text = row.relayUrl.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(8.dp))
Text(
text = timeAgoShort(row.lastActivity, stringRes(R.string.now)),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.buzz_dm_more),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.clip(CircleShape)
.clickable { menuOpen = true }
.size(28.dp)
.padding(4.dp),
)
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(stringRes(R.string.buzz_dm_hide)) },
onClick = {
menuOpen = false
scope.launch {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
accountViewModel.account.hideBuzzDm(channel)
}
},
)
}
}
}
}
}
/** The DM title: the other participants' display names, comma-joined (or a "+N" tail). */
@Composable
private fun DmTitle(
others: List<HexKey>,
accountViewModel: AccountViewModel,
) {
// `map` is inline (a @Composable call is legal inside it); resolve every shown name
// first, then join the plain strings — joinToString is NOT inline so can't call one.
val text =
if (others.isEmpty()) {
stringRes(R.string.buzz_dm_just_you)
} else {
val shown = others.take(2).map { UserName(it, accountViewModel) }
val joined = shown.joinToString(", ")
if (others.size > 2) "$joined +${others.size - 2}" else joined
}
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
/** A resolved display name for [userHex] (falls back to a short pubkey while loading). */
@Composable
private fun UserName(
userHex: HexKey,
accountViewModel: AccountViewModel,
): String {
val user: User = remember(userHex) { LocalCache.getOrCreateUser(userHex) }
val name by observeUserName(user, accountViewModel)
return name
}
/** A single avatar for a 1:1 DM, or two overlapping avatars for a group DM. */
@Composable
private fun DmAvatars(
others: List<HexKey>,
accountViewModel: AccountViewModel,
nav: INav,
) {
when {
others.isEmpty() ->
Box(
modifier = Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoAwesome,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(22.dp),
)
}
others.size == 1 -> UserPicture(others[0], 44.dp, accountViewModel = accountViewModel, nav = nav)
else ->
Box(modifier = Modifier.size(44.dp)) {
UserPicture(
others[1],
30.dp,
pictureModifier = Modifier.align(Alignment.BottomEnd),
accountViewModel = accountViewModel,
nav = nav,
)
UserPicture(
others[0],
30.dp,
pictureModifier =
Modifier
.align(Alignment.TopStart)
.offset(x = (-2).dp, y = (-2).dp),
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
/** Inviting empty state for a fresh DM inbox. */
@Composable
private fun EmptyDmInbox(modifier: Modifier = Modifier) {
Card(
modifier = modifier.padding(24.dp).fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier.size(56.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(28.dp),
)
}
Text(
text = stringRes(R.string.buzz_dm_empty_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = stringRes(R.string.buzz_dm_empty_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,225 @@
/*
* 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.runtime.Immutable
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.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.stream.StreamMessageV2Event
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.nipC7Chats.ChatEvent
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
/**
* 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.
*/
class BuzzDmListViewModel : ViewModel() {
@Volatile private var account: Account? = null
private val refreshMutex = Mutex()
private var liveJob: Job? = null
private val _rows = MutableStateFlow<List<DmRow>>(emptyList())
val rows: StateFlow<List<DmRow>> = _rows.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
/** One inbox row: a materialized DM plus what the UI needs to render it. */
@Immutable
data class DmRow(
val channelId: String,
val relayUrl: NormalizedRelayUrl,
/** All participants (from the 41001), 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). */
val lastActivity: Long,
)
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 {
_isLoading.value = true
try {
fetchDiscovery(account)
fetchRosters(account)
rebuildRows(account)
} finally {
_isLoading.value = false
}
}
}
}
/**
* 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) {
val myPubkey = account.userProfile().pubkeyHex
val relays = BuzzRelayDialect.flow.value
if (relays.isEmpty()) return
val filters =
listOf(
Filter(kinds = listOf(DmCreatedEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
)
account.client.fetchAllPagesFromPool(relays.associateWith { filters }) { _, _ -> }
}
/**
* 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
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 }),
),
)
}
if (byRelay.isEmpty()) return
account.client.fetchAllPagesFromPool(byRelay) { _, _ -> }
}
private fun rebuildRows(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
_rows.value =
BuzzDmRegistry
.visibleFor(myPubkey)
.map { dm ->
DmRow(
channelId = dm.channelId,
relayUrl = dm.relay,
allParticipants = dm.participants,
others = dm.participants.filter { it != myPubkey },
lastActivity = lastActivityFor(dm.channelId, dm.createdAt),
)
}.sortedByDescending { it.lastActivity }
}
/** Newest message `created_at` for [channelId] from [LocalCache], or [fallback] when empty. */
private fun lastActivityFor(
channelId: String,
fallback: Long,
): Long =
LocalCache
.filter(
Filter(
kinds = listOf(ChatEvent.KIND, StreamMessageV2Event.KIND),
tags = mapOf("h" to listOf(channelId)),
),
).maxOfOrNull { it.createdAt() ?: 0L }
?.takeIf { it > 0L }
?: fallback
/**
* 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.
*/
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 ->
launch {
account.client
.subscribeAsFlow(
relay,
Filter(kinds = listOf(DmCreatedEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
).collect { /* consumed globally by CacheClientConnector */ }
}
launch {
account.client
.subscribeAsFlow(
relay,
Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
).collect { }
}
}
// (b) Re-project whenever the registry (conversations or my hidden set) changes.
launch {
combine(BuzzDmRegistry.conversations, BuzzDmRegistry.hidden) { _, _ -> }
.collect { rebuildRows(account) }
}
}
}
override fun onCleared() {
liveJob?.cancel()
liveJob = null
super.onCleared()
}
}
@@ -0,0 +1,206 @@
/*
* 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.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.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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.InputChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
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
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
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.note.UserPicture
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.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.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun BuzzNewDmScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: BuzzNewDmViewModel = viewModel()
viewModel.bindAccountIfMissing(accountViewModel.account)
val relays by viewModel.relays.collectAsStateWithLifecycle()
val selectedRelay by viewModel.relay.collectAsStateWithLifecycle()
val participants by viewModel.participants.collectAsStateWithLifecycle()
val status by viewModel.status.collectAsStateWithLifecycle()
var input by remember { mutableStateOf("") }
var inputError by remember { mutableStateOf<String?>(null) }
val sending = status is BuzzNewDmViewModel.Status.Sending
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_dm_new), nav) },
) { padding ->
Column(
modifier = Modifier.padding(padding).fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Workspace relay — a DM lives on exactly one Buzz relay (its tenant).
if (relays.isNotEmpty()) {
Text(
text = stringRes(R.string.buzz_dm_workspace),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
relays.forEach { relay ->
FilterChip(
selected = relay == selectedRelay,
onClick = { viewModel.selectRelay(relay) },
label = { Text(relay.displayUrl(), maxLines = 1, overflow = TextOverflow.Ellipsis) },
)
}
}
}
// Recipients
Text(
text = stringRes(R.string.buzz_dm_recipients),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
if (participants.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
participants.forEach { hex ->
ParticipantChip(hex, accountViewModel, nav) { viewModel.removeParticipant(hex) }
}
}
}
OutlinedTextField(
value = input,
onValueChange = {
input = it
inputError = null
},
modifier = Modifier.fillMaxWidth(),
label = { Text(stringRes(R.string.buzz_dm_add_hint)) },
singleLine = true,
isError = inputError != null,
supportingText = inputError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
val err = viewModel.addParticipant(input)
if (err == null) input = "" else inputError = err
},
),
)
(status as? BuzzNewDmViewModel.Status.Error)?.let {
Text(it.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.weight(1f))
Button(
onClick = {
viewModel.start { groupId ->
if (groupId != null) {
nav.newStack(Route.RelayGroup(groupId.id, groupId.relayUrl.url))
} else {
nav.newStack(Route.BuzzDmList)
}
}
},
enabled = !sending && participants.isNotEmpty() && selectedRelay != null,
modifier = Modifier.fillMaxWidth(),
) {
if (sending) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(10.dp))
Text(stringRes(R.string.buzz_dm_opening))
} else {
Icon(symbol = MaterialSymbols.AutoMirrored.Send, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(10.dp))
Text(stringRes(R.string.buzz_dm_start))
}
}
}
}
}
/** A removable chip for one added recipient — avatar + resolved name + a clear affordance. */
@Composable
private fun ParticipantChip(
hex: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
onRemove: () -> Unit,
) {
val user: User = remember(hex) { LocalCache.getOrCreateUser(hex) }
val name by observeUserName(user, accountViewModel)
InputChip(
selected = false,
onClick = onRemove,
label = { Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
avatar = { UserPicture(hex, 22.dp, accountViewModel = accountViewModel, nav = nav) },
trailingIcon = {
Icon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.buzz_dm_remove), modifier = Modifier.size(16.dp))
},
)
}
@@ -0,0 +1,174 @@
/*
* 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.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
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
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
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).
*/
class BuzzNewDmViewModel : ViewModel() {
@Volatile private var account: Account? = null
private val _relay = MutableStateFlow<NormalizedRelayUrl?>(null)
val relay: StateFlow<NormalizedRelayUrl?> = _relay.asStateFlow()
private val _relays = MutableStateFlow<List<NormalizedRelayUrl>>(emptyList())
val relays: StateFlow<List<NormalizedRelayUrl>> = _relays.asStateFlow()
private val _participants = MutableStateFlow<List<HexKey>>(emptyList())
val participants: StateFlow<List<HexKey>> = _participants.asStateFlow()
private val _status = MutableStateFlow<Status>(Status.Idle)
val status: StateFlow<Status> = _status.asStateFlow()
sealed interface Status {
data object Idle : Status
data object Sending : Status
data class Error(
val message: String,
) : Status
}
fun bindAccountIfMissing(account: Account) {
if (this.account != null) return
this.account = account
val buzz =
BuzzRelayDialect.flow.value
.toList()
.sortedBy { it.url }
_relays.value = buzz
_relay.value = buzz.firstOrNull()
}
fun selectRelay(relay: NormalizedRelayUrl) {
_relay.value = relay
}
/**
* 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.
*/
fun addParticipant(input: String): 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 == 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
return null
}
fun removeParticipant(hex: HexKey) {
_participants.update { it - hex }
}
/**
* 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).
*/
fun start(onOpened: (GroupId?) -> Unit) {
val account = account ?: return
val relay =
_relay.value ?: run {
_status.value = Status.Error("Pick a workspace relay")
return
}
val others = _participants.value
if (others.isEmpty()) {
_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)
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
}
}
@@ -138,6 +138,8 @@ fun BuzzWorkspacesScreen(
) {
item { AgentConsoleHeroCard(onClick = { nav.nav(Route.AgentConsole) }) }
item { DirectMessagesCard(onClick = { nav.nav(Route.BuzzDmList) }) }
if (workspaces.isEmpty()) {
item { EmptyWorkspaces(onBrowse = { nav.nav(Route.RelayGroups) }) }
} else {
@@ -224,6 +226,57 @@ private fun AgentConsoleHeroCard(onClick: () -> Unit) {
}
}
/** A tonal card leading to the Buzz Direct Messages inbox. */
@Composable
private fun DirectMessagesCard(onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondary),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondary,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.buzz_dm_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Text(
text = stringRes(R.string.buzz_dm_card_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(22.dp),
)
}
}
}
/**
* 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
+14
View File
@@ -3309,6 +3309,20 @@
<string name="buzz_forum_new_title">New forum topic</string>
<string name="buzz_forum_body_label">What do you want to discuss?</string>
<string name="buzz_forum_post_action">Post topic</string>
<string name="buzz_dm_title">Direct Messages</string>
<string name="buzz_dm_card_subtitle">Private conversations on your workspaces</string>
<string name="buzz_dm_new">New message</string>
<string name="buzz_dm_empty_title">No direct messages yet</string>
<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_just_you">Just you</string>
<string name="buzz_dm_workspace">Workspace</string>
<string name="buzz_dm_recipients">To</string>
<string name="buzz_dm_add_hint">Add someone (npub or hex)</string>
<string name="buzz_dm_start">Start conversation</string>
<string name="buzz_dm_opening">Opening…</string>
<string name="buzz_dm_remove">Remove</string>
<string name="chat_delivery_details_title">Message Delivery</string>
<string name="close">Close</string>
+4
View File
@@ -606,6 +606,10 @@ and `commons` aggregator the app uses.
| `amy buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]` | Sign a NIP-OA attestation authorizing AGENT (offline; needs a local key). Prints the `auth` tag to hand to the agent operator. |
| `amy buzz console [--relays R,R] [--timeout SECS]` | Fetch my kind:44200 turn metrics (`#p`=me), decrypt, and aggregate fleet + per-agent cost/tokens. |
| `amy buzz personas [--relays R,R] [--timeout SECS]` | List my kind:30175 persona definitions (newest per slug). |
| `amy buzz dm list [--relays R,R] [--limit N] [--timeout SECS]` | List my DMs from the relay-signed kind:41001 confirmations (`#p`=me): dm id + participants. |
| `amy buzz dm open RELAY PUBKEY [PUBKEY…]` | Open (or re-surface) a DM with 1-8 people (kind:41010). The relay assigns the channel id and confirms via 41001. |
| `amy buzz dm hide RELAY CHANNEL` | Hide a DM from my sidebar (kind:41012); re-opening it un-hides. |
| `amy buzz dm add-member RELAY CHANNEL PUBKEY` | Add a member to an existing group DM (kind:41011). |
### Concord Channels (encrypted communities)
@@ -27,10 +27,15 @@ import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
@@ -55,6 +60,11 @@ object BuzzCommands {
| [--timeout SECS]
|amy buzz personas [--relays R,R] list my kind-30175 personas
| [--timeout SECS]
|amy buzz dm list [--relays R,R] list my DMs (kind-41001, #p = me)
| [--limit N] [--timeout SECS]
|amy buzz dm open RELAY PUBKEY [PUBKEY] open a DM with 1-8 people (kind-41010)
|amy buzz dm hide RELAY CHANNEL hide a DM from my sidebar (kind-41012)
|amy buzz dm add-member RELAY CHANNEL PUBKEY add a member to a group DM (kind-41011)
""".trimMargin()
suspend fun dispatch(
@@ -71,9 +81,139 @@ object BuzzCommands {
"attest" to { rest -> attest(dataDir, rest) },
"console" to { rest -> console(dataDir, rest) },
"personas" to { rest -> personas(dataDir, rest) },
"dm" to { rest -> dm(dataDir, rest) },
),
)
/** `buzz dm …` — the Buzz direct-message sub-verbs (list / open / hide / add-member). */
private suspend fun dm(
dataDir: DataDir,
tail: Array<String>,
): Int {
val usage =
"""
|amy buzz dm list [--relays R,R] [--limit N] [--timeout SECS]
|amy buzz dm open RELAY PUBKEY [PUBKEY]
|amy buzz dm hide RELAY CHANNEL
|amy buzz dm add-member RELAY CHANNEL PUBKEY
""".trimMargin()
return route(
"buzz dm",
tail,
usage,
mapOf(
"list" to { rest -> dmList(dataDir, rest) },
"open" to { rest -> dmOpen(dataDir, rest) },
"hide" to { rest -> dmHide(dataDir, rest) },
"add-member" to { rest -> dmAddMember(dataDir, rest) },
),
)
}
/** `buzz dm list` → drains the relay-signed kind-41001 confirmations addressed to me. */
private suspend fun dmList(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val relaysFlag = args.flag("relays")
val limit = args.flag("limit")?.toIntOrNull() ?: 50
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8
args.rejectUnknown("relays", "limit", "timeout")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val me = ctx.identity.pubKeyHex
val relays = relaysFor(ctx, relaysFlag)
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
val filter = Filter(kinds = listOf(DmCreatedEvent.KIND), tags = mapOf("p" to listOf(me)), limit = limit)
val dms =
ctx
.drainAllPages(relays.associateWith { listOf(filter) }, timeoutSecs * 1000)
.map { it.second }
.filterIsInstance<DmCreatedEvent>()
.distinctBy { it.id }
.sortedByDescending { it.createdAt }
.take(limit)
.map {
mapOf(
"dm_id" to it.dmId(),
"participants" to it.participants(),
"created_at" to it.createdAt,
)
}
Output.emit(mapOf("count" to dms.size, "dms" to dms))
return 0
}
}
/** `buzz dm open RELAY PUBKEY [PUBKEY…]` → publishes a kind-41010 with 1-8 `p` participants. */
private suspend fun dmOpen(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "buzz dm open RELAY PUBKEY [PUBKEY…]"
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val participants = mutableListOf<HexKey>()
var i = 1
while (true) {
val raw = args.positionalOrNull(i) ?: break
val hex =
decodePublicKeyAsHexOrNull(raw.trim())?.takeIf { it.isValid() }
?: return Output.error("bad_args", "invalid public key (npub or 64-char hex): $raw")
if (hex !in participants) participants.add(hex)
i++
}
if (participants.size !in DmOpenEvent.MIN_PARTICIPANTS..DmOpenEvent.MAX_PARTICIPANTS) {
return Output.error("bad_args", "a DM needs ${DmOpenEvent.MIN_PARTICIPANTS}-${DmOpenEvent.MAX_PARTICIPANTS} participants")
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
val signed = ctx.signer.sign(DmOpenEvent.build(participants))
val ack = ctx.publish(signed, setOf(relay))
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"relay" to relay.url,
"participants" to participants,
"published" to ack.values.any { it.accepted },
),
)
return 0
}
}
/** `buzz dm hide RELAY CHANNEL` → publishes a kind-41012 scoped by the DM's `h` tag. */
private suspend fun dmHide(
dataDir: DataDir,
rest: Array<String>,
): Int =
publishScoped(dataDir, rest, "buzz dm hide RELAY CHANNEL") { _, channelId, _ ->
DmHideEvent.build(channelId)
}
/** `buzz dm add-member RELAY CHANNEL PUBKEY` → publishes a kind-41011 (`h` + new `p`). */
private suspend fun dmAddMember(
dataDir: DataDir,
rest: Array<String>,
): Int {
val usage = "buzz dm add-member RELAY CHANNEL PUBKEY"
val memberInput = Args(rest).positionalOrNull(2) ?: return Output.error("bad_args", usage)
val member =
decodePublicKeyAsHexOrNull(memberInput.trim())?.takeIf { it.isValid() }
?: return Output.error("bad_args", "invalid public key (npub or 64-char hex): $memberInput")
return publishScoped(dataDir, rest, usage) { _, channelId, _ ->
DmAddMemberEvent.build(channelId, member)
}
}
/** `buzz post RELAY GID <text>` → publishes a kind-40002 stream message with an `h` tag. */
private suspend fun post(
dataDir: DataDir,
@@ -0,0 +1,130 @@
/*
* 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.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.
*
* 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.
*
* Like [BuzzRelayDialect] / `BuzzWorkspaceStates`, a singleton (one copy per process).
*/
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. */
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,
channelIds: Set<String>,
) = lock.withLock {
if (hiddenByViewer[viewer] == channelIds) return@withLock
if (channelIds.isEmpty()) {
hiddenByViewer.remove(viewer)
} else {
hiddenByViewer[viewer] = channelIds
}
mutableHidden.value = hiddenByViewer.toMap()
}
/** 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()
}
}
@@ -0,0 +1,99 @@
/*
* 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 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 })
}
@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" })
// Clearing alice's hide (empty snapshot) brings it back.
BuzzDmRegistry.recordHidden(alice, emptySet())
assertTrue(BuzzDmRegistry.visibleFor(alice).any { it.channelId == "chan-1" })
}
}
@@ -217,12 +217,26 @@ the Threads tab is the read-side follow-up.)
mirrors `BuzzHeldAttestations` to the device-global DataStore and reloads it at startup,
**re-verifying each against its agent key** so a tampered on-disk credential is dropped.
**Direct messages are wired** end-to-end. A Buzz DM is NOT NIP-17 gift-wrap — it's a
relay-authoritative NIP-29 group whose `h`/id is a relay-generated UUID, its messages plain
kind-9/40002 with `h` = that id, gated by the relay to its members (confidentiality is
relay-side, not E2E). Flow:
- **discovery**`BuzzDmListViewModel` fetches + live-subscribes the relay-signed
`DmCreatedEvent` (41001, `#p` = me) and per-viewer `DmVisibilityEvent` (30622); `LocalCache`
consumes them into `commons/.../buzz/BuzzDmRegistry` (channel id → participants/relay, plus the
viewer's hidden set). It then fetches each DM's NIP-29 directory (39000-39003 `#d`) so the
relay-signed roster is present — a DM isn't in the joined-group list, so nothing else would.
- **open/hide/add-member**`Account.openBuzzDm` (41010, relay assigns the UUID and confirms via
41001; we never mint it), `hideBuzzDm` (41012), `addBuzzDmMember` (41011).
- **UI**`BuzzDmListScreen` (inbox) + `BuzzNewDmScreen` (publish 41010, await the 41001, jump
into the shared `RelayGroupChatScreen`); the whole chat timeline + composer is reused unchanged.
- **CLI**`amy buzz dm list/open/hide/add-member`.
Still not wired: **presence (kind 20001)** — accepted by Buzz but it collides with Amethyst's
`GeohashPresenceEvent` in `EventFactory` (20001 is registered as geohash presence), so it needs
disambiguation before it can render (typing/20002 has no such collision). The **job/huddle/DM
disambiguation before it can render (typing/20002 has no such collision). The **job/huddle
composers** are deferred: jobs (43xxx) and huddles (48xxx) have **no builder** in `buzz-sdk`
(reserved kinds), so a composer would encode an unconfirmed schema; DM (`build_dm_open`, 41001)
is a full encrypted-DM subsystem for a later pass.
(reserved kinds), so a composer would encode an unconfirmed schema.
## Owner Attestation (NIP-OA) — implemented