diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt
index fe5f68d670..adf62d63be 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt
@@ -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,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt
index 4fb1f50ffa..598827b42f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt
@@ -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)) },
+ )
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt
index b1ee7fa174..fcf78442df 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt
@@ -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)
+ },
+ )
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt
new file mode 100644
index 0000000000..c6d237a914
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt
@@ -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
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt
index 42a3fe8650..30512735c9 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt
@@ -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)
}
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index ebaa131c93..167a715f3d 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -3447,6 +3447,48 @@
%1$s updated the channel profile
(edited)
(diff truncated)
+
+
+ %1$s joined
+ %1$s was added by %2$s
+ %1$s left
+ %1$s was removed by %2$s
+ %1$s set the topic to \"%2$s\"
+ %1$s cleared the topic
+ %1$s set the purpose to \"%2$s\"
+ %1$s cleared the purpose
+ %1$s made this channel open — anyone can find and join it
+ %1$s made this channel private — invite only
+ %1$s changed the visibility to %2$s
+ %1$s set messages to disappear after %2$s
+ %1$s turned off disappearing messages
+ %1$s archived this channel
+ %1$s restored this channel
+ %1$s created this channel
+ %1$s deleted this channel
+ %1$s deleted a message
+ %1$s deleted a message: %2$s
+ %1$s started this conversation
+
+ %1$s: %2$s
+
+
+ 🔊 %1$s started a huddle
+ 🔊 %1$s joined the huddle
+ 🔊 %1$s left the huddle
+ 🔊 Huddle ended
+
+
+ ⚙ %1$s requested a job
+ ⚙ %1$s accepted the job
+ ⚙ Job progress
+ ⚙ Job result
+ ⚙ Job cancelled
+ ⚠ Job error
+
+
+ ▲ %1$s upvoted a post
+ ▼ %1$s downvoted a post
Canvas
No canvas has been shared in this workspace yet.
Edit canvas
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt
index 4d7021dcd7..7bdcdf8333 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt
@@ -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? = 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"
}
}
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt
index dd1c247b03..9f7284f6b7 100644
--- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt
@@ -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)
+ }
}