From 7903834efd1789f38c8db00eab1378bfb6930729 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 20:58:11 +0000 Subject: [PATCH] feat(commons): Buzz workspaces as NIP-29 groups in the Buzz dialect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8 --- .../amethyst/model/LocalCache.kt | 264 +++++++++++++++++- .../datasource/RelayGroupFilterBuilders.kt | 33 ++- .../model/BuzzWorkspaceChannelTest.kt | 167 +++++++++++ .../datasource/BuzzTimelineKindsTest.kt | 79 ++++++ .../commons/model/buzz/BuzzRelayDialect.kt | 69 +++++ .../model/buzz/BuzzWorkspaceChannel.kt | 104 +++++++ .../nip29RelayGroups/RelayGroupChannel.kt | 2 +- .../com/vitorpamplona/quartz/buzz/README.md | 29 ++ 8 files changed, 742 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzTimelineKindsTest.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzRelayDialect.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceChannel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 3138eee1d8..07301e3c58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt index a68436ff0d..03e5297089 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt @@ -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 = + 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, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt new file mode 100644 index 0000000000..bdf81e1cba --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt @@ -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() + every { Looper.getMainLooper() } returns mockk() + 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) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzTimelineKindsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzTimelineKindsTest.kt new file mode 100644 index 0000000000..91d3a95ccf --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzTimelineKindsTest.kt @@ -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)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzRelayDialect.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzRelayDialect.kt new file mode 100644 index 0000000000..65b660d391 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzRelayDialect.kt @@ -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>(emptySet()) + + /** Relays known to speak the Buzz dialect; UI can collect this for affordances. */ + val flow: StateFlow> = 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() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceChannel.kt new file mode 100644 index 0000000000..54d578625c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceChannel.kt @@ -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() + + private val editVersion = MutableStateFlow(0) + + /** Bumps when any edit overlay changes, so rows can re-read [editFor]. */ + val editUpdates: StateFlow = 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 + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index aaed4b3e9a..2cac481782 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -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() { /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md index e916190171..a76b01a6f4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md @@ -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