mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
feat(commons): Buzz workspaces as NIP-29 groups in the Buzz dialect
Surfaces block/buzz workspace channels inside the existing NIP-29 relay-group experience — one group model, dialect-aware rendering, zero impact on vanilla groups (kind-filtered feeds can never receive Buzz kinds by accident). - BuzzRelayDialect (commons): per-relay capability registry. Event-shape detection — the first Buzz-only kind consumed from a relay marks it; vanilla relays never serve those kinds, so no false positives. NIP-11 marking can be layered on later. - BuzzWorkspaceChannel (commons): sibling of RelayGroupChannel (now `open`) holding Buzz-only channel state: the kind-40003 edit overlay (never render superseded text as current) and the newest kind-40100 canvas. Kind-9 chat and kind-40002 stream messages share ONE timeline per group so mixed-dialect conversations stay whole. - LocalCache: consumes every registered Buzz kind (previously all fell into the "Event Not Supported" branch). Timeline kinds attach to the group's channel, materialized dialect-aware with an in-place upgrade (note migration) when the dialect is discovered after the channel was first created as plain NIP-29. Addressables store replaceably; the rest store as queryable regular events; ephemeral signals (typing 20002, observer 24200, huddle reaction 24810, pairing 24134) mark the dialect but are deliberately not persisted. - RelayGroupFilterBuilders: group-chat REQs widen their timeline kind set with 40002/40003/40008/40099 only for marked relays; vanilla NIP-29 REQs unchanged. Tests cover dialect detection + materialization, the plain-channel upgrade with timeline migration, newest-edit-wins overlay ordering, and that filter builders extend kinds only on marked relays. Full amethyst unit suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
@@ -50,6 +52,77 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
|
||||
import com.vitorpamplona.amethyst.service.BundledInsert
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.note.dateFormatter
|
||||
import com.vitorpamplona.quartz.buzz.aeEngrams.EngramEvent
|
||||
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
|
||||
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
|
||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
||||
import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
||||
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
|
||||
import com.vitorpamplona.quartz.buzz.erReminders.EventReminderEvent
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
|
||||
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchiveRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentitiesListEvent
|
||||
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentityEvent
|
||||
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchiveRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchivedIdentityEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
|
||||
import com.vitorpamplona.quartz.buzz.managedAgents.ManagedAgentEvent
|
||||
import com.vitorpamplona.quartz.buzz.moderation.ModerationBanEvent
|
||||
import com.vitorpamplona.quartz.buzz.moderation.ModerationResolveReportEvent
|
||||
import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent
|
||||
import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent
|
||||
import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
|
||||
import com.vitorpamplona.quartz.buzz.pairing.PairingEvent
|
||||
import com.vitorpamplona.quartz.buzz.plPushLease.PushLeaseEvent
|
||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminChangeRoleEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageBookmarkedEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessagePinnedEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalDeniedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalGrantedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalRequestedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCancelledEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCompletedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowFailedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepCompletedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepFailedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepStartedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent
|
||||
import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
|
||||
@@ -734,7 +807,31 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
fun getOrCreateGeohashChannel(geohash: String): GeohashChatChannel = geohashChannels.getOrCreate(geohash) { GeohashChatChannel(geohash) }
|
||||
|
||||
fun getOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel = relayGroupChannels.getOrCreate(key) { RelayGroupChannel(key) }
|
||||
fun getOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel =
|
||||
relayGroupChannels.getOrCreate(key) {
|
||||
// A relay marked as Buzz materializes its groups as workspace channels so
|
||||
// Buzz-only state (edit overlays, canvas) has a home; everything else stays
|
||||
// a plain NIP-29 group. See BuzzRelayDialect.
|
||||
if (BuzzRelayDialect.isBuzz(key.relayUrl)) BuzzWorkspaceChannel(key) else RelayGroupChannel(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the group's channel as a [BuzzWorkspaceChannel], upgrading a plain
|
||||
* [RelayGroupChannel] in place when the group's relay turns out to speak the Buzz
|
||||
* dialect after the channel was first materialized (e.g. its 39000 metadata arrived
|
||||
* before the first Buzz-kind event). The upgrade migrates the attached timeline
|
||||
* notes; per-relay pagination cursors reset, which only costs a re-page.
|
||||
*/
|
||||
private fun getOrUpgradeBuzzChannel(key: GroupId): BuzzWorkspaceChannel {
|
||||
val existing = getOrCreateRelayGroupChannel(key)
|
||||
if (existing is BuzzWorkspaceChannel) return existing
|
||||
|
||||
val upgraded = BuzzWorkspaceChannel(key)
|
||||
existing.notes.forEach { _, note -> upgraded.addNote(note, null) }
|
||||
existing.relays().forEach { upgraded.addRelay(it) }
|
||||
relayGroupChannels.put(key, upgraded)
|
||||
return upgraded
|
||||
}
|
||||
|
||||
fun getConcordChannelIfExists(key: ConcordChannelId): ConcordChannel? = concordChannels.get(key)
|
||||
|
||||
@@ -1986,6 +2083,75 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return new
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume + channel-attach for Buzz workspace timeline kinds (stream messages,
|
||||
* diffs, system rows, forum posts, job cards, huddle lifecycle). These kinds only
|
||||
* exist on `block/buzz` relays, so their arrival IS the dialect detection: the
|
||||
* serving relay is marked in [BuzzRelayDialect] and the group's channel is
|
||||
* materialized (or upgraded) as a [BuzzWorkspaceChannel] before the shared NIP-29
|
||||
* attach routes the note into the same one-timeline-per-group channel kind-9 chat
|
||||
* uses. Mixed vanilla/Buzz conversations therefore stay whole.
|
||||
*/
|
||||
private fun consumeBuzzTimelineEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
if (relay != null) {
|
||||
BuzzRelayDialect.mark(relay)
|
||||
event.groupId()?.let { getOrUpgradeBuzzChannel(GroupId(it, relay)) }
|
||||
}
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
}
|
||||
|
||||
/** Store-only consume for Buzz kinds that carry no channel timeline row; still marks the dialect. */
|
||||
private fun consumeBuzzRegularEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
if (relay != null) BuzzRelayDialect.mark(relay)
|
||||
}
|
||||
|
||||
/** The group's [BuzzWorkspaceChannel] for a consumed Buzz event, when resolvable. */
|
||||
private fun buzzChannelFor(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
): BuzzWorkspaceChannel? {
|
||||
val groupId = event.groupId() ?: return null
|
||||
if (relay == null) return null
|
||||
return getOrUpgradeBuzzChannel(GroupId(groupId, relay))
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
event: StreamMessageEditEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
consumeBuzzTimelineEvent(event, relay, wasVerified).also {
|
||||
// A 40003 replaces an earlier message's content: track the overlay so the
|
||||
// UI never renders superseded text as current.
|
||||
val target = event.editedMessage() ?: return@also
|
||||
val editNote = getOrCreateNote(event.id)
|
||||
if (editNote.event != null) {
|
||||
buzzChannelFor(event, relay)?.addEdit(target, editNote)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
event: CanvasEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
consumeBuzzTimelineEvent(event, relay, wasVerified).also {
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event != null) {
|
||||
buzzChannelFor(event, relay)?.updateCanvas(note)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
|
||||
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
|
||||
@@ -4407,6 +4573,102 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Buzz workspace kinds (block/buzz — the Buzz dialect of NIP-29).
|
||||
// Timeline kinds attach into the group's BuzzWorkspaceChannel; the
|
||||
// rest are stored for query/state. Kinds 9041/20001/39005/49001 are
|
||||
// absent on purpose: their numbers belong to GoalEvent,
|
||||
// GeohashPresenceEvent, GroupPinnedEvent and a non-wire audit kind.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is StreamMessageEditEvent -> consume(event, relay, wasVerified)
|
||||
is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is CanvasEvent -> consume(event, relay, wasVerified)
|
||||
is ForumPostEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is ForumCommentEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is ForumVoteEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is JobRequestEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is JobAcceptedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is JobProgressEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is JobResultEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is JobCancelEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is JobErrorEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is HuddleStartedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is HuddleParticipantJoinedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is HuddleParticipantLeftEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
is HuddleEndedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
|
||||
|
||||
// Buzz addressable/replaceable state.
|
||||
is PersonaEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is TeamEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ManagedAgentEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is EngramEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is WorkflowDefEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is EventReminderEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is PushLeaseEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is DmVisibilityEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is WindowBoundsEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ArchivedIdentitiesListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
// Buzz store-only regular kinds (queryable state; no timeline row yet).
|
||||
is StreamMessagePinnedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is StreamMessageBookmarkedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is StreamMessageScheduledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is StreamReminderEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is DmCreatedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is DmOpenEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is MemberAddedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is MemberRemovedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is AgentTurnMetricEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ModerationBanEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ModerationTimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ModerationUntimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ModerationResolveReportEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ProductFeedbackEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is RelayAdminAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is RelayAdminRemoveMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is RelayAdminChangeRoleEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is SetWorkspaceProfileEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ArchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is UnarchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ArchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is UnarchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is HuddleGuidelinesEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowTriggeredEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowStepStartedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowStepCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowStepFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowCancelledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowApprovalRequestedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowApprovalGrantedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowApprovalDeniedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is WorkflowTriggerEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ApprovalGrantEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ApprovalDenyEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
|
||||
// Buzz ephemeral signals: transient by definition (20000-29999); mark
|
||||
// the dialect but do not pollute the note store with typing/telemetry.
|
||||
is TypingIndicatorEvent -> {
|
||||
relay?.let { BuzzRelayDialect.mark(it) }
|
||||
false
|
||||
}
|
||||
is ObserverFrameEvent -> {
|
||||
relay?.let { BuzzRelayDialect.mark(it) }
|
||||
false
|
||||
}
|
||||
is HuddleReactionEvent -> {
|
||||
relay?.let { BuzzRelayDialect.mark(it) }
|
||||
false
|
||||
}
|
||||
is PairingEvent -> false
|
||||
|
||||
is PollEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
|
||||
+30
-3
@@ -20,6 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -85,6 +90,28 @@ val RELAY_GROUP_STATE_KINDS = RELAY_GROUP_METADATA_KINDS + RELAY_GROUP_PIN_KINDS
|
||||
/** Timeline kinds shown in a group's chat — chat messages and polls. */
|
||||
val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
|
||||
|
||||
/**
|
||||
* Extra timeline kinds a `block/buzz` workspace relay serves in the same `h`-scoped
|
||||
* channels: stream messages v2 (40002), edits (40003), diffs (40008) and system rows
|
||||
* (40099). Only requested from relays [BuzzRelayDialect] has marked — harmless
|
||||
* elsewhere, but there is no reason to widen every vanilla NIP-29 REQ.
|
||||
*/
|
||||
val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
|
||||
listOf(
|
||||
StreamMessageV2Event.KIND,
|
||||
StreamMessageEditEvent.KIND,
|
||||
StreamMessageDiffEvent.KIND,
|
||||
SystemMessageEvent.KIND,
|
||||
)
|
||||
|
||||
/** The timeline kinds to request from [relay]: the NIP-29 set, plus Buzz kinds on Buzz relays. */
|
||||
fun relayGroupTimelineKinds(relay: NormalizedRelayUrl): List<Int> =
|
||||
if (BuzzRelayDialect.isBuzz(relay)) {
|
||||
RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS
|
||||
} else {
|
||||
RELAY_GROUP_TIMELINE_KINDS
|
||||
}
|
||||
|
||||
/** Forum-thread kinds shown in a group's Threads tab. */
|
||||
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND)
|
||||
|
||||
@@ -146,7 +173,7 @@ fun buildRelayGroupJoinedChatTailFilters(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_TIMELINE_KINDS,
|
||||
kinds = relayGroupTimelineKinds(relay),
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to ids.distinct()),
|
||||
since = sinceEpoch,
|
||||
),
|
||||
@@ -162,7 +189,7 @@ fun buildRelayGroupOpenChatTailFilter(
|
||||
relay = groupId.relayUrl,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_TIMELINE_KINDS,
|
||||
kinds = relayGroupTimelineKinds(groupId.relayUrl),
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
|
||||
since = sinceEpoch,
|
||||
),
|
||||
@@ -185,7 +212,7 @@ fun buildRelayGroupHistoryFilters(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_TIMELINE_KINDS,
|
||||
kinds = relayGroupTimelineKinds(relay),
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
|
||||
until = until,
|
||||
limit = limit,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.os.Looper
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceChannel
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The Buzz dialect of NIP-29 in `LocalCache`: dialect detection from event shape,
|
||||
* `BuzzWorkspaceChannel` materialization (including the upgrade of a channel that was
|
||||
* first created as a plain [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel]),
|
||||
* timeline attachment, and the 40003 edit overlay.
|
||||
*/
|
||||
class BuzzWorkspaceChannelTest {
|
||||
private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
|
||||
private val vanillaRelay = RelayUrlNormalizer.normalizeOrNull("wss://groups.example.com/")!!
|
||||
private val signer = NostrSignerInternal(KeyPair())
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// LocalCache.consume refuses the main thread; plain JVM tests have no Looper,
|
||||
// where null == null reads as "main". Distinct mocks make it a worker thread.
|
||||
mockkStatic(Looper::class)
|
||||
every { Looper.myLooper() } returns mockk<Looper>()
|
||||
every { Looper.getMainLooper() } returns mockk<Looper>()
|
||||
BuzzRelayDialect.clearForTesting()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(Looper::class)
|
||||
BuzzRelayDialect.clearForTesting()
|
||||
}
|
||||
|
||||
private fun newChannelId() = UUID.randomUUID().toString()
|
||||
|
||||
private suspend fun streamMessage(
|
||||
channelId: String,
|
||||
text: String,
|
||||
) = signer.sign(StreamMessageV2Event.build(channelId, text))
|
||||
|
||||
@Test
|
||||
fun buzzKindMarksDialectAndMaterializesWorkspaceChannel() =
|
||||
runBlocking {
|
||||
val channelId = newChannelId()
|
||||
val msg = streamMessage(channelId, "hello workspace")
|
||||
|
||||
assertFalse(BuzzRelayDialect.isBuzz(buzzRelay))
|
||||
|
||||
LocalCache.checkDeletionAndConsume(msg, buzzRelay, false)
|
||||
|
||||
assertTrue("consuming a 40002 must mark the serving relay as Buzz", BuzzRelayDialect.isBuzz(buzzRelay))
|
||||
|
||||
val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay))
|
||||
assertNotNull("the group channel must exist after a 40002 lands", channel)
|
||||
assertTrue("a Buzz relay's group must be a BuzzWorkspaceChannel", channel is BuzzWorkspaceChannel)
|
||||
assertTrue("the message must be attached to the channel timeline", channel!!.notes.containsKey(msg.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vanillaRelayStaysPlainRelayGroup() {
|
||||
val key = GroupId("plain-group", vanillaRelay)
|
||||
val channel = LocalCache.getOrCreateRelayGroupChannel(key)
|
||||
assertFalse(
|
||||
"a group on an unmarked relay must NOT materialize as a Buzz channel",
|
||||
channel is BuzzWorkspaceChannel,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainChannelUpgradesWhenDialectIsDiscoveredLater() =
|
||||
runBlocking {
|
||||
val channelId = newChannelId()
|
||||
val key = GroupId(channelId, buzzRelay)
|
||||
|
||||
// Arrival order: the group materializes plain (e.g. via its 39000 metadata)
|
||||
// BEFORE any Buzz kind reveals the dialect...
|
||||
val plain = LocalCache.getOrCreateRelayGroupChannel(key)
|
||||
assertFalse(plain is BuzzWorkspaceChannel)
|
||||
|
||||
// A kind-9 note is already attached to the plain channel.
|
||||
val vanillaMsg =
|
||||
signer.sign(
|
||||
ChatEvent.build("kind 9 from a vanilla client") {
|
||||
add(arrayOf("h", channelId))
|
||||
},
|
||||
)
|
||||
LocalCache.checkDeletionAndConsume(vanillaMsg, buzzRelay, false)
|
||||
|
||||
// ...then the first 40002 arrives and upgrades it, migrating the timeline.
|
||||
val buzzMsg = streamMessage(channelId, "kind 40002 from a Buzz client")
|
||||
LocalCache.checkDeletionAndConsume(buzzMsg, buzzRelay, false)
|
||||
|
||||
val upgraded = LocalCache.getRelayGroupChannelIfExists(key)
|
||||
assertTrue("channel must be upgraded in place", upgraded is BuzzWorkspaceChannel)
|
||||
assertTrue("pre-upgrade kind-9 note must be migrated", upgraded!!.notes.containsKey(vanillaMsg.id))
|
||||
assertTrue("the Buzz message shares the same timeline", upgraded.notes.containsKey(buzzMsg.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editOverlayTracksNewestEdit() =
|
||||
runBlocking {
|
||||
val channelId = newChannelId()
|
||||
val original = streamMessage(channelId, "teh typo")
|
||||
LocalCache.checkDeletionAndConsume(original, buzzRelay, false)
|
||||
|
||||
val edit1 =
|
||||
signer.sign(
|
||||
StreamMessageEditEvent.build(channelId, original.id, "the typo", createdAt = original.createdAt + 10),
|
||||
)
|
||||
val edit2 =
|
||||
signer.sign(
|
||||
StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 20),
|
||||
)
|
||||
|
||||
LocalCache.checkDeletionAndConsume(edit2, buzzRelay, false)
|
||||
LocalCache.checkDeletionAndConsume(edit1, buzzRelay, false)
|
||||
|
||||
val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay)) as BuzzWorkspaceChannel
|
||||
|
||||
assertEquals(
|
||||
"the newest edit wins regardless of arrival order",
|
||||
"the fix",
|
||||
channel.effectiveContentFor(original.id),
|
||||
)
|
||||
assertEquals(edit2.id, channel.editFor(original.id)?.idHex)
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The group-chat REQ builders widen their timeline kind set with the Buzz stream kinds
|
||||
* only for relays [BuzzRelayDialect] has marked — vanilla NIP-29 REQs stay untouched.
|
||||
*/
|
||||
class BuzzTimelineKindsTest {
|
||||
private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
|
||||
private val vanillaRelay = RelayUrlNormalizer.normalizeOrNull("wss://groups.example.com/")!!
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
BuzzRelayDialect.clearForTesting()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
BuzzRelayDialect.clearForTesting()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vanillaRelayKeepsThePlainNip29KindSet() {
|
||||
assertEquals(RELAY_GROUP_TIMELINE_KINDS, relayGroupTimelineKinds(vanillaRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buzzRelayGetsTheExtendedKindSet() {
|
||||
BuzzRelayDialect.mark(buzzRelay)
|
||||
|
||||
val kinds = relayGroupTimelineKinds(buzzRelay)
|
||||
assertTrue(kinds.containsAll(RELAY_GROUP_TIMELINE_KINDS))
|
||||
assertTrue(kinds.containsAll(BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS))
|
||||
|
||||
// The vanilla relay is unaffected by the buzz mark on another relay.
|
||||
assertEquals(RELAY_GROUP_TIMELINE_KINDS, relayGroupTimelineKinds(vanillaRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openChatTailFilterCarriesBuzzKindsOnlyOnBuzzRelays() {
|
||||
BuzzRelayDialect.mark(buzzRelay)
|
||||
|
||||
val buzzFilter = buildRelayGroupOpenChatTailFilter(GroupId("g1", buzzRelay), sinceEpoch = 0L)
|
||||
val vanillaFilter = buildRelayGroupOpenChatTailFilter(GroupId("g1", vanillaRelay), sinceEpoch = 0L)
|
||||
|
||||
assertTrue(buzzFilter.filter.kinds!!.contains(StreamMessageV2Event.KIND))
|
||||
assertFalse(vanillaFilter.filter.kinds!!.contains(StreamMessageV2Event.KIND))
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Tracks which relays speak the **Buzz dialect** of NIP-29 — `block/buzz` workspace
|
||||
* relays whose channel timelines carry Buzz-custom kinds (stream messages 40002,
|
||||
* edits 40003, diffs 40008, system rows 40099, canvas 40100, forum 45001/45003,
|
||||
* agent jobs 43001-43006, huddle cards 48100-48103) alongside plain kind-9 chat.
|
||||
*
|
||||
* Detection sources, in order of arrival:
|
||||
* 1. **Event shape** (implemented): the first Buzz-only channel kind consumed from a
|
||||
* relay marks it (`LocalCache` calls [mark] before materializing the channel). A
|
||||
* vanilla NIP-29 relay never serves those kinds, so there are no false positives.
|
||||
* 2. **NIP-11** (future): a relay announcing Buzz in its info document can be marked
|
||||
* at connect time, before any event arrives.
|
||||
*
|
||||
* The registry decides which channel type `LocalCache` materializes for a group
|
||||
* (`BuzzWorkspaceChannel` vs plain `RelayGroupChannel`) and which timeline kinds the
|
||||
* group-chat REQ builders put on the wire for that relay. Marks only ever ADD
|
||||
* capability: un-marking is never needed because a marked relay provably served a
|
||||
* Buzz kind, and extra requested kinds are harmless on any relay.
|
||||
*
|
||||
* Like `LocalCache`, this is a process-wide singleton (one copy per Android process).
|
||||
*/
|
||||
object BuzzRelayDialect {
|
||||
private val buzzRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
|
||||
/** Relays known to speak the Buzz dialect; UI can collect this for affordances. */
|
||||
val flow: StateFlow<Set<NormalizedRelayUrl>> = buzzRelays
|
||||
|
||||
fun isBuzz(relay: NormalizedRelayUrl): Boolean = relay in buzzRelays.value
|
||||
|
||||
/** Marks [relay] as Buzz. Returns true when this call changed the state. */
|
||||
fun mark(relay: NormalizedRelayUrl): Boolean {
|
||||
while (true) {
|
||||
val current = buzzRelays.value
|
||||
if (relay in current) return false
|
||||
if (buzzRelays.compareAndSet(current, current + relay)) return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clears all marks so unit tests don't leak state into each other. */
|
||||
fun clearForTesting() {
|
||||
buzzRelays.value = emptySet()
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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 androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* A `block/buzz` workspace channel: a [RelayGroupChannel] on a relay that speaks the
|
||||
* Buzz dialect of NIP-29 (see [BuzzRelayDialect]).
|
||||
*
|
||||
* Buzz channels keep the whole NIP-29 machinery — the `h`-scoped [groupId], relay-signed
|
||||
* 39000-39003 state, membership — and add channel content the vanilla renderer must not
|
||||
* ignore, because skipping it misrepresents the conversation:
|
||||
*
|
||||
* - **kind 40003 message edits**: an overlay replacing an earlier message's content.
|
||||
* Rendering the original without the edit shows *stale text as current*, so the
|
||||
* overlay is tracked here ([editFor]) rather than left as a loose note.
|
||||
* - **kind 40100 canvas**: the channel's collaborative document; only the newest
|
||||
* matters ([canvasNote]).
|
||||
* - Stream messages (40002), diffs (40008), system rows (40099), forum posts and agent
|
||||
* job cards flow through the base timeline exactly like kind-9 chat — they need no
|
||||
* extra state here, just kind-aware rendering.
|
||||
*
|
||||
* One timeline per group: kind-9 chat from vanilla NIP-29 clients and 40002 from Buzz
|
||||
* clients land in the SAME channel, so mixed-dialect conversations stay whole.
|
||||
*/
|
||||
@Stable
|
||||
class BuzzWorkspaceChannel(
|
||||
groupId: GroupId,
|
||||
) : RelayGroupChannel(groupId) {
|
||||
/**
|
||||
* Newest 40003 edit per target message id. Keyed by the edited message's event id;
|
||||
* the value is the edit note (whose content is the replacement text).
|
||||
*/
|
||||
private val editsByTarget = LargeCache<HexKey, Note>()
|
||||
|
||||
private val editVersion = MutableStateFlow(0)
|
||||
|
||||
/** Bumps when any edit overlay changes, so rows can re-read [editFor]. */
|
||||
val editUpdates: StateFlow<Int> = editVersion
|
||||
|
||||
/** The newest canvas (kind 40100) note for this channel, or null when none seen. */
|
||||
var canvasNote: Note? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Records a 40003 edit note. Keeps only the newest edit per target (Buzz's own UI
|
||||
* applies last-write-wins by `created_at`). The note may still be loading when the
|
||||
* cache attaches it; callers pass the already-loaded note.
|
||||
*/
|
||||
fun addEdit(
|
||||
targetId: HexKey,
|
||||
editNote: Note,
|
||||
) {
|
||||
val current = editsByTarget.get(targetId)
|
||||
if (current == null || (editNote.createdAt() ?: 0L) > (current.createdAt() ?: 0L)) {
|
||||
editsByTarget.put(targetId, editNote)
|
||||
editVersion.value++
|
||||
}
|
||||
}
|
||||
|
||||
/** The newest edit note overlaying [targetId], or null when the message is unedited. */
|
||||
fun editFor(targetId: HexKey): Note? = editsByTarget.get(targetId)
|
||||
|
||||
/** The effective display content for a message: its newest edit's text, or null when unedited. */
|
||||
fun effectiveContentFor(targetId: HexKey): String? = editsByTarget.get(targetId)?.event?.content
|
||||
|
||||
fun updateCanvas(note: Note) {
|
||||
if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) {
|
||||
canvasNote = note
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Marker so generic code can ask "is this a Buzz edit?" without importing quartz kinds. */
|
||||
const val EDIT_KIND = StreamMessageEditEvent.KIND
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -52,7 +52,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
* host relay, so every read and write is pinned there via [relays].
|
||||
*/
|
||||
@Stable
|
||||
class RelayGroupChannel(
|
||||
open class RelayGroupChannel(
|
||||
val groupId: GroupId,
|
||||
) : Channel() {
|
||||
/**
|
||||
|
||||
@@ -116,6 +116,35 @@ Interop lessons encoded in the test:
|
||||
treated as absent and channel-scoped kinds are rejected.
|
||||
- The channel must exist (9007) before channel-scoped kinds are accepted.
|
||||
|
||||
## Client integration (commons + amethyst)
|
||||
|
||||
Buzz workspaces surface in Amethyst as **NIP-29 groups in the Buzz dialect** — one
|
||||
group model, two dialect renderers, and no impact on vanilla groups (feeds are
|
||||
kind-filtered, so they can never receive Buzz kinds by accident):
|
||||
|
||||
- `commons/.../model/buzz/BuzzRelayDialect.kt` — per-relay capability registry.
|
||||
Detection is event-shape based: the first Buzz-only kind consumed from a relay marks
|
||||
it (no false positives — vanilla relays never serve those kinds). NIP-11 marking can
|
||||
be added later for pre-connect detection.
|
||||
- `commons/.../model/buzz/BuzzWorkspaceChannel.kt` — sibling of `RelayGroupChannel`
|
||||
(which is now `open`). Holds Buzz-only channel state: the kind-40003 **edit overlay**
|
||||
(never render superseded text as current) and the newest kind-40100 **canvas**.
|
||||
Kind-9 chat and kind-40002 stream messages share ONE timeline per group, so
|
||||
mixed-dialect conversations stay whole.
|
||||
- `amethyst LocalCache` consumes every registered Buzz kind (they previously fell into
|
||||
the "Event Not Supported" branch): timeline kinds attach to the group's
|
||||
`BuzzWorkspaceChannel` (materialized dialect-aware, with an in-place upgrade when the
|
||||
dialect is discovered after the channel was first created), addressables store
|
||||
replaceably, the rest store as queryable regular events, and ephemeral signals
|
||||
(typing 20002, observer 24200, huddle reactions 24810, pairing 24134) mark the
|
||||
dialect but are deliberately not persisted.
|
||||
- The group-chat REQ builders (`RelayGroupFilterBuilders`) widen their timeline kind
|
||||
set with 40002/40003/40008/40099 **only for marked relays**, so vanilla NIP-29 REQs
|
||||
are untouched.
|
||||
|
||||
Not yet wired: kind-aware rendering of 40002/40099/diff rows in the chat UI, the
|
||||
composer switch (write 40002 on Buzz relays), and NIP-42-at-connect workspace sessions.
|
||||
|
||||
## Owner Attestation (NIP-OA) — implemented
|
||||
|
||||
The primitive that makes "agents as first-class members" work without enrolling every
|
||||
|
||||
Reference in New Issue
Block a user