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