feat(buzz): widen group REQ to richer kinds + kind-aware renderers

Read-path interop. Two changes:

1. RelayGroupFilterBuilders: BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS now also
   requests canvas (40100), forum posts/votes/comments (45001-45003), agent
   jobs (43001-43006), and huddle lifecycle (48100-48103) — all h-scoped, so
   the same #h group REQ returns them. Previously only 40002/40003/40008/40099
   were requested, so LocalCache could consume these kinds but they never
   arrived for a group feed.

2. RenderBuzzNotes: kind-aware rows dispatched from ChatMessageCompose —
   RenderBuzzDiff (40008: monospace, horizontally-scrollable diff with a
   repo/commit/file header instead of prose-wrapped gutters), RenderBuzzActivityRow
   (job + huddle lifecycle as centered system narration — huddles MUST be caught
   here since their content is JSON), and RenderBuzzForumVote (45002). Forum
   posts stay as plain chat bubbles (their content is already plain text).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-22 01:23:26 +00:00
parent 5137b92046
commit 0ea5d0e876
4 changed files with 191 additions and 2 deletions
@@ -56,7 +56,10 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzActivityRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzDiff
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzEditedNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzForumVote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzSystemMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChannelAdminSystemMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
@@ -67,9 +70,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeBuzzEdit
import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
@@ -136,6 +142,16 @@ fun ChatroomMessageCompose(
} else if (event is SystemMessageEvent) {
// Buzz kind-40099: relay-signed room narration (join/leave/topic).
RenderBuzzSystemMessage(baseNote)
} else if (event is StreamMessageDiffEvent) {
// Buzz kind-40008: a code/text diff pushed into the channel.
RenderBuzzDiff(baseNote)
} else if (event is ForumVoteEvent) {
// Buzz kind-45002: a forum up/down vote.
RenderBuzzForumVote(baseNote)
} else if (isBuzzActivityRow(event)) {
// Buzz agent-job (43xxx) and huddle (48xxx) lifecycle narration. Huddles
// especially must be caught here — their content is JSON, not chat text.
RenderBuzzActivityRow(baseNote)
} else {
NormalChatNote(
baseNote,
@@ -20,7 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -30,6 +35,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
@@ -41,7 +49,20 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatSystemMessage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
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.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
/**
@@ -131,3 +152,117 @@ fun RenderBuzzSystemMessage(note: Note) {
ChatSystemMessage(text = text)
}
/**
* True for the Buzz agent-job and huddle lifecycle kinds that [RenderBuzzActivityRow]
* narrates as a centered system line rather than a chat bubble. Huddle events in
* particular MUST be caught here — their `content` is JSON, so rendering them as a plain
* chat message would show raw `{"ephemeral_channel_id":…}`.
*/
fun isBuzzActivityRow(event: Event?): Boolean =
event is JobRequestEvent ||
event is JobAcceptedEvent ||
event is JobProgressEvent ||
event is JobResultEvent ||
event is JobCancelEvent ||
event is JobErrorEvent ||
event is HuddleStartedEvent ||
event is HuddleParticipantJoinedEvent ||
event is HuddleParticipantLeftEvent ||
event is HuddleEndedEvent
/**
* Narrates a Buzz agent-job or huddle lifecycle event as a centered system line. The
* label is derived from the kind; job progress/result/error also append a short content
* snippet (the human-readable status/result/error the agent wrote).
*/
@Composable
fun RenderBuzzActivityRow(note: Note) {
val event = note.event ?: return
val text =
remember(event) {
when (event) {
is JobRequestEvent -> "⚙ job requested" + event.request().snippet()
is JobAcceptedEvent -> "⚙ job accepted"
is JobProgressEvent -> "⚙ job progress" + (event.status()?.let { ": $it" } ?: "") + event.content.snippet()
is JobResultEvent -> "⚙ job result" + event.result().snippet()
is JobCancelEvent -> "⚙ job cancelled"
is JobErrorEvent -> "⚠ job error" + event.error().snippet()
is HuddleStartedEvent -> "🔊 huddle started"
is HuddleParticipantJoinedEvent -> "🔊 someone joined the huddle"
is HuddleParticipantLeftEvent -> "🔊 someone left the huddle"
is HuddleEndedEvent -> "🔊 huddle ended"
else -> event.content.take(120)
}
}
ChatSystemMessage(text = text)
}
/** A short one-line snippet of free-text content appended after a label, or "" if blank. */
private fun String.snippet(): String {
val oneLine = trim().replace('\n', ' ')
if (oneLine.isEmpty()) return ""
return ": " + if (oneLine.length > 80) oneLine.take(80) + "" else oneLine
}
/**
* A Buzz kind-40008 stream diff: a code/text diff pushed into the channel. Renders the
* repo/commit/file header from the `DiffMeta` tags plus the diff body in a monospace,
* horizontally-scrollable block, rather than the plain-text fallback (which mangles the
* `+`/`-` gutters into wrapped prose).
*/
@Composable
fun RenderBuzzDiff(note: Note) {
val event = note.event as? StreamMessageDiffEvent ?: return
val meta = remember(event) { event.diffMeta() }
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(modifier = Modifier.padding(12.dp)) {
val header =
remember(meta) {
meta?.let {
buildString {
it.filePath?.let(::append)
it.language?.let { lang -> append(if (isEmpty()) lang else " · $lang") }
it.prNumber?.let { pr -> append(" · PR #$pr") }
it.commitSha
?.takeIf { sha -> sha.isNotBlank() }
?.let { sha -> append(" · ${sha.take(8)}") }
}.ifBlank { null }
}
}
if (header != null) {
Text(
text = header,
style = MaterialTheme.typography.labelMedium,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = event.content,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
)
if (meta?.truncated == true) {
Text(
text = stringRes(R.string.buzz_diff_truncated),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
}
}
}
/** A Buzz kind-45002 forum vote: a lightweight up/down signal, shown as a system line. */
@Composable
fun RenderBuzzForumVote(note: Note) {
val event = note.event as? ForumVoteEvent ?: return
// Content is the vote token ("+"/"-" or similar); show a compact glyph line.
val text = remember(event) { if (event.content.trim().startsWith("-")) "▼ downvoted a post" else "▲ upvoted a post" }
ChatSystemMessage(text = text)
}
@@ -20,6 +20,20 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
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.HuddleParticipantJoinedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
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.stream.CanvasEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
@@ -91,12 +105,21 @@ 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). Requested UNCONDITIONALLY alongside the NIP-29 set — on a vanilla relay the
* channels, requested UNCONDITIONALLY alongside the NIP-29 set — on a vanilla relay the
* kinds simply match nothing. A dialect-gated version was tried and reverted: gating
* creates a bootstrap hole (nothing asks for a Buzz kind until one is consumed) and a
* worse one — history pages fetched before the mark advance their cursors past ranges
* queried WITHOUT Buzz kinds, permanently skipping older workspace messages.
*
* All are `h`-scoped (`GroupIdTag`), so the same `#h` group REQ returns them:
* - stream messages v2 (40002), edits (40003), diffs (40008), system rows (40099), canvas (40100)
* - forum posts/votes/comments (45001-45003)
* - agent jobs (43001-43006)
* - huddle lifecycle (48100-48103)
*
* Consumption for every one of these already exists in `LocalCache` (see
* `consumeBuzzTimelineEvent`); requesting them here is what lets them actually arrive for
* a group feed instead of only appearing if another subscription happened to fetch them.
*/
val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
listOf(
@@ -104,6 +127,20 @@ val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
StreamMessageEditEvent.KIND,
StreamMessageDiffEvent.KIND,
SystemMessageEvent.KIND,
CanvasEvent.KIND,
ForumPostEvent.KIND,
ForumVoteEvent.KIND,
ForumCommentEvent.KIND,
JobRequestEvent.KIND,
JobAcceptedEvent.KIND,
JobProgressEvent.KIND,
JobResultEvent.KIND,
JobCancelEvent.KIND,
JobErrorEvent.KIND,
HuddleStartedEvent.KIND,
HuddleParticipantJoinedEvent.KIND,
HuddleParticipantLeftEvent.KIND,
HuddleEndedEvent.KIND,
)
/** The timeline kinds requested for every relay-group REQ (NIP-29 + Buzz; see above). */
+1
View File
@@ -3291,6 +3291,7 @@
<string name="chat_system_created_channel_unnamed">%1$s created the channel</string>
<string name="chat_system_updated_channel">%1$s updated the channel profile</string>
<string name="buzz_message_edited">(edited)</string>
<string name="buzz_diff_truncated">(diff truncated)</string>
<string name="chat_delivery_details_title">Message Delivery</string>
<string name="close">Close</string>