Merge pull request #3727 from vitorpamplona/feat/buzz-system-message-sentences

feat(buzz): say who joined and what changed in the kind-40099 system lines
This commit is contained in:
Vitor Pamplona
2026-07-26 18:26:03 -04:00
committed by GitHub
8 changed files with 487 additions and 64 deletions
@@ -149,17 +149,17 @@ fun ChatroomMessageCompose(
RenderChannelAdminSystemMessage(baseNote, accountViewModel, nav)
} else if (event is SystemMessageEvent) {
// Buzz kind-40099: relay-signed room narration (join/leave/topic).
RenderBuzzSystemMessage(baseNote)
RenderBuzzSystemMessage(baseNote, accountViewModel, nav)
} 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)
RenderBuzzForumVote(baseNote, accountViewModel)
} 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)
RenderBuzzActivityRow(baseNote, accountViewModel)
} else {
NormalChatNote(
baseNote,
@@ -20,22 +20,29 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Size18dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.grayText
@@ -43,11 +50,16 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
* A centered, muted system line for events that narrate the room rather than talk
* in it (channel created, profile updated, ...). Visually distinct from user
* bubbles: no author row, no tail, one small pill in the middle of the feed.
*
* [leading] is an optional slot rendered inside the pill, before the text — used to
* put the avatar of whoever the line is about ("Alice joined") next to the sentence,
* so a membership change is recognizable without reading the name.
*/
@Composable
fun ChatSystemMessage(
text: String,
onClick: (() -> Unit)? = null,
leading: (@Composable () -> Unit)? = null,
) {
Row(
modifier =
@@ -66,19 +78,33 @@ fun ChatSystemMessage(
Modifier
},
) {
SystemMessageText(text)
if (leading == null) {
SystemMessageText(text)
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(start = 8.dp),
) {
leading()
SystemMessageText(text, startPadding = 0.dp)
}
}
}
}
}
@Composable
private fun SystemMessageText(text: String) {
private fun SystemMessageText(
text: String,
startPadding: Dp = 12.dp,
) {
Text(
text = text,
fontSize = Font12SP,
color = MaterialTheme.colorScheme.grayText,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
modifier = Modifier.padding(start = startPadding, end = 12.dp, top = 5.dp, bottom = 5.dp),
)
}
@@ -88,5 +114,9 @@ private fun ChatSystemMessagePreview() {
ThemeComparisonColumn {
ChatSystemMessage("Alice created the channel Amethyst Users", onClick = {})
ChatSystemMessage("Alice updated the channel profile")
ChatSystemMessage(
"Bob was added by Alice",
leading = { Box(Modifier.size(Size18dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) },
)
}
}
@@ -110,33 +110,6 @@ fun RenderBuzzEditedNote(
}
}
/**
* A Buzz kind-40099 system message ("X joined", "channel created", "topic changed"):
* narrates the room rather than speaking in it, so it renders as a centered system
* line like the NIP-28 admin events, from the relay-signed JSON payload.
*/
@Composable
fun RenderBuzzSystemMessage(note: Note) {
val event = note.event as? SystemMessageEvent ?: return
val text = remember(event) { buzzSystemMessageText(event) }
ChatSystemMessage(text = text)
}
/**
* The one-line label for a Buzz kind-40099 system message. Relay-emitted machine text
* (join/leave/topic); shown as-is rather than through string resources — the payload
* vocabulary is Buzz's, not ours to translate yet. Pure so the Messages-list preview and
* the in-chat system line render identical text.
*/
fun buzzSystemMessageText(event: SystemMessageEvent): String {
val payload = event.payload()
return when (payload?.type) {
"topic_changed" -> payload.topic?.let { "topic: $it" } ?: "topic changed"
"purpose_changed" -> payload.purpose?.let { "purpose: $it" } ?: "purpose changed"
else -> payload?.type?.replace('_', ' ')
} ?: event.content.take(120)
}
/**
* 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
@@ -161,32 +134,59 @@ fun isBuzzActivityRow(event: Event?): Boolean =
* snippet (the human-readable status/result/error the agent wrote).
*/
@Composable
fun RenderBuzzActivityRow(note: Note) {
fun RenderBuzzActivityRow(
note: Note,
accountViewModel: AccountViewModel,
) {
val event = note.event ?: return
val text = remember(event) { buzzActivityLabel(event) ?: event.content.take(120) }
val text = buzzActivityLabel(event, accountViewModel) ?: remember(event) { event.content.take(120) }
ChatSystemMessage(text = text)
}
/**
* The centered-system-line label for a Buzz agent-job or huddle lifecycle event, or null if
* [event] isn't one. Pure so the Messages-list preview and the in-chat activity row read the
* same. 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).
* [event] isn't one. Shared by the Messages-list preview and the in-chat activity row so the
* two read the same.
*
* Huddle joins/leaves name the participant from the event's `p` tag (falling back to the
* signer), and job lines name the signer — the agent that accepted, the human that requested —
* because "someone joined the huddle" in a busy channel says nothing about who to talk to.
* Job progress/result/error also append a short snippet of what the agent wrote.
*/
fun buzzActivityLabel(event: Event): String? =
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"
@Composable
fun buzzActivityLabel(
event: Event,
accountViewModel: AccountViewModel,
): String? {
val signer = observeUserNameByHex(event.pubKey, accountViewModel)
val participant =
observeUserNameByHex(
remember(event) {
when (event) {
is HuddleParticipantJoinedEvent -> event.participant() ?: event.pubKey
is HuddleParticipantLeftEvent -> event.participant() ?: event.pubKey
else -> null
}
},
accountViewModel,
)
return when (event) {
is JobRequestEvent -> stringRes(R.string.buzz_job_requested, signer) + remember(event) { event.request().snippet() }
is JobAcceptedEvent -> stringRes(R.string.buzz_job_accepted, signer)
is JobProgressEvent ->
stringRes(R.string.buzz_job_progress) +
remember(event) { (event.status()?.let { ": $it" } ?: "") + event.content.snippet() }
is JobResultEvent -> stringRes(R.string.buzz_job_result) + remember(event) { event.result().snippet() }
is JobCancelEvent -> stringRes(R.string.buzz_job_cancelled)
is JobErrorEvent -> stringRes(R.string.buzz_job_error) + remember(event) { event.error().snippet() }
is HuddleStartedEvent -> stringRes(R.string.buzz_huddle_started, signer)
is HuddleParticipantJoinedEvent -> stringRes(R.string.buzz_huddle_joined, participant)
is HuddleParticipantLeftEvent -> stringRes(R.string.buzz_huddle_left, participant)
is HuddleEndedEvent -> stringRes(R.string.buzz_huddle_ended)
else -> null
}
}
/**
* A human-readable one-line preview of a Buzz chat-timeline event for the Messages list, or null
@@ -195,12 +195,16 @@ fun buzzActivityLabel(event: Event): String? =
* so this returns the same summary the in-chat system/activity/diff row shows instead of dumping
* raw payload into the preview. Keyed off the exact set in [isBuzzChatTimelineContent].
*/
fun buzzTimelinePreviewSummary(event: Event): String? =
@Composable
fun buzzTimelinePreviewSummary(
event: Event,
accountViewModel: AccountViewModel,
): String? =
when (event) {
is StreamMessageV2Event -> null
is SystemMessageEvent -> buzzSystemMessageText(event)
is StreamMessageDiffEvent -> event.diffMeta()?.filePath?.let { "📄 $it" } ?: "📄 diff"
else -> buzzActivityLabel(event)
is SystemMessageEvent -> buzzSystemMessageText(event, accountViewModel)
is StreamMessageDiffEvent -> remember(event) { event.diffMeta()?.filePath?.let { "📄 $it" } ?: "📄 diff" }
else -> buzzActivityLabel(event, accountViewModel)
}
/** A short one-line snippet of free-text content appended after a label, or "" if blank. */
@@ -265,9 +269,21 @@ fun RenderBuzzDiff(note: Note) {
/** A Buzz kind-45002 forum vote: a lightweight up/down signal, shown as a system line. */
@Composable
fun RenderBuzzForumVote(note: Note) {
fun RenderBuzzForumVote(
note: Note,
accountViewModel: AccountViewModel,
) {
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)
val isDownVote = remember(event) { event.content.trim().startsWith("-") }
val voter = observeUserNameByHex(event.pubKey, accountViewModel)
ChatSystemMessage(
text =
if (isDownVote) {
stringRes(R.string.buzz_forum_downvoted, voter)
} else {
stringRes(R.string.buzz_forum_upvoted, voter)
},
)
}
@@ -0,0 +1,207 @@
/*
* 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.feed.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.pluralStringResource
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.UserPicture
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.amethyst.ui.theme.Size18dp
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A Buzz kind-40099 system message: relay-authored narration of a channel state change
* ("Alice joined", "Bob made this channel private"). It narrates the room rather than
* speaking in it, so it renders as a centered system line like the NIP-28 admin events —
* with the avatar of whoever the line is about, tapping through to their profile.
*
* The event's own author is the **relay keypair**, never a person, so the people in the
* sentence come from the signed JSON payload's `actor`/`target` pubkeys, not from
* `note.author`.
*/
@Composable
fun RenderBuzzSystemMessage(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? SystemMessageEvent ?: return
val payload = remember(event) { event.payload() }
val subject = remember(payload) { payload?.subject() }
ChatSystemMessage(
text = buzzSystemMessageText(event, accountViewModel),
onClick = subject?.let { { nav.nav(Route.Profile(it)) } },
leading =
subject?.let {
{
UserPicture(
userHex = it,
size = Size18dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
},
)
}
/**
* The one-line sentence for a Buzz kind-40099 system message, with `actor`/`target` pubkeys
* resolved to the names the viewer knows them by (petname first, like everywhere else).
*
* Shared by the in-chat system line and the Messages-list preview so the two can never word
* the same event differently. A payload this version has no sentence for degrades to
* "name: the_raw_type" rather than vanishing — new relay vocabulary stays legible.
*/
@Composable
fun buzzSystemMessageText(
event: SystemMessageEvent,
accountViewModel: AccountViewModel,
): String {
val payload = remember(event) { event.payload() } ?: return remember(event) { event.content.take(120) }
val actor = observeUserNameByHex(payload.actor, accountViewModel)
val target = observeUserNameByHex(payload.target, accountViewModel)
val subject = if (payload.target != null) target else actor
return when (payload.type) {
// The relay emits the same type for "I joined" and "someone added me", separated only by
// actor == target. Wording them the same would credit a self-join to whoever invited.
SystemMessagePayload.MEMBER_JOINED ->
if (payload.target == null || payload.target == payload.actor) {
stringRes(R.string.buzz_system_member_joined, subject)
} else {
stringRes(R.string.buzz_system_member_added, target, actor)
}
SystemMessagePayload.MEMBER_LEFT -> stringRes(R.string.buzz_system_member_left, subject)
SystemMessagePayload.MEMBER_REMOVED ->
if (payload.target == null || payload.target == payload.actor) {
stringRes(R.string.buzz_system_member_left, subject)
} else {
stringRes(R.string.buzz_system_member_removed, target, actor)
}
SystemMessagePayload.TOPIC_CHANGED ->
payload.topic?.takeIf { it.isNotBlank() }?.let {
stringRes(R.string.buzz_system_topic_changed, actor, it)
} ?: stringRes(R.string.buzz_system_topic_cleared, actor)
SystemMessagePayload.PURPOSE_CHANGED ->
payload.purpose?.takeIf { it.isNotBlank() }?.let {
stringRes(R.string.buzz_system_purpose_changed, actor, it)
} ?: stringRes(R.string.buzz_system_purpose_cleared, actor)
// Buzz has exactly two visibility modes; spell out what each one means for the reader
// rather than echoing the relay's token, and keep a literal fallback for a third.
SystemMessagePayload.VISIBILITY_CHANGED ->
when (payload.visibility) {
SystemMessagePayload.VISIBILITY_OPEN -> stringRes(R.string.buzz_system_visibility_open, actor)
SystemMessagePayload.VISIBILITY_PRIVATE -> stringRes(R.string.buzz_system_visibility_private, actor)
else -> stringRes(R.string.buzz_system_visibility_other, actor, payload.visibility ?: "")
}
// A null ttl_seconds is the relay clearing the TTL (messages become permanent).
SystemMessagePayload.TTL_CHANGED ->
payload.ttlSeconds?.takeIf { it > 0 }?.let {
stringRes(R.string.buzz_system_ttl_set, actor, ttlDurationText(it))
} ?: stringRes(R.string.buzz_system_ttl_cleared, actor)
SystemMessagePayload.CHANNEL_ARCHIVED -> stringRes(R.string.buzz_system_channel_archived, actor)
SystemMessagePayload.CHANNEL_UNARCHIVED -> stringRes(R.string.buzz_system_channel_unarchived, actor)
SystemMessagePayload.CHANNEL_CREATED -> stringRes(R.string.buzz_system_channel_created, actor)
SystemMessagePayload.CHANNEL_DELETED -> stringRes(R.string.buzz_system_channel_deleted, actor)
SystemMessagePayload.MESSAGE_DELETED ->
payload.publicReason?.takeIf { it.isNotBlank() }?.let {
stringRes(R.string.buzz_system_message_deleted_reason, actor, it)
} ?: stringRes(R.string.buzz_system_message_deleted, actor)
SystemMessagePayload.DM_CREATED -> stringRes(R.string.buzz_system_dm_created, actor)
else -> stringRes(R.string.buzz_system_unknown, actor, payload.type.replace('_', ' '))
}
}
/**
* A TTL as a rounded, pluralized duration ("7 days", "12 hours"), reusing the same duration
* plurals as the last-seen line. Rounds to the largest whole unit that fits, which is what the
* relay's own values are (a day, a week) and reads better than "604800 seconds".
*/
@Composable
private fun ttlDurationText(seconds: Long): String =
when {
seconds >= TimeUtils.ONE_DAY -> {
val n = (seconds / TimeUtils.ONE_DAY).toInt()
pluralStringResource(R.plurals.duration_days, n, n)
}
seconds >= TimeUtils.ONE_HOUR -> {
val n = (seconds / TimeUtils.ONE_HOUR).toInt()
pluralStringResource(R.plurals.duration_hours, n, n)
}
else -> {
val n = (seconds / TimeUtils.ONE_MINUTE).toInt().coerceAtLeast(1)
pluralStringResource(R.plurals.duration_minutes, n, n)
}
}
/**
* The display name for a pubkey that arrived inside an event's *payload* rather than as its
* author — resolving it needs a [com.vitorpamplona.amethyst.commons.model.User] first, which may
* not be in the cache yet.
*
* Returns the shortened hex until the profile loads, and empty string for a null pubkey so a
* caller can format a sentence whose subject the relay omitted without printing "null".
*/
@Composable
fun observeUserNameByHex(
pubkey: HexKey?,
accountViewModel: AccountViewModel,
): String {
if (pubkey == null) return ""
var user by remember(pubkey) { mutableStateOf(accountViewModel.getUserIfExists(pubkey)) }
if (user == null) {
LaunchedEffect(pubkey) { user = accountViewModel.checkGetOrCreateUser(pubkey) }
}
val loaded = user ?: return remember(pubkey) { pubkey.take(8) }
val name by observeUserName(loaded, accountViewModel)
return name
}
@@ -456,7 +456,7 @@ private fun RelayGroupRoomCompose(
// A Buzz timeline row (system line, huddle/job activity, diff) carries JSON/diff in its
// content, so show its human-readable summary — the same text the in-chat row renders —
// rather than "author: {json}". Plain chat messages fall through to the usual framing.
buzzTimelinePreviewSummary(noteEvent) ?: "$authorName: ${noteEvent.content.take(200)}"
buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${noteEvent.content.take(200)}"
} else {
// Event-less placeholder row. Until the channel's `limit = 1` preview REQ settles we cannot
// tell an empty channel from one whose newest message simply hasn't arrived, and claiming
@@ -604,7 +604,7 @@ private fun RelayGroupServerRoomCompose(
val authorName by observeUserName(author, accountViewModel)
// Buzz timeline rows (system/huddle/job/diff) carry JSON/diff content — summarize them
// like the in-chat row instead of printing raw payload; plain chat falls through.
buzzTimelinePreviewSummary(noteEvent) ?: "$authorName: ${noteEvent.content.take(200)}"
buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${noteEvent.content.take(200)}"
} else {
stringRes(R.string.relay_group_no_messages_yet)
}
+42
View File
@@ -3447,6 +3447,48 @@
<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>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<string name="buzz_system_member_joined">%1$s joined</string>
<string name="buzz_system_member_added">%1$s was added by %2$s</string>
<string name="buzz_system_member_left">%1$s left</string>
<string name="buzz_system_member_removed">%1$s was removed by %2$s</string>
<string name="buzz_system_topic_changed">%1$s set the topic to \"%2$s\"</string>
<string name="buzz_system_topic_cleared">%1$s cleared the topic</string>
<string name="buzz_system_purpose_changed">%1$s set the purpose to \"%2$s\"</string>
<string name="buzz_system_purpose_cleared">%1$s cleared the purpose</string>
<string name="buzz_system_visibility_open">%1$s made this channel open — anyone can find and join it</string>
<string name="buzz_system_visibility_private">%1$s made this channel private — invite only</string>
<string name="buzz_system_visibility_other">%1$s changed the visibility to %2$s</string>
<string name="buzz_system_ttl_set">%1$s set messages to disappear after %2$s</string>
<string name="buzz_system_ttl_cleared">%1$s turned off disappearing messages</string>
<string name="buzz_system_channel_archived">%1$s archived this channel</string>
<string name="buzz_system_channel_unarchived">%1$s restored this channel</string>
<string name="buzz_system_channel_created">%1$s created this channel</string>
<string name="buzz_system_channel_deleted">%1$s deleted this channel</string>
<string name="buzz_system_message_deleted">%1$s deleted a message</string>
<string name="buzz_system_message_deleted_reason">%1$s deleted a message: %2$s</string>
<string name="buzz_system_dm_created">%1$s started this conversation</string>
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<string name="buzz_system_unknown">%1$s: %2$s</string>
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<string name="buzz_huddle_started">🔊 %1$s started a huddle</string>
<string name="buzz_huddle_joined">🔊 %1$s joined the huddle</string>
<string name="buzz_huddle_left">🔊 %1$s left the huddle</string>
<string name="buzz_huddle_ended">🔊 Huddle ended</string>
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<string name="buzz_job_requested">⚙ %1$s requested a job</string>
<string name="buzz_job_accepted">⚙ %1$s accepted the job</string>
<string name="buzz_job_progress">⚙ Job progress</string>
<string name="buzz_job_result">⚙ Job result</string>
<string name="buzz_job_cancelled">⚙ Job cancelled</string>
<string name="buzz_job_error">⚠ Job error</string>
<!-- Buzz forum votes (kind 45002). -->
<string name="buzz_forum_upvoted">▲ %1$s upvoted a post</string>
<string name="buzz_forum_downvoted">▼ %1$s downvoted a post</string>
<string name="buzz_canvas_title">Canvas</string>
<string name="buzz_canvas_empty">No canvas has been shared in this workspace yet.</string>
<string name="buzz_canvas_edit">Edit canvas</string>
@@ -31,11 +31,26 @@ import kotlinx.serialization.json.Json
* record of a channel state change (join, leave, rename, archive, delete, ...).
*
* Field names mirror the `serde_json::json!` payloads emitted by `emit_system_message`
* and its callers in Buzz's `buzz-relay/src/handlers/side_effects.rs`. Only [type] is
* always present; [actor] is present on almost every variant, and the remaining fields
* are variant-specific (`member_joined`/`member_removed` carry [target], `topic_changed`
* carries [topic], `ttl_changed` carries [ttlSeconds], etc). Unknown keys are ignored
* for forward compatibility.
* and its callers in Buzz's `crates/buzz-relay/src/handlers/`. Only [type] is always
* present; [actor] is present on every variant the relay emits today, and the rest are
* variant-specific. Unknown keys are ignored for forward compatibility, so a relay that
* grows a new field or a new [type] degrades to a plain line instead of failing to parse.
*
* The complete vocabulary, read off `side_effects.rs` / `command_executor.rs`:
*
* | [type] | carries | emitted when |
* |--|--|--|
* | [MEMBER_JOINED] | [actor], [target] | someone was added ([actor] added [target]) or joined on their own ([actor] == [target]) |
* | [MEMBER_LEFT] | [actor], optional [target] | a member left; the explicit-leave path omits [target] |
* | [MEMBER_REMOVED] | [actor], [target] | [actor] removed [target] |
* | [TOPIC_CHANGED] | [actor], [topic] | the channel topic was set |
* | [PURPOSE_CHANGED] | [actor], [purpose] | the channel purpose was set |
* | [VISIBILITY_CHANGED] | [actor], [visibility] | flipped between [VISIBILITY_OPEN] and [VISIBILITY_PRIVATE] |
* | [TTL_CHANGED] | [actor], [ttlSeconds] | disappearing messages set; `null` [ttlSeconds] means cleared (permanent) |
* | [CHANNEL_ARCHIVED] / [CHANNEL_UNARCHIVED] | [actor] | archive flag flipped |
* | [CHANNEL_CREATED] / [CHANNEL_DELETED] | [actor] | channel lifecycle |
* | [MESSAGE_DELETED] | [actor], [targetEventId], optional [actionId] / [reasonCode] / [publicReason] | a message was deleted (moderation tombstone) |
* | [DM_CREATED] | [actor], [participants] | a DM channel was opened |
*/
@Serializable
data class SystemMessagePayload(
@@ -46,9 +61,29 @@ data class SystemMessagePayload(
val purpose: String? = null,
val visibility: String? = null,
@SerialName("ttl_seconds") val ttlSeconds: Long? = null,
/** The deleted message's id on [MESSAGE_DELETED]. */
@SerialName("target_event_id") val targetEventId: String? = null,
/** Moderation-action id linking the tombstone back to the action that caused it. */
@SerialName("action_id") val actionId: String? = null,
/** Machine-readable moderation reason (e.g. `spam`), when the deleter gave one. */
@SerialName("reason_code") val reasonCode: String? = null,
/** Human-readable moderation reason the relay is willing to show everyone. */
@SerialName("public_reason") val publicReason: String? = null,
/** Every participant of a newly opened DM, [actor] included. */
val participants: List<String>? = null,
) {
fun encodeToJson(): String = JSON.encodeToString(this)
/**
* The pubkey the sentence is *about* — whose avatar to show. Membership changes are about the
* member who joined/left/was removed; everything else is about whoever performed the action.
*/
fun subject(): String? =
when (type) {
MEMBER_JOINED, MEMBER_LEFT, MEMBER_REMOVED -> target ?: actor
else -> actor
}
companion object {
val JSON =
Json {
@@ -58,5 +93,25 @@ data class SystemMessagePayload(
}
fun decodeFromJson(json: String): SystemMessagePayload = JSON.decodeFromString(json)
const val MEMBER_JOINED = "member_joined"
const val MEMBER_LEFT = "member_left"
const val MEMBER_REMOVED = "member_removed"
const val TOPIC_CHANGED = "topic_changed"
const val PURPOSE_CHANGED = "purpose_changed"
const val VISIBILITY_CHANGED = "visibility_changed"
const val TTL_CHANGED = "ttl_changed"
const val CHANNEL_ARCHIVED = "channel_archived"
const val CHANNEL_UNARCHIVED = "channel_unarchived"
const val CHANNEL_CREATED = "channel_created"
const val CHANNEL_DELETED = "channel_deleted"
const val MESSAGE_DELETED = "message_deleted"
const val DM_CREATED = "dm_created"
/** Searchable, anyone can join. */
const val VISIBILITY_OPEN = "open"
/** Hidden, invite-only. */
const val VISIBILITY_PRIVATE = "private"
}
}
@@ -69,4 +69,77 @@ class SystemMessageEventTest {
assertEquals(actor, payload.actor)
assertEquals(3600L, payload.ttlSeconds)
}
/**
* Every variant the relay emits, with the exact JSON shape from `emit_system_message`'s callers
* in `crates/buzz-relay/src/handlers/`. The UI words each one differently — "was added by" vs
* "joined", "made this private" vs a raw token — so a field silently dropping to null here is a
* sentence losing its subject on screen.
*/
@Test
fun everyRelayVariantKeepsItsFields() {
fun decode(json: String) = SystemMessagePayload.decodeFromJson(json)
val added = decode("""{"type":"member_joined","actor":"$actor","target":"$target"}""")
assertEquals(SystemMessagePayload.MEMBER_JOINED, added.type)
assertEquals(target, added.target)
// The self-join path sends the SAME type with actor == target; that equality is the only
// thing separating "Bob joined" from "Bob was added by Alice".
val selfJoin = decode("""{"type":"member_joined","actor":"$actor","target":"$actor"}""")
assertEquals(selfJoin.actor, selfJoin.target)
// The explicit-leave path omits `target` entirely.
val left = decode("""{"type":"member_left","actor":"$actor"}""")
assertEquals(null, left.target)
val removed = decode("""{"type":"member_removed","actor":"$actor","target":"$target"}""")
assertEquals(target, removed.target)
assertEquals("Standup", decode("""{"type":"topic_changed","actor":"$actor","topic":"Standup"}""").topic)
assertEquals("Ship it", decode("""{"type":"purpose_changed","actor":"$actor","purpose":"Ship it"}""").purpose)
val private = decode("""{"type":"visibility_changed","actor":"$actor","visibility":"private"}""")
assertEquals(SystemMessagePayload.VISIBILITY_PRIVATE, private.visibility)
val open = decode("""{"type":"visibility_changed","actor":"$actor","visibility":"open"}""")
assertEquals(SystemMessagePayload.VISIBILITY_OPEN, open.visibility)
// Clearing the TTL sends an explicit null, which must read as "no TTL", not as a parse failure.
assertEquals(null, decode("""{"type":"ttl_changed","actor":"$actor","ttl_seconds":null}""").ttlSeconds)
assertEquals(604800L, decode("""{"type":"ttl_changed","actor":"$actor","ttl_seconds":604800}""").ttlSeconds)
for (type in listOf("channel_archived", "channel_unarchived", "channel_created", "channel_deleted")) {
assertEquals(actor, decode("""{"type":"$type","actor":"$actor"}""").actor)
}
val deleted =
decode(
"""{"type":"message_deleted","actor":"$actor","target_event_id":"cc","action_id":"a1","reason_code":"spam","public_reason":"off topic"}""",
)
assertEquals("cc", deleted.targetEventId)
assertEquals("a1", deleted.actionId)
assertEquals("spam", deleted.reasonCode)
assertEquals("off topic", deleted.publicReason)
val dm = decode("""{"type":"dm_created","actor":"$actor","participants":["$actor","$target"]}""")
assertEquals(listOf(actor, target), dm.participants)
}
/** The avatar shown beside the line: the member a membership change is about, the actor otherwise. */
@Test
fun subjectIsTheMemberForMembershipChangesAndTheActorOtherwise() {
assertEquals(target, SystemMessagePayload(type = SystemMessagePayload.MEMBER_JOINED, actor = actor, target = target).subject())
assertEquals(target, SystemMessagePayload(type = SystemMessagePayload.MEMBER_REMOVED, actor = actor, target = target).subject())
assertEquals(actor, SystemMessagePayload(type = SystemMessagePayload.MEMBER_LEFT, actor = actor).subject())
assertEquals(actor, SystemMessagePayload(type = SystemMessagePayload.VISIBILITY_CHANGED, actor = actor, visibility = "open").subject())
assertEquals(actor, SystemMessagePayload(type = SystemMessagePayload.TOPIC_CHANGED, actor = actor, topic = "x").subject())
}
/** An unknown type or extra key must survive as a line, not blow up the whole message. */
@Test
fun unknownTypeAndUnknownKeysStillParse() {
val payload = SystemMessagePayload.decodeFromJson("""{"type":"pinned_changed","actor":"$actor","brand_new":"x"}""")
assertEquals("pinned_changed", payload.type)
assertEquals(actor, payload.actor)
}
}