feat(buzz): import your workspace channels from a relay in Find groups

Buzz workspaces expose no public group directory (membership is server-side, no
kind-10009 join), so the generic "browse a relay" directory comes back empty for
them. Add a membership-scoped importer: from Find groups, enter the Buzz relay
you're already on and tap "Import your channels". It warm-authenticates the relay
(NIP-42), reads the relay's kind-44100 member-added notifications addressed to you,
fetches each channel's 39000 metadata, and lists your non-DM workspace channels
with Add / Add all.

Adding calls account.follow(channel) — a public kind-10009 group tag — so the
channel then flows through the existing relay-group machinery for free: it shows in
the Messages list (inline or grouped-by-community per relayGroupViewMode) and loads
at boot via the always-on relay-group subscription. No separate registry needed.
Entering the importer also joins + pre-approves NIP-42 for the relay so discovery
is served.

New: Route.BuzzRelayImport, BuzzRelayImportViewModel, BuzzRelayImportScreen, a
Find-groups entry point, and strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-22 21:23:01 +00:00
parent 2621fed63c
commit 08f581f29c
6 changed files with 497 additions and 0 deletions
@@ -107,6 +107,7 @@ 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.BuzzInviteScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzNewDmScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzRelayImportScreen
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
@@ -465,6 +466,7 @@ fun BuildNavigation(
composableFromEnd<Route.BuzzWorkspaces> { BuzzWorkspacesScreen(accountViewModel, nav) }
composableFromEnd<Route.BuzzDmList> { BuzzDmListScreen(accountViewModel, nav) }
composableFromEnd<Route.BuzzNewDm> { BuzzNewDmScreen(accountViewModel, nav) }
composableFromEndArgs<Route.BuzzRelayImport> { BuzzRelayImportScreen(it.relayUrl, accountViewModel, nav) }
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
@@ -725,6 +725,11 @@ sealed class Route {
@Serializable object BuzzNewDm : Route()
@Serializable
data class BuzzRelayImport(
val relayUrl: String,
) : Route()
@Serializable object RelayGroupBrowse : Route()
// Concord Channels (encrypted communities). Addressed by community id + channel id
@@ -0,0 +1,283 @@
/*
* 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.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.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.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.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.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
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.nip29RelayGroups.GroupId
/**
* Import the workspace channels the user already belongs to on a Buzz relay (joined via the Buzz
* web/desktop app) into Amethyst. Because Buzz membership is server-side with no public directory,
* this reads the relay's kind-44100 member-added notifications for the user, lists their non-DM
* channels, and adds the chosen ones to the kind-10009 list so they surface in Messages and load at
* boot. See [BuzzRelayImportViewModel].
*/
@Composable
fun BuzzRelayImportScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: BuzzRelayImportViewModel = viewModel(key = "BuzzRelayImport-$relayUrl")
viewModel.bind(accountViewModel.account, relayUrl)
val status by viewModel.status.collectAsStateWithLifecycle()
val channels by viewModel.channels.collectAsStateWithLifecycle()
val added by viewModel.added.collectAsStateWithLifecycle()
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_import_title), nav) },
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
when (val s = status) {
is BuzzRelayImportViewModel.Status.Loading ->
CenteredMessage {
CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp)
Spacer(Modifier.size(12.dp))
Text(stringRes(R.string.buzz_import_loading), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
is BuzzRelayImportViewModel.Status.Error ->
CenteredMessage {
Text(s.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyLarge)
}
is BuzzRelayImportViewModel.Status.Ready ->
if (channels.isEmpty()) {
CenteredMessage {
EmptyImport()
}
} else {
val allAdded = channels.all { it.id in added }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(R.string.buzz_import_your_channels),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (!allAdded) {
FilledTonalButton(onClick = { viewModel.addAll() }) {
Text(stringRes(R.string.buzz_import_add_all))
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(channels, key = { it.id }) { groupId ->
ImportRow(
groupId = groupId,
isAdded = groupId.id in added,
onAdd = { viewModel.add(groupId) },
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
}
}
@Composable
private fun ImportRow(
groupId: GroupId,
isAdded: Boolean,
onAdd: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
val name = channel.toBestDisplayName()
val memberCount = channel.memberCount()
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ImportAvatar(name = name, seed = groupId.id)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (memberCount > 0) {
Text(
text = "$memberCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (isAdded) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(4.dp))
Text(
text = stringRes(R.string.buzz_import_added),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
} else {
OutlinedButton(onClick = onAdd) {
Text(stringRes(R.string.buzz_import_add))
}
}
}
}
}
/** A round monogram whose color is derived deterministically from the channel id. */
@Composable
private fun ImportAvatar(
name: String,
seed: String,
) {
val hue = remember(seed) { (seed.hashCode().toLong() and 0xFFFFFF).toFloat() % 360f }
val color = remember(hue) { Color.hsl(hue, 0.55f, 0.5f) }
val initial =
remember(name) {
name
.trim()
.firstOrNull()
?.uppercaseChar()
?.toString() ?: "#"
}
Box(
modifier = Modifier.size(40.dp).clip(CircleShape).background(color),
contentAlignment = Alignment.Center,
) {
Text(text = initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = Color.White)
}
}
@Composable
private fun CenteredMessage(content: @Composable () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
content()
}
}
@Composable
private fun EmptyImport() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier.size(52.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoAwesome,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(26.dp),
)
}
Text(
text = stringRes(R.string.buzz_import_empty_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = stringRes(R.string.buzz_import_empty_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,183 @@
/*
* 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.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
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 java.util.Collections
/**
* Backing ViewModel for [BuzzRelayImportScreen]: given a Buzz workspace relay the user is
* already a member of (on the Buzz web/desktop app), it surfaces the workspace channels they
* belong to so they can add them to Amethyst.
*
* Buzz membership is server-side (no NIP-29 kind-10009 join event), so a Buzz relay exposes no
* public group directory — the generic "browse a relay" flow returns nothing. Instead the relay
* addresses each member a kind-44100 member-added notification (`#p` = me). This warm-authenticates
* the relay (NIP-42) and reads those, then fetches each channel's kind-39000 metadata to keep only
* the **non-DM** workspace channels (DMs surface separately). Adding one calls [Account.follow] to
* append it to the user's kind-10009 list — which makes it appear in the Messages list and load at
* boot through the existing relay-group machinery, no separate registry needed.
*/
class BuzzRelayImportViewModel : ViewModel() {
@Volatile private var account: Account? = null
private var relay: NormalizedRelayUrl? = null
val relayUrl: NormalizedRelayUrl? get() = relay
sealed interface Status {
data object Loading : Status
data object Ready : Status
data class Error(
val message: String,
) : Status
}
private val _status = MutableStateFlow<Status>(Status.Loading)
val status: StateFlow<Status> = _status.asStateFlow()
/** The non-DM workspace channels the user is a member of on this relay. */
private val _channels = MutableStateFlow<List<GroupId>>(emptyList())
val channels: StateFlow<List<GroupId>> = _channels.asStateFlow()
/** Channel ids already present in the user's kind-10009 list (seeded + updated as they add). */
private val _added = MutableStateFlow<Set<String>>(emptySet())
val added: StateFlow<Set<String>> = _added.asStateFlow()
fun bind(
account: Account,
relayUrlStr: String,
) {
if (this.account != null) return
val normalized =
RelayUrlNormalizer.normalizeOrNull(relayUrlStr) ?: run {
_status.value = Status.Error("Invalid relay URL")
return
}
this.account = account
this.relay = normalized
// The user came here to import from THIS relay: remember it as a joined workspace (persisted,
// marks the Buzz dialect) and pre-approve NIP-42 auth so the `#p=me` read below is served.
BuzzWorkspaces.join(normalized)
viewModelScope.launch { account.relayAuthLedger.setDecision(normalized.url, RelayAuthDecision.ALLOW) }
// Seed "already added" from the current kind-10009 list so channels the user already has
// render as added rather than offering a duplicate Add.
_added.value =
account.relayGroupList.liveRelayGroupList.value
.filter { RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == normalized }
.mapTo(mutableSetOf()) { it.groupId }
discover(account, normalized)
}
private fun discover(
account: Account,
relay: NormalizedRelayUrl,
) {
viewModelScope.launch(Dispatchers.IO) {
_status.value = Status.Loading
try {
val myPubkey = account.userProfile().pubkeyHex
val channelIds = Collections.synchronizedSet(HashSet<String>())
// 1. Warm-auth then read the relay's kind-44100 member-added notifications for me.
account.client.fetchAllWithHooks(
filters =
mapOf(
relay to
listOf(
Filter(
kinds = listOf(MemberAddedNotificationEvent.KIND),
tags = mapOf("p" to listOf(myPubkey)),
),
),
),
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, event ->
(event as? MemberAddedNotificationEvent)?.channel()?.let { channelIds.add(it) }
false
}
// 2. Fetch each channel's NIP-29 metadata (39000-39003) so its name + Buzz `t` type load.
if (channelIds.isNotEmpty()) {
account.client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())))),
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
}
// 3. Keep only non-DM workspace channels; a channel whose metadata hasn't arrived
// (type unknown) is optimistically shown as a workspace channel.
_channels.value =
channelIds
.mapNotNull { id ->
val groupId = GroupId(id, relay)
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
if (channel.event?.isBuzzDm() == true) null else groupId
}.sortedBy { it.id }
_status.value = Status.Ready
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_status.value = Status.Error(e.message ?: "Could not load your channels")
}
}
}
/** Append [groupId] to the user's kind-10009 list (public group tag), so it shows in Messages. */
fun add(groupId: GroupId) {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
account.follow(channel)
_added.update { it + groupId.id }
}
}
fun addAll() {
_channels.value.forEach(::add)
}
}
@@ -35,6 +35,7 @@ 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
@@ -179,6 +180,20 @@ fun RelayGroupBrowseScreen(
Text(stringRes(R.string.relay_group_browse_go))
}
// Buzz workspaces expose no public group directory (membership is server-side), so the
// directory above comes back empty for them. This routes to the membership-scoped
// importer that lists the channels you already belong to on the relay you typed.
TextButton(
onClick = {
val normalized = RelayUrlNormalizer.normalizeOrNull(relayUrl.trim())
if (normalized == null) showError = true else nav.nav(Route.BuzzRelayImport(normalized.url))
},
enabled = relayUrl.isNotBlank(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Text(stringRes(R.string.relay_group_browse_buzz_import))
}
if (joined.isNotEmpty()) {
SectionHeader(stringRes(R.string.relay_group_browse_your_relays))
joined.sorted().forEach { server ->
+9
View File
@@ -2513,6 +2513,7 @@
<string name="relay_group_browse_description">Browse the groups hosted on a relay. Paste a relay address, or pick one below.</string>
<string name="relay_group_browse_relay_label">Relay address</string>
<string name="relay_group_browse_go">Browse</string>
<string name="relay_group_browse_buzz_import">Using a Buzz workspace? Import your channels</string>
<string name="relay_group_browse_your_relays">Relays you\'re on</string>
<string name="relay_group_browse_popular">Popular relays</string>
<string name="relay_group_no_messages_yet">No messages yet</string>
@@ -3346,6 +3347,14 @@
<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="buzz_import_title">Import workspace channels</string>
<string name="buzz_import_loading">Finding your channels…</string>
<string name="buzz_import_your_channels">Your channels on this workspace</string>
<string name="buzz_import_add">Add</string>
<string name="buzz_import_add_all">Add all</string>
<string name="buzz_import_added">Added</string>
<string name="buzz_import_empty_title">No channels found</string>
<string name="buzz_import_empty_body">You don\'t seem to be a member of any channels on this relay yet. Accept the workspace invite in the browser first, then try again.</string>
<string name="chat_delivery_details_title">Message Delivery</string>
<string name="close">Close</string>