diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 683c1cdf44..423f4c159d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index a38530a400..89e5d31506 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzBrand.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzBrand.kt new file mode 100644 index 0000000000..f16f753892 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzBrand.kt @@ -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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt index ef0ff312a3..e6ae9f8d28 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt @@ -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(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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteScreen.kt index 68d748717a..49120c24eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzInviteScreen.kt @@ -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:///invite/`), 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)) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesScreen.kt index 3b5d7e7a0a..148532a1b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesScreen.kt @@ -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() + }, + ) + } + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 24e28b8f81..d9f5588056 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -350,6 +350,14 @@ %1$d channel %1$d channels + + %1$d workspace + %1$d workspaces + + + %1$d channel + %1$d channels + %1$d member %1$d members @@ -3300,6 +3308,8 @@ %1$s and %2$s are typing… Several people are typing… Workspaces + Where humans and agents build together + Leave workspace Your workspaces No workspaces yet Buzz workspaces are relays where humans and AI agents build together. Join one to see it here. @@ -3316,6 +3326,9 @@ Start a private conversation with anyone on a Buzz workspace. More Hide conversation + Add member + Add someone to this DM + Not a valid npub or hex key Just you Workspace To @@ -3331,6 +3344,8 @@ Continue in browser This doesn\'t look like a valid Buzz invite link. This invite has expired. Ask for a new one. + Finish accepting the terms in the browser, then come back — your workspace will be waiting. + Open my workspaces Message Delivery Close diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistry.kt index 69cca9dd50..7087e6c1e9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistry.kt @@ -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, - /** 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() private val hiddenByViewer = HashMap>() - - private val mutableConversations = MutableStateFlow>(emptyMap()) private val mutableHidden = MutableStateFlow>>(emptyMap()) - /** All known DM conversations, keyed by channel id; UI collects this. */ - val conversations: StateFlow> = mutableConversations - - /** Per-viewer hidden-DM channel ids; UI collects this to filter the list. */ + /** Per-viewer hidden-DM channel ids; the DM inbox collects this to filter itself. */ val hidden: StateFlow>> = mutableHidden - /** - * Records a materialized DM. Keeps the newest confirmation per channel (a re-open can - * re-emit the 41001 with a later `created_at`), so re-materialization never regresses - * the participant set. - */ - fun record(conversation: BuzzDmConversation) = - lock.withLock { - val prev = conversationsById[conversation.channelId] - if (prev == null || conversation.createdAt >= prev.createdAt) { - conversationsById[conversation.channelId] = conversation - mutableConversations.value = conversationsById.toMap() - } - } - /** Replaces [viewer]'s hidden-DM set with [channelIds] (the whole 30622 snapshot). */ fun recordHidden( viewer: HexKey, @@ -104,27 +65,10 @@ object BuzzDmRegistry { /** The channel ids [viewer] has hidden (possibly empty). */ fun hiddenFor(viewer: HexKey): Set = mutableHidden.value[viewer] ?: emptySet() - /** True when [channelId] is a known DM channel — lets the workspace list exclude DMs. */ - fun isDm(channelId: String): Boolean = channelId in mutableConversations.value - - /** - * [viewer]'s visible DM conversations (all known minus the viewer's hidden set), - * newest-DM-first. The UI may re-sort by last message time; this order is the sound - * fallback when a freshly-opened DM has no messages yet. - */ - fun visibleFor(viewer: HexKey): List { - val hiddenSet = hiddenFor(viewer) - return mutableConversations.value.values - .filter { it.channelId !in hiddenSet } - .sortedByDescending { it.createdAt } - } - /** Test-only: clears all registry state so unit tests don't leak into each other. */ fun clearForTesting() = lock.withLock { - conversationsById.clear() hiddenByViewer.clear() - mutableConversations.value = emptyMap() mutableHidden.value = emptyMap() } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistryTest.kt index 03656d04fa..5b43fb2d08 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzDmRegistryTest.kt @@ -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 = 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)) } }