From 8df98c34df821c276385ce581e4aba8a92fb09ab Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 18:19:01 -0400 Subject: [PATCH] feat(buzz): say who joined and what changed in the kind-40099 system lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-chat markers read "member joined" / "visibility changed" — the relay's raw payload type with the underscores swapped for spaces. Everything that makes the line useful was parsed and then dropped: which member, who added them, and what the setting changed TO. Render the full sentence from the signed payload instead, with the pubkeys resolved to the names the viewer knows them by and the avatar of whoever the line is about: member joined -> "Shawn was added by straycat" (actor != target) -> "Vitor joined" (self-join, actor == target) member removed -> "Bob was removed by Alice" visibility changed -> "straycat made this channel open — anyone can find and join it" -> "... made this channel private — invite only" ttl changed -> "Alice set messages to disappear after 7 days" / turned off topic/purpose -> the new value, or "cleared the topic" when blank channel created/deleted/archived/restored, message deleted (+ public reason), dm created -> named after their actor The event is signed by the RELAY keypair, so the people come from the payload's actor/target, never from note.author. Membership lines are about the member, so that is whose avatar shows and whose profile the pill opens; everything else is about the actor. Also covers the neighbouring timeline narration, which had the same problem: huddle joins/leaves now name the participant from the `p` tag instead of "someone joined the huddle", job lines name the signer, and forum votes name the voter. All of these move from hardcoded English into string resources. Quartz's SystemMessagePayload was missing target_event_id, action_id, reason_code, public_reason and participants — every field of the moderation tombstone and the DM-created payload. The KDoc now carries the complete vocabulary read off the relay's emit_system_message callers, and the type strings are constants so the UI cannot typo a branch into dead code. An unknown type still renders as "alice: some_new_type" rather than vanishing, so a relay that grows new vocabulary stays legible. Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: the four "was added by" lines, "straycat created this channel", "Matthias Debernardini joined" and "straycat made this channel open" all render with the right subject avatar, in the chat and in the Messages-list preview. Co-Authored-By: Claude Opus 5 (1M context) --- .../loggedIn/chats/feed/ChatMessageCompose.kt | 6 +- .../chats/feed/layouts/ChatSystemMessage.kt | 36 ++- .../chats/feed/types/RenderBuzzNotes.kt | 118 +++++----- .../feed/types/RenderBuzzSystemMessage.kt | 207 ++++++++++++++++++ .../chats/rooms/ChatroomHeaderCompose.kt | 4 +- amethyst/src/main/res/values/strings.xml | 42 ++++ .../buzz/stream/SystemMessagePayload.kt | 65 +++++- .../buzz/stream/SystemMessageEventTest.kt | 73 ++++++ 8 files changed, 487 insertions(+), 64 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt 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) + } }