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) + } }