mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(buzz): workspace + DM discovery via 44100/39000, matching the live relay
The earlier discovery layer read the NIP-29 joined list (kind-10009) and
kind-41001 — neither of which the deployed relay uses, so a joined workspace
rendered nothing. Rework it to the model live testing confirmed.
Enabling layer — persist joined workspaces:
- commons BuzzWorkspaces: process-wide set of joined workspace relays (Buzz
membership is server-side, so there's no join event to rebuild from). Joining
also marks the relay a Buzz dialect. Unit-tested.
- BuzzWorkspacePreferences: device-global DataStore that restores the set at
startup (so the app connects + authenticates + discovers on cold start) and
mirrors changes. Eager init in AppModules. BuzzInviteScreen now `join`s.
Quartz:
- BuzzChannelMetadata: read the relay's `t` channel-type tag ("stream"/"forum"/
"dm") and a DM's inlined `p` participants off kind-39000.
Discovery (both hubs now source from the relay's real signals):
- BuzzWorkspacesViewModel: fetch + live-subscribe kind-44100 member-added
notifications (#p=me) across joined relays → my channels; fetch each channel's
39000 metadata; keep the non-DM ones. BuzzWorkspacesScreen unions this with the
NIP-29 joined list.
- BuzzDmListViewModel: same 44100 discovery, kept where 39000 `t`=dm (participants
from the metadata `p` tags), minus the 30622 hidden set — replaces the dead
kind-41001 path.
- Account.openBuzzDm returns the relay-assigned channel id from the OK response
(`response:{channel_id}`); BuzzNewDmViewModel opens the chat from it instead of
polling 41001.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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<HexKey>,
|
||||
) {
|
||||
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). */
|
||||
|
||||
+88
@@ -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<NormalizedRelayUrl>) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
+76
-75
@@ -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<String, NormalizedRelayUrl>()
|
||||
|
||||
private val _rows = MutableStateFlow<List<DmRow>>(emptyList())
|
||||
val rows: StateFlow<List<DmRow>> = _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<HexKey>,
|
||||
/** Participants other than me — who the DM is "with". */
|
||||
val others: List<HexKey>,
|
||||
/** 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<NormalizedRelayUrl> = 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<MemberAddedNotificationEvent>().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) }
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -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,
|
||||
|
||||
+10
-47
@@ -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<HexKey>,
|
||||
): 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
|
||||
}
|
||||
}
|
||||
|
||||
+20
-6
@@ -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 } }
|
||||
|
||||
+163
@@ -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<String, NormalizedRelayUrl>()
|
||||
|
||||
private val _channelsByRelay = MutableStateFlow<Map<NormalizedRelayUrl, List<GroupId>>>(emptyMap())
|
||||
|
||||
/** Non-DM member channels grouped by workspace relay; the hub collects this. */
|
||||
val channelsByRelay: StateFlow<Map<NormalizedRelayUrl, List<GroupId>>> = _channelsByRelay.asStateFlow()
|
||||
|
||||
private fun relays(): Set<NormalizedRelayUrl> = 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<MemberAddedNotificationEvent>().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()
|
||||
}
|
||||
}
|
||||
+82
@@ -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<Set<NormalizedRelayUrl>>(emptySet())
|
||||
|
||||
/** The joined workspace relays; discovery subscriptions and the workspaces hub collect this. */
|
||||
val flow: StateFlow<Set<NormalizedRelayUrl>> = 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<NormalizedRelayUrl>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
+72
@@ -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))
|
||||
}
|
||||
}
|
||||
+48
@@ -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<HexKey> = tags.mapNotNull(PTag::parseKey)
|
||||
|
||||
const val BUZZ_CHANNEL_TYPE_DM = "dm"
|
||||
Reference in New Issue
Block a user