mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge pull request #3727 from vitorpamplona/feat/buzz-system-message-sentences
feat(buzz): say who joined and what changed in the kind-40099 system lines
This commit is contained in:
+3
-3
@@ -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,
|
||||
|
||||
+33
-3
@@ -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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+67
-51
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+207
@@ -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
|
||||
}
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
|
||||
@@ -3447,6 +3447,48 @@
|
||||
<string name="chat_system_updated_channel">%1$s updated the channel profile</string>
|
||||
<string name="buzz_message_edited">(edited)</string>
|
||||
<string name="buzz_diff_truncated">(diff truncated)</string>
|
||||
|
||||
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
|
||||
<string name="buzz_system_member_joined">%1$s joined</string>
|
||||
<string name="buzz_system_member_added">%1$s was added by %2$s</string>
|
||||
<string name="buzz_system_member_left">%1$s left</string>
|
||||
<string name="buzz_system_member_removed">%1$s was removed by %2$s</string>
|
||||
<string name="buzz_system_topic_changed">%1$s set the topic to \"%2$s\"</string>
|
||||
<string name="buzz_system_topic_cleared">%1$s cleared the topic</string>
|
||||
<string name="buzz_system_purpose_changed">%1$s set the purpose to \"%2$s\"</string>
|
||||
<string name="buzz_system_purpose_cleared">%1$s cleared the purpose</string>
|
||||
<string name="buzz_system_visibility_open">%1$s made this channel open — anyone can find and join it</string>
|
||||
<string name="buzz_system_visibility_private">%1$s made this channel private — invite only</string>
|
||||
<string name="buzz_system_visibility_other">%1$s changed the visibility to %2$s</string>
|
||||
<string name="buzz_system_ttl_set">%1$s set messages to disappear after %2$s</string>
|
||||
<string name="buzz_system_ttl_cleared">%1$s turned off disappearing messages</string>
|
||||
<string name="buzz_system_channel_archived">%1$s archived this channel</string>
|
||||
<string name="buzz_system_channel_unarchived">%1$s restored this channel</string>
|
||||
<string name="buzz_system_channel_created">%1$s created this channel</string>
|
||||
<string name="buzz_system_channel_deleted">%1$s deleted this channel</string>
|
||||
<string name="buzz_system_message_deleted">%1$s deleted a message</string>
|
||||
<string name="buzz_system_message_deleted_reason">%1$s deleted a message: %2$s</string>
|
||||
<string name="buzz_system_dm_created">%1$s started this conversation</string>
|
||||
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
|
||||
<string name="buzz_system_unknown">%1$s: %2$s</string>
|
||||
|
||||
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
|
||||
<string name="buzz_huddle_started">🔊 %1$s started a huddle</string>
|
||||
<string name="buzz_huddle_joined">🔊 %1$s joined the huddle</string>
|
||||
<string name="buzz_huddle_left">🔊 %1$s left the huddle</string>
|
||||
<string name="buzz_huddle_ended">🔊 Huddle ended</string>
|
||||
|
||||
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
|
||||
<string name="buzz_job_requested">⚙ %1$s requested a job</string>
|
||||
<string name="buzz_job_accepted">⚙ %1$s accepted the job</string>
|
||||
<string name="buzz_job_progress">⚙ Job progress</string>
|
||||
<string name="buzz_job_result">⚙ Job result</string>
|
||||
<string name="buzz_job_cancelled">⚙ Job cancelled</string>
|
||||
<string name="buzz_job_error">⚠ Job error</string>
|
||||
|
||||
<!-- Buzz forum votes (kind 45002). -->
|
||||
<string name="buzz_forum_upvoted">▲ %1$s upvoted a post</string>
|
||||
<string name="buzz_forum_downvoted">▼ %1$s downvoted a post</string>
|
||||
<string name="buzz_canvas_title">Canvas</string>
|
||||
<string name="buzz_canvas_empty">No canvas has been shared in this workspace yet.</string>
|
||||
<string name="buzz_canvas_edit">Edit canvas</string>
|
||||
|
||||
+60
-5
@@ -31,11 +31,26 @@ import kotlinx.serialization.json.Json
|
||||
* record of a channel state change (join, leave, rename, archive, delete, ...).
|
||||
*
|
||||
* Field names mirror the `serde_json::json!` payloads emitted by `emit_system_message`
|
||||
* and its callers in Buzz's `buzz-relay/src/handlers/side_effects.rs`. Only [type] is
|
||||
* always present; [actor] is present on almost every variant, and the remaining fields
|
||||
* are variant-specific (`member_joined`/`member_removed` carry [target], `topic_changed`
|
||||
* carries [topic], `ttl_changed` carries [ttlSeconds], etc). Unknown keys are ignored
|
||||
* for forward compatibility.
|
||||
* and its callers in Buzz's `crates/buzz-relay/src/handlers/`. Only [type] is always
|
||||
* present; [actor] is present on every variant the relay emits today, and the rest are
|
||||
* variant-specific. Unknown keys are ignored for forward compatibility, so a relay that
|
||||
* grows a new field or a new [type] degrades to a plain line instead of failing to parse.
|
||||
*
|
||||
* The complete vocabulary, read off `side_effects.rs` / `command_executor.rs`:
|
||||
*
|
||||
* | [type] | carries | emitted when |
|
||||
* |--|--|--|
|
||||
* | [MEMBER_JOINED] | [actor], [target] | someone was added ([actor] added [target]) or joined on their own ([actor] == [target]) |
|
||||
* | [MEMBER_LEFT] | [actor], optional [target] | a member left; the explicit-leave path omits [target] |
|
||||
* | [MEMBER_REMOVED] | [actor], [target] | [actor] removed [target] |
|
||||
* | [TOPIC_CHANGED] | [actor], [topic] | the channel topic was set |
|
||||
* | [PURPOSE_CHANGED] | [actor], [purpose] | the channel purpose was set |
|
||||
* | [VISIBILITY_CHANGED] | [actor], [visibility] | flipped between [VISIBILITY_OPEN] and [VISIBILITY_PRIVATE] |
|
||||
* | [TTL_CHANGED] | [actor], [ttlSeconds] | disappearing messages set; `null` [ttlSeconds] means cleared (permanent) |
|
||||
* | [CHANNEL_ARCHIVED] / [CHANNEL_UNARCHIVED] | [actor] | archive flag flipped |
|
||||
* | [CHANNEL_CREATED] / [CHANNEL_DELETED] | [actor] | channel lifecycle |
|
||||
* | [MESSAGE_DELETED] | [actor], [targetEventId], optional [actionId] / [reasonCode] / [publicReason] | a message was deleted (moderation tombstone) |
|
||||
* | [DM_CREATED] | [actor], [participants] | a DM channel was opened |
|
||||
*/
|
||||
@Serializable
|
||||
data class SystemMessagePayload(
|
||||
@@ -46,9 +61,29 @@ data class SystemMessagePayload(
|
||||
val purpose: String? = null,
|
||||
val visibility: String? = null,
|
||||
@SerialName("ttl_seconds") val ttlSeconds: Long? = null,
|
||||
/** The deleted message's id on [MESSAGE_DELETED]. */
|
||||
@SerialName("target_event_id") val targetEventId: String? = null,
|
||||
/** Moderation-action id linking the tombstone back to the action that caused it. */
|
||||
@SerialName("action_id") val actionId: String? = null,
|
||||
/** Machine-readable moderation reason (e.g. `spam`), when the deleter gave one. */
|
||||
@SerialName("reason_code") val reasonCode: String? = null,
|
||||
/** Human-readable moderation reason the relay is willing to show everyone. */
|
||||
@SerialName("public_reason") val publicReason: String? = null,
|
||||
/** Every participant of a newly opened DM, [actor] included. */
|
||||
val participants: List<String>? = null,
|
||||
) {
|
||||
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||
|
||||
/**
|
||||
* The pubkey the sentence is *about* — whose avatar to show. Membership changes are about the
|
||||
* member who joined/left/was removed; everything else is about whoever performed the action.
|
||||
*/
|
||||
fun subject(): String? =
|
||||
when (type) {
|
||||
MEMBER_JOINED, MEMBER_LEFT, MEMBER_REMOVED -> target ?: actor
|
||||
else -> actor
|
||||
}
|
||||
|
||||
companion object {
|
||||
val JSON =
|
||||
Json {
|
||||
@@ -58,5 +93,25 @@ data class SystemMessagePayload(
|
||||
}
|
||||
|
||||
fun decodeFromJson(json: String): SystemMessagePayload = JSON.decodeFromString(json)
|
||||
|
||||
const val MEMBER_JOINED = "member_joined"
|
||||
const val MEMBER_LEFT = "member_left"
|
||||
const val MEMBER_REMOVED = "member_removed"
|
||||
const val TOPIC_CHANGED = "topic_changed"
|
||||
const val PURPOSE_CHANGED = "purpose_changed"
|
||||
const val VISIBILITY_CHANGED = "visibility_changed"
|
||||
const val TTL_CHANGED = "ttl_changed"
|
||||
const val CHANNEL_ARCHIVED = "channel_archived"
|
||||
const val CHANNEL_UNARCHIVED = "channel_unarchived"
|
||||
const val CHANNEL_CREATED = "channel_created"
|
||||
const val CHANNEL_DELETED = "channel_deleted"
|
||||
const val MESSAGE_DELETED = "message_deleted"
|
||||
const val DM_CREATED = "dm_created"
|
||||
|
||||
/** Searchable, anyone can join. */
|
||||
const val VISIBILITY_OPEN = "open"
|
||||
|
||||
/** Hidden, invite-only. */
|
||||
const val VISIBILITY_PRIVATE = "private"
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user