mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
@@ -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
|
||||
|
||||
+304
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+225
@@ -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()
|
||||
}
|
||||
}
|
||||
+206
@@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
+174
@@ -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
|
||||
}
|
||||
}
|
||||
+53
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
+130
@@ -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()
|
||||
}
|
||||
}
|
||||
+99
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user