diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 7b51c1623e..3f05c8122d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences +import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences @@ -275,6 +276,11 @@ class AppModules( // lazy) so it loads before the first Buzz-relay AUTH and mirrors later changes to disk. val buzzAttestationPrefs = BuzzAttestationPreferences(appContext, applicationIOScope) + // Restore + persist the joined Buzz workspace relays across restarts (device-global). Eager so + // the app knows which relays to sync as workspaces on cold start (Buzz membership is + // server-side; there is no join event to rebuild the set from). + val buzzWorkspacePrefs = BuzzWorkspacePreferences(appContext, applicationIOScope) + // Service that will run at all times to receive events from Pokey val pokeyReceiver = PokeyReceiver() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 55f9b14b24..5412cab03d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -213,6 +213,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -2929,9 +2930,17 @@ class Account( suspend fun openBuzzDm( relay: NormalizedRelayUrl, participants: List, - ) { - val template = DmOpenEvent.build(participants) - signAndSendPrivatelyOrBroadcast(template) { listOf(relay) } + ): String? { + val signed = signer.sign(DmOpenEvent.build(participants)) + // The relay confirms the DM synchronously in the OK as `response:{"channel_id":"…"}` — + // the authoritative, relay-assigned channel UUID (the deployed relay does not emit a + // queryable kind-41001). Read it straight from the ack so the caller can open the chat. + val results = client.publishAndCollectResults(signed, setOf(relay)) + val okMessage = results.values.firstOrNull { it.accepted }?.message ?: return null + return okMessage + .substringAfter("\"channel_id\":\"", "") + .substringBefore('"') + .takeIf { it.isNotBlank() } } /** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt new file mode 100644 index 0000000000..cdd9a7855d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt @@ -0,0 +1,88 @@ +/* + * 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.model.preferences + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Device-global persistence for the set of joined `block/buzz` workspaces ([BuzzWorkspaces]), + * so the app knows which relays to connect + NIP-42-authenticate + run member-channel discovery + * against on a cold start — Buzz membership is server-side (granted by the HTTP invite claim), + * with no NIP-51/kind-10009 join event to rebuild the set from. Uses the app-wide + * [sharedPreferencesDataStore] like [BuzzAttestationPreferences] (not per-account: a joined + * relay is workspace-wide, and restoring only marks relays to sync — the relay still gates every + * read/write by the authenticated key). + * + * On construction it loads the saved relay URLs into the singleton (re-normalizing each, dropping + * any that no longer parse), then mirrors every later change back to disk. Construct once, eagerly. + */ +@Stable +class BuzzWorkspacePreferences( + private val context: Context, + private val scope: CoroutineScope, +) { + init { + scope.launch { + restoreFromDisk() + // Persist on every change AFTER the initial restore (drop(1) skips the value present + // at collection start, which restoreFromDisk already wrote). + BuzzWorkspaces.flow.drop(1).collect { persist(it) } + } + } + + private suspend fun restoreFromDisk() { + try { + val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return + val relays = raw.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + if (relays.isNotEmpty()) BuzzWorkspaces.restore(relays) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("BuzzWorkspacePrefs") { "Error reading joined workspaces: ${e.message}" } + } + } + + private suspend fun persist(relays: Set) { + try { + context.sharedPreferencesDataStore.edit { prefs -> + prefs[KEY] = relays.map { it.url }.toSet() + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("BuzzWorkspacePrefs") { "Error writing joined workspaces: ${e.message}" } + } + } + + companion object { + private val KEY = stringSetPreferencesKey("buzz.joinedWorkspaces") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index 3b9e4c9779..6a23a972c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -25,18 +25,22 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.filter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS -import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent +import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event +import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants +import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -47,27 +51,34 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap /** * Backing ViewModel for [BuzzDmListScreen] — the user's Buzz direct-message inbox. * - * A Buzz DM is a relay-authoritative NIP-29 group whose `h`/id is a relay-generated UUID, - * so the message timeline reuses the whole relay-group chat stack; this ViewModel only - * owns *discovery* and the inbox projection. It: - * - fetches + live-subscribes the relay-signed [DmCreatedEvent] (`kind:41001`, `#p` = me) - * and per-viewer [DmVisibilityEvent] (`kind:30622`, `#p` = me) across the Buzz-dialect - * relays — [LocalCache] consumes them and feeds [BuzzDmRegistry]; - * - fetches each discovered DM's NIP-29 directory (39000-39003, `#d` = channel id) so the - * relay-signed roster is present, which is what the shared chat composer gates on - * (a DM isn't in the joined-group list, so nothing else would fetch it); - * - projects [BuzzDmRegistry] (minus the viewer's hidden set) into [rows], sorted by last - * message time and enriched with the other participants for name/avatar rendering. + * A Buzz DM is a relay-authoritative NIP-29 group whose `h`/id is a relay-generated UUID, so the + * message timeline reuses the whole relay-group chat stack; this ViewModel owns only *discovery* + * and the inbox projection. Discovery mirrors how the deployed relay actually models DMs (it does + * NOT emit a queryable kind-41001): the relay addresses each member a kind-44100 member-added + * notification (`#p` = me, `h` = channel), and marks a channel a DM via the `t` tag on its + * kind-39000 metadata (with the participants inlined as `p` tags). So it: + * - fetches + live-subscribes 44100 (`#p` = me) across the joined Buzz relays to learn the + * channels the user is in; + * - fetches each channel's directory (39000-39003) and keeps the ones whose metadata says + * `t` = `dm` — that same 39000 also carries the roster the shared chat composer's member gate + * needs, and the DM participants; + * - subscribes the per-viewer [DmVisibilityEvent] (`kind:30622`) so a hidden DM (tracked in + * [BuzzDmRegistry]) drops out; + * - projects the visible DMs into [rows], sorted by last message time. */ class BuzzDmListViewModel : ViewModel() { @Volatile private var account: Account? = null private val refreshMutex = Mutex() private var liveJob: Job? = null + /** channelId -> relay it was discovered on (from the 44100 provenance). */ + private val memberChannels = ConcurrentHashMap() + private val _rows = MutableStateFlow>(emptyList()) val rows: StateFlow> = _rows.asStateFlow() @@ -79,14 +90,16 @@ class BuzzDmListViewModel : ViewModel() { data class DmRow( val channelId: String, val relayUrl: NormalizedRelayUrl, - /** All participants (from the 41001), including me. */ + /** All participants (from the 39000 metadata `p` tags), including me. */ val allParticipants: List, /** Participants other than me — who the DM is "with". */ val others: List, - /** Newest message time (or the DM's created_at when it has no messages yet). */ + /** Newest message time (or 0 when the DM has no messages yet). */ val lastActivity: Long, ) + private fun relays(): Set = BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value + fun bindAccountIfMissing(account: Account) { if (this.account != null) return this.account = account @@ -100,8 +113,8 @@ class BuzzDmListViewModel : ViewModel() { refreshMutex.withLock { _isLoading.value = true try { - fetchDiscovery(account) - fetchRosters(account) + discoverMemberChannels(account) + fetchMetadata(account) rebuildRows(account) } finally { _isLoading.value = false @@ -110,108 +123,96 @@ class BuzzDmListViewModel : ViewModel() { } } - /** - * One-shot paged fetch of the DM confirmations + visibility snapshots addressed to me - * (`#p` = me) from every Buzz-dialect relay. Events land in [LocalCache] → [BuzzDmRegistry]. - */ - private suspend fun fetchDiscovery(account: Account) { + /** Fetch kind-44100 (`#p` = me) + the visibility snapshot (30622) across the joined relays. */ + private suspend fun discoverMemberChannels(account: Account) { val myPubkey = account.userProfile().pubkeyHex - val relays = BuzzRelayDialect.flow.value + val relays = relays() if (relays.isEmpty()) return val filters = listOf( - Filter(kinds = listOf(DmCreatedEvent.KIND), tags = mapOf("p" to listOf(myPubkey))), + Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(myPubkey))), Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(myPubkey))), ) - account.client.fetchAllPagesFromPool(relays.associateWith { filters }) { _, _ -> } + account.client.fetchAllPagesFromPool(relays.associateWith { filters }) { event, relay -> + (event as? MemberAddedNotificationEvent)?.channel()?.let { memberChannels[it] = relay } + } } - /** - * Second phase: for the DMs just discovered, fetch each channel's NIP-29 directory - * (39000-39003) from its own relay so the relay-signed roster populates. Without it the - * shared chat composer's member gate would hide the input field on a DM. - */ - private suspend fun fetchRosters(account: Account) { - val myPubkey = account.userProfile().pubkeyHex + /** Fetch the NIP-29 directory (39000-39003) of every discovered channel so its `t`/roster load. */ + private suspend fun fetchMetadata(account: Account) { val byRelay = - BuzzDmRegistry - .visibleFor(myPubkey) - .groupBy { it.relay } - .mapValues { (_, dms) -> - listOf( - Filter( - kinds = RELAY_GROUP_METADATA_KINDS, - tags = mapOf("d" to dms.map { it.channelId }), - ), - ) - } + memberChannels.entries + .groupBy({ it.value }, { it.key }) + .mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) } if (byRelay.isEmpty()) return account.client.fetchAllPagesFromPool(byRelay) { _, _ -> } } + /** Project the discovered DM channels (metadata `t` = `dm`), minus my hidden set, newest-first. */ private fun rebuildRows(account: Account) { val myPubkey = account.userProfile().pubkeyHex + val hidden = BuzzDmRegistry.hiddenFor(myPubkey) _rows.value = - BuzzDmRegistry - .visibleFor(myPubkey) - .map { dm -> + memberChannels.entries + .mapNotNull { (channelId, relay) -> + if (channelId in hidden) return@mapNotNull null + val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(channelId, relay)) + val metadata = channel.event ?: return@mapNotNull null + if (!metadata.isBuzzDm()) return@mapNotNull null + val participants = metadata.buzzParticipants() DmRow( - channelId = dm.channelId, - relayUrl = dm.relay, - allParticipants = dm.participants, - others = dm.participants.filter { it != myPubkey }, - lastActivity = lastActivityFor(dm.channelId, dm.createdAt), + channelId = channelId, + relayUrl = relay, + allParticipants = participants, + others = participants.filter { it != myPubkey }, + lastActivity = lastActivityFor(channelId), ) }.sortedByDescending { it.lastActivity } } - /** Newest message `created_at` for [channelId] from [LocalCache], or [fallback] when empty. */ - private fun lastActivityFor( - channelId: String, - fallback: Long, - ): Long = + /** Newest message `created_at` for [channelId] from [LocalCache], or 0 when the DM is empty. */ + private fun lastActivityFor(channelId: String): Long = LocalCache .filter( Filter( kinds = listOf(ChatEvent.KIND, StreamMessageV2Event.KIND), tags = mapOf("h" to listOf(channelId)), ), - ).maxOfOrNull { it.createdAt() ?: 0L } - ?.takeIf { it > 0L } - ?: fallback + ).maxOfOrNull { it.createdAt() ?: 0L } ?: 0L /** - * Keeps a live REQ open for new DM confirmations / visibility changes and re-projects - * the inbox whenever the registry moves. Idempotent; torn down with the ViewModel. + * Keeps a live 44100 + 30622 REQ open (so new DMs / hide changes arrive) and re-projects the + * inbox when the registry or dialect set moves. Idempotent; torn down with the ViewModel. */ private fun startLive() { val account = account ?: return if (liveJob != null) return val myPubkey = account.userProfile().pubkeyHex - val relays = BuzzRelayDialect.flow.value liveJob = viewModelScope.launch(Dispatchers.IO) { - // (a) Keep the discovery REQs open so LocalCache keeps feeding the registry. - relays.forEach { relay -> + relays().forEach { relay -> launch { - account.client - .subscribeAsFlow( - relay, - Filter(kinds = listOf(DmCreatedEvent.KIND), tags = mapOf("p" to listOf(myPubkey))), - ).collect { /* consumed globally by CacheClientConnector */ } + val filter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(myPubkey))) + account.client.subscribeAsFlow(relay, filter).collect { events -> + var changed = false + events.filterIsInstance().forEach { e -> + e.channel()?.let { if (memberChannels.put(it, relay) == null) changed = true } + } + if (changed) { + fetchMetadata(account) + rebuildRows(account) + } + } } launch { - account.client - .subscribeAsFlow( - relay, - Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(myPubkey))), - ).collect { } + val filter = Filter(kinds = listOf(DmVisibilityEvent.KIND), tags = mapOf("p" to listOf(myPubkey))) + account.client.subscribeAsFlow(relay, filter).collect { /* consumed → BuzzDmRegistry.hidden */ } } } - // (b) Re-project whenever the registry (conversations or my hidden set) changes. + // Re-project when my hidden set (30622) or the joined-relay set changes. launch { - combine(BuzzDmRegistry.conversations, BuzzDmRegistry.hidden) { _, _ -> } + combine(BuzzDmRegistry.hidden, BuzzWorkspaces.flow, BuzzRelayDialect.flow) { _, _, _ -> } .collect { rebuildRows(account) } } } 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 ad72073b9d..68d748717a 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 @@ -50,7 +50,7 @@ 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.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -149,10 +149,11 @@ fun BuzzInviteScreen( Button( onClick = { - // Recognize the workspace's relay as Buzz-dialect so its events are materialized - // as workspace 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 { BuzzRelayDialect.mark(it) } + // 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt index 9228d72c32..77c2a53b26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt @@ -22,20 +22,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isValid -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -45,9 +41,9 @@ import kotlinx.coroutines.withContext /** * Backing ViewModel for [BuzzNewDmScreen]. It gathers 1-8 other participants and a Buzz - * workspace relay, publishes a kind-41010 open command, then waits for the relay-signed - * kind-41001 confirmation to land in [BuzzDmRegistry] so it can hand the caller the - * relay-assigned [GroupId] (we never mint the DM's UUID ourselves). + * workspace relay, publishes a kind-41010 open command, and reads the relay's synchronous OK + * (`response:{"channel_id":"…"}`) for the assigned [GroupId] — we never mint the DM's UUID + * ourselves, and the deployed relay does not emit a queryable kind-41001 to poll for. */ class BuzzNewDmViewModel : ViewModel() { @Volatile private var account: Account? = null @@ -78,7 +74,7 @@ class BuzzNewDmViewModel : ViewModel() { if (this.account != null) return this.account = account val buzz = - BuzzRelayDialect.flow.value + (BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value) .toList() .sortedBy { it.url } _relays.value = buzz @@ -110,9 +106,9 @@ class BuzzNewDmViewModel : ViewModel() { } /** - * Publishes the 41010 and awaits the 41001 confirmation, then invokes [onOpened] with - * the relay-assigned [GroupId]. On timeout it still calls [onOpened] with null so the - * screen can fall back to the inbox (the DM will surface there once it confirms). + * Publishes the 41010 and reads the relay's synchronous OK confirmation for the assigned + * channel id, then invokes [onOpened] with the [GroupId]. On a null id (the relay didn't + * confirm in the ack) it calls [onOpened] with null so the screen falls back to the inbox. */ fun start(onOpened: (GroupId?) -> Unit) { val account = account ?: return @@ -126,49 +122,16 @@ class BuzzNewDmViewModel : ViewModel() { _status.value = Status.Error("Add at least one person") return } - val expected = (others + account.userProfile().pubkeyHex).toSet() _status.value = Status.Sending viewModelScope.launch(Dispatchers.IO) { try { - account.openBuzzDm(relay, others) - val groupId = awaitConfirmation(account, relay, expected) + val channelId = account.openBuzzDm(relay, others) + val groupId = channelId?.let { GroupId(it, relay) } withContext(Dispatchers.Main) { onOpened(groupId) } } catch (e: IllegalArgumentException) { _status.value = Status.Error(e.message ?: "Could not open the DM") } } } - - /** - * Polls for the relay-signed 41001 whose participant set matches [expected] on [relay], - * re-fetching `#p` = me between checks so a fresh confirmation is pulled in. Returns the - * matched [GroupId], or null after [CONFIRM_TIMEOUT_MS]. - */ - private suspend fun awaitConfirmation( - account: Account, - relay: NormalizedRelayUrl, - expected: Set, - ): GroupId? { - val myPubkey = account.userProfile().pubkeyHex - val filters = listOf(Filter(kinds = listOf(DmCreatedEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))) - val deadline = CONFIRM_TIMEOUT_MS - var waited = 0L - while (waited < deadline) { - account.client.fetchAllPagesFromPool(mapOf(relay to filters)) { _, _ -> } - val match = - BuzzDmRegistry.conversations.value.values.firstOrNull { - it.relay == relay && it.participants.toSet() == expected - } - if (match != null) return GroupId(match.channelId, relay) - delay(POLL_INTERVAL_MS) - waited += POLL_INTERVAL_MS - } - return null - } - - companion object { - private const val CONFIRM_TIMEOUT_MS = 6_000L - private const val POLL_INTERVAL_MS = 500L - } } 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 2250aef92c..3b5d7e7a0a 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 @@ -54,6 +54,7 @@ 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 @@ -94,17 +95,30 @@ fun BuzzWorkspacesScreen( .collectAsStateWithLifecycle() val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle() + // Buzz membership is server-side (redeeming an invite claims relay membership) — there is no + // NIP-29 join event — so the joined-group list alone misses workspace channels. Discover them + // from the relay's kind-44100 member-added notifications instead, and union the two sources. + val viewModel: BuzzWorkspacesViewModel = + viewModel(key = "BuzzWorkspaces-" + accountViewModel.account.userProfile().pubkeyHex) + viewModel.bindAccountIfMissing(accountViewModel.account) + val discovered by viewModel.channelsByRelay.collectAsStateWithLifecycle() + // A Buzz workspace IS a relay (a tenant, per buzz-core's `relay_url_authority`), and its - // channels are the NIP-29 groups on it — so group the joined Buzz-dialect groups by relay - // into workspace → channels, the Concord community→channels shape. + // channels are the NIP-29 groups on it — so group by relay into workspace → channels, the + // Concord community→channels shape. Sources: (1) joined NIP-29 groups on Buzz-dialect relays, + // (2) member channels discovered via kind-44100 (already non-DM). val workspaces = - remember(joined, buzzRelays) { - joined - .mapNotNull { tag -> + remember(joined, buzzRelays, discovered) { + val fromJoined = + joined.mapNotNull { tag -> val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@mapNotNull null if (relay !in buzzRelays) return@mapNotNull null GroupId(tag.groupId, relay) - }.groupBy { it.relayUrl } + } + val fromDiscovery = discovered.values.flatten() + (fromJoined + fromDiscovery) + .distinctBy { it.relayUrl.url + "/" + it.id } + .groupBy { it.relayUrl } .toList() .sortedBy { it.first.url } .map { (relay, channels) -> relay to channels.sortedBy { it.id } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesViewModel.kt new file mode 100644 index 0000000000..66364065e3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspacesViewModel.kt @@ -0,0 +1,163 @@ +/* + * 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.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces +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.fetchAllPagesFromPool +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap + +/** + * Discovers the workspace channels the user is a member of on each joined Buzz relay, so the + * Workspaces hub shows them even though Buzz membership is server-side (no NIP-29 join event). + * + * The relay addresses each member a kind-44100 member-added notification (`#p` = me, `h` = + * channel), so this fetches + live-subscribes 44100 across the joined relays ([BuzzWorkspaces]), + * then fetches each channel's kind-39000 metadata to read its Buzz `t` channel type. Channels + * whose type is **not** `dm` are workspace channels (DMs surface in the DM inbox instead). Rows + * are grouped by relay = workspace; the screen unions this with the NIP-29 joined-group list so + * both membership models render. + */ +class BuzzWorkspacesViewModel : ViewModel() { + @Volatile private var account: Account? = null + private val refreshMutex = Mutex() + private var liveJob: Job? = null + + /** channelId -> relay it was discovered on (from the 44100 provenance). */ + private val memberChannels = ConcurrentHashMap() + + private val _channelsByRelay = MutableStateFlow>>(emptyMap()) + + /** Non-DM member channels grouped by workspace relay; the hub collects this. */ + val channelsByRelay: StateFlow>> = _channelsByRelay.asStateFlow() + + private fun relays(): Set = BuzzWorkspaces.flow.value + BuzzRelayDialect.flow.value + + fun bindAccountIfMissing(account: Account) { + if (this.account != null) return + this.account = account + refresh() + startLive() + } + + fun refresh() { + val account = account ?: return + viewModelScope.launch(Dispatchers.IO) { + refreshMutex.withLock { + discoverMemberChannels(account) + fetchMetadata(account) + rebuild() + } + } + } + + /** Fetch kind-44100 (`#p` = me) across the joined relays, recording each channel's relay. */ + private suspend fun discoverMemberChannels(account: Account) { + val myPubkey = account.userProfile().pubkeyHex + val relays = relays() + if (relays.isEmpty()) return + val filter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(myPubkey))) + account.client.fetchAllPagesFromPool(relays.associateWith { listOf(filter) }) { event, relay -> + (event as? MemberAddedNotificationEvent)?.channel()?.let { memberChannels[it] = relay } + } + } + + /** Fetch the NIP-29 directory (39000-39003) of every discovered channel so its `t` type loads. */ + private suspend fun fetchMetadata(account: Account) { + val byRelay = + memberChannels.entries + .groupBy({ it.value }, { it.key }) + .mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) } + if (byRelay.isEmpty()) return + account.client.fetchAllPagesFromPool(byRelay) { _, _ -> } + } + + /** Project the discovered non-DM channels, grouped by relay. */ + private fun rebuild() { + _channelsByRelay.value = + memberChannels.entries + .mapNotNull { (channelId, relay) -> + val groupId = GroupId(channelId, relay) + val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) + // Keep only non-DM channels here; DMs render in the DM inbox. A channel whose + // metadata hasn't arrived yet (type unknown) is optimistically shown as a + // workspace channel — a later refresh moves it out once a `t:dm` is seen. + if (channel.event?.isBuzzDm() == true) null else relay to groupId + }.groupBy({ it.first }, { it.second }) + .mapValues { (_, ids) -> ids.sortedBy { it.id } } + } + + /** Keep a live 44100 subscription open (so new channels appear) and re-project on registry moves. */ + private fun startLive() { + val account = account ?: return + if (liveJob != null) return + val myPubkey = account.userProfile().pubkeyHex + + liveJob = + viewModelScope.launch(Dispatchers.IO) { + relays().forEach { relay -> + launch { + val filter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(myPubkey))) + account.client.subscribeAsFlow(relay, filter).collect { events -> + var changed = false + events.filterIsInstance().forEach { e -> + e.channel()?.let { if (memberChannels.put(it, relay) == null) changed = true } + } + if (changed) { + fetchMetadata(account) + rebuild() + } + } + } + } + // Re-project when the joined-workspace set or dialect marks change. + launch { + combine(BuzzWorkspaces.flow, BuzzRelayDialect.flow) { _, _ -> }.collect { refresh() } + } + } + } + + override fun onCleared() { + liveJob?.cancel() + liveJob = null + super.onCleared() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaces.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaces.kt new file mode 100644 index 0000000000..f5a6e42574 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaces.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.buzz + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * The set of `block/buzz` workspaces the user has **joined** — one relay (tenant) each. + * + * Distinct from [BuzzRelayDialect], which merely marks relays *observed* to speak the Buzz + * dialect: a workspace here is one the user actively joined (redeemed an invite for), so the + * app must connect + NIP-42-authenticate + run the member-channel discovery against it, even + * on a cold start before any Buzz event has arrived to trigger dialect detection. Buzz + * membership is granted server-side by the HTTP invite claim — there is no NIP-51/kind-10009 + * join event to key off — so this joined set is the client's own bookkeeping of which relays + * to sync as workspaces. + * + * Persisted across launches by the platform (`BuzzWorkspacePreferences` on Android mirrors it + * to a device-global store and restores it at startup). Like [BuzzRelayDialect] it is a + * process-wide singleton; joining also marks the relay as a Buzz dialect. + */ +object BuzzWorkspaces { + private val joined = MutableStateFlow>(emptySet()) + + /** The joined workspace relays; discovery subscriptions and the workspaces hub collect this. */ + val flow: StateFlow> = joined + + fun isJoined(relay: NormalizedRelayUrl): Boolean = relay in joined.value + + /** Records [relay] as a joined workspace (and a Buzz dialect). Returns true when it was new. */ + fun join(relay: NormalizedRelayUrl): Boolean { + BuzzRelayDialect.mark(relay) + while (true) { + val current = joined.value + if (relay in current) return false + if (joined.compareAndSet(current, current + relay)) return true + } + } + + /** Removes [relay] from the joined set (leaving a workspace). */ + fun leave(relay: NormalizedRelayUrl) { + while (true) { + val current = joined.value + if (relay !in current) return + if (joined.compareAndSet(current, current - relay)) return + } + } + + /** + * Replaces the whole joined set with [relays] — used to restore from disk at startup. Each is + * also marked a Buzz dialect so its events materialize as workspace channels immediately. + */ + fun restore(relays: Set) { + relays.forEach { BuzzRelayDialect.mark(it) } + joined.value = relays + } + + /** Test-only: clears the joined set so unit tests don't leak state into each other. */ + fun clearForTesting() { + joined.value = emptySet() + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspacesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspacesTest.kt new file mode 100644 index 0000000000..9e72bbcd3b --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspacesTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.buzz + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BuzzWorkspacesTest { + private val a = RelayUrlNormalizer.normalize("wss://a.buzz.example") + private val b = RelayUrlNormalizer.normalize("wss://b.buzz.example") + + @BeforeTest fun setup() { + BuzzWorkspaces.clearForTesting() + BuzzRelayDialect.clearForTesting() + } + + @AfterTest fun teardown() { + BuzzWorkspaces.clearForTesting() + BuzzRelayDialect.clearForTesting() + } + + @Test + fun joiningRecordsAndMarksDialect() { + assertTrue(BuzzWorkspaces.join(a)) + assertTrue(BuzzWorkspaces.isJoined(a)) + assertEquals(setOf(a), BuzzWorkspaces.flow.value) + // Joining also marks the relay a Buzz dialect so its events render as workspace channels. + assertTrue(BuzzRelayDialect.isBuzz(a)) + // Re-joining is a no-op (returns false). + assertFalse(BuzzWorkspaces.join(a)) + } + + @Test + fun leaveRemoves() { + BuzzWorkspaces.join(a) + BuzzWorkspaces.join(b) + BuzzWorkspaces.leave(a) + assertEquals(setOf(b), BuzzWorkspaces.flow.value) + assertFalse(BuzzWorkspaces.isJoined(a)) + } + + @Test + fun restoreReplacesAndMarksAll() { + BuzzWorkspaces.join(a) + BuzzWorkspaces.restore(setOf(b)) + assertEquals(setOf(b), BuzzWorkspaces.flow.value) + assertTrue(BuzzRelayDialect.isBuzz(b)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt new file mode 100644 index 0000000000..9659635914 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt @@ -0,0 +1,48 @@ +/* + * 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.quartz.buzz.workspace + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent + +/* + * Buzz-specific readers over a NIP-29 group's relay-signed metadata (kind:39000). + * + * Buzz relays enrich the standard metadata so clients can classify a channel without a + * separate fetch: a single `t` tag carries the channel type ("stream" / "forum" / "dm" — note + * this collides with NIP-29's use of `t` for topic hashtags, so on a Buzz relay + * GroupMetadataEvent.hashtags is really the channel type), and a DM's participants are inlined + * as `p` tags on the 39000 itself. Ground truth: buzz-relay/src/handlers/side_effects.rs + * (emit_group_discovery_events). + */ + +/** The Buzz channel type from the relay's `t` tag ("stream" / "forum" / "dm"), or null. */ +fun GroupMetadataEvent.buzzChannelType(): String? = tags.firstTagValue("t") + +/** True when the relay marks this channel a DM (`t` = "dm"). */ +fun GroupMetadataEvent.isBuzzDm(): Boolean = buzzChannelType() == BUZZ_CHANNEL_TYPE_DM + +/** The DM participant pubkeys inlined as `p` tags on a Buzz DM's 39000 (empty for non-DMs). */ +fun GroupMetadataEvent.buzzParticipants(): List = tags.mapNotNull(PTag::parseKey) + +const val BUZZ_CHANNEL_TYPE_DM = "dm"