From c79e7d361fd344988e0746c8efb541b718764ce7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 14:09:45 +0000 Subject: [PATCH] feat(buzz): wire Buzz direct messages end-to-end (app + amy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8 --- .../vitorpamplona/amethyst/model/Account.kt | 34 ++ .../amethyst/model/LocalCache.kt | 36 ++- .../amethyst/ui/navigation/AppNavigation.kt | 4 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../screen/loggedIn/buzz/BuzzDmListScreen.kt | 304 ++++++++++++++++++ .../loggedIn/buzz/BuzzDmListViewModel.kt | 225 +++++++++++++ .../screen/loggedIn/buzz/BuzzNewDmScreen.kt | 206 ++++++++++++ .../loggedIn/buzz/BuzzNewDmViewModel.kt | 174 ++++++++++ .../loggedIn/buzz/BuzzWorkspacesScreen.kt | 53 +++ amethyst/src/main/res/values/strings.xml | 14 + cli/README.md | 4 + .../amethyst/cli/commands/BuzzCommands.kt | 140 ++++++++ .../commons/model/buzz/BuzzDmRegistry.kt | 130 ++++++++ .../commons/model/buzz/BuzzDmRegistryTest.kt | 99 ++++++ .../com/vitorpamplona/quartz/buzz/README.md | 20 +- 15 files changed, 1442 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistry.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistryTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4c322ea2b5..55f9b14b24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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, + ) { + 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 1e4cf779ec..683c1cdf44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index d50c007839..46b6fe585b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { PublicChatsScreen(accountViewModel, nav) } composableFromEnd { RelayGroupDiscoveryScreen(accountViewModel, nav) } composableFromEnd { BuzzWorkspacesScreen(accountViewModel, nav) } + composableFromEnd { BuzzDmListScreen(accountViewModel, nav) } + composableFromEnd { BuzzNewDmScreen(accountViewModel, nav) } composableFromEnd { FollowPacksScreen(accountViewModel, nav) } composableFromEnd { LiveStreamsScreen(accountViewModel, nav) } composableFromEnd { NestsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index ce72348918..60ba1cc593 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt new file mode 100644 index 0000000000..ef0ff312a3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt @@ -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, + 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, + 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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt new file mode 100644 index 0000000000..3b9e4c9779 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -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>(emptyList()) + val rows: StateFlow> = _rows.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _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, + /** Participants other than me — who the DM is "with". */ + val others: List, + /** 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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmScreen.kt new file mode 100644 index 0000000000..cd613cae5f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmScreen.kt @@ -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(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)) + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt new file mode 100644 index 0000000000..9228d72c32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt @@ -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(null) + val relay: StateFlow = _relay.asStateFlow() + + private val _relays = MutableStateFlow>(emptyList()) + val relays: StateFlow> = _relays.asStateFlow() + + private val _participants = MutableStateFlow>(emptyList()) + val participants: StateFlow> = _participants.asStateFlow() + + private val _status = MutableStateFlow(Status.Idle) + val status: StateFlow = _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, + ): 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesScreen.kt index f8a2f6057f..2250aef92c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesScreen.kt @@ -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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5bdbfc488e..d0e6ca58b9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3309,6 +3309,20 @@ New forum topic What do you want to discuss? Post topic + Direct Messages + Private conversations on your workspaces + New message + No direct messages yet + Start a private conversation with anyone on a Buzz workspace. + More + Hide conversation + Just you + Workspace + To + Add someone (npub or hex) + Start conversation + Opening… + Remove Message Delivery Close diff --git a/cli/README.md b/cli/README.md index 1f6760d397..231dc427fa 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt index 576472b5d3..7d9c4fea15 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt @@ -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, + ): 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, + ): 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() + .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, + ): 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() + 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, + ): 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, + ): 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 ` → publishes a kind-40002 stream message with an `h` tag. */ private suspend fun post( dataDir: DataDir, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistry.kt new file mode 100644 index 0000000000..69cca9dd50 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistry.kt @@ -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, + /** 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() + private val hiddenByViewer = HashMap>() + + private val mutableConversations = MutableStateFlow>(emptyMap()) + private val mutableHidden = MutableStateFlow>>(emptyMap()) + + /** All known DM conversations, keyed by channel id; UI collects this. */ + val conversations: StateFlow> = mutableConversations + + /** Per-viewer hidden-DM channel ids; UI collects this to filter the list. */ + val hidden: StateFlow>> = 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, + ) = 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 = 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 { + 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() + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistryTest.kt new file mode 100644 index 0000000000..03656d04fa --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistryTest.kt @@ -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 = 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" }) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md index d96b7d2f06..1712dd3c2e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md @@ -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