mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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
|
||||
|
||||
+283
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+183
@@ -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)
|
||||
}
|
||||
}
|
||||
+15
@@ -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 ->
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user