feat: render channel admin events as centered system messages

NIP-28 channel create (kind 40) and metadata update (kind 41) events
narrate the room rather than talk in it. They now render as a centered,
muted system pill - 'X created the channel Y' / 'X updated the channel
profile' - that taps through to the channel, instead of a full channel
profile card inside a regular user bubble (chat design best practice:
system messages should be visually distinct from user messages).

The old card renderers (RenderCreateChannelNote / RenderChannelData)
stay in the tree but are no longer referenced by the chat feed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
This commit is contained in:
Claude
2026-07-11 14:04:03 +00:00
parent 5abb89435d
commit 8a6b658984
4 changed files with 189 additions and 9 deletions
@@ -46,7 +46,6 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.DisplayDraftChat
import com.vitorpamplona.amethyst.ui.note.LikeReaction
@@ -62,11 +61,10 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChangeChannelMetadataNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChannelAdminSystemMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderCreateChannelNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia
@@ -138,6 +136,8 @@ fun ChatroomMessageCompose(
RenderChatRaid(baseNote, accountViewModel, nav)
} else if (event is LiveActivitiesClipEvent) {
RenderChatClip(baseNote, accountViewModel, nav)
} else if (event is ChannelCreateEvent || event is ChannelMetadataEvent) {
RenderChannelAdminSystemMessage(baseNote, accountViewModel, nav)
} else {
NormalChatNote(
baseNote,
@@ -227,10 +227,7 @@ fun NormalChatNote(
shouldHighlight = shouldHighlight,
onHighlightFinished = onHighlightFinished,
onClick = {
if (note.event is ChannelCreateEvent) {
nav.nav(Route.PublicChatChannel(note.idHex))
true
} else if (innerQuote && onScrollToNote != null) {
if (innerQuote && onScrollToNote != null) {
onScrollToNote(note)
true
} else {
@@ -516,8 +513,6 @@ fun NoteRow(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
when {
note.event is ChannelCreateEvent -> RenderCreateChannelNote(note, bgColor, accountViewModel, nav)
note.event is ChannelMetadataEvent -> RenderChangeChannelMetadataNote(note, bgColor, accountViewModel, nav)
note.event is DraftWrapEvent -> RenderDraftEvent(note, canPreview, innerQuote, onWantsToReply, onWantsToEditDraft, bgColor, accountViewModel, nav)
note.event is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav)
hasMip04Media(note.event) -> RenderMarmotEncryptedMedia(note, bgColor, accountViewModel, nav)
@@ -0,0 +1,94 @@
/*
* 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.layouts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
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.ThemeComparisonColumn
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.
*/
@Composable
fun ChatSystemMessage(
text: String,
onClick: (() -> Unit)? = null,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.Center,
) {
if (onClick != null) {
Surface(
onClick = onClick,
shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
SystemMessageText(text)
}
} else {
Surface(
shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
SystemMessageText(text)
}
}
}
}
@Composable
private fun SystemMessageText(text: String) {
Text(
text = text,
fontSize = Font12SP,
color = MaterialTheme.colorScheme.grayText,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
)
}
@Preview
@Composable
private fun ChatSystemMessagePreview() {
ThemeComparisonColumn {
ChatSystemMessage("Alice created the channel Amethyst Users", onClick = {})
ChatSystemMessage("Alice updated the channel profile")
}
}
@@ -0,0 +1,87 @@
/*
* 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.getValue
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
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.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
/**
* Channel admin events (NIP-28 kind 40/41) narrate the room instead of talking in
* it, so they render as a centered system line — "X created the channel", "X
* updated the channel profile" — that taps through to the channel, instead of a
* full profile card inside a user bubble.
*/
@Composable
fun RenderChannelAdminSystemMessage(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
when (val noteEvent = note.event) {
is ChannelCreateEvent -> {
val authorName = watchAuthorName(note, accountViewModel)
val channelName = remember(noteEvent) { noteEvent.channelInfo().name }
ChatSystemMessage(
text =
if (channelName.isNullOrBlank()) {
stringRes(R.string.chat_system_created_channel_unnamed, authorName)
} else {
stringRes(R.string.chat_system_created_channel, authorName, channelName)
},
onClick = { nav.nav(Route.PublicChatChannel(note.idHex)) },
)
}
is ChannelMetadataEvent -> {
val authorName = watchAuthorName(note, accountViewModel)
val channelId = remember(noteEvent) { noteEvent.channelId() }
ChatSystemMessage(
text = stringRes(R.string.chat_system_updated_channel, authorName),
onClick = channelId?.let { { nav.nav(Route.PublicChatChannel(it)) } },
)
}
else -> {}
}
}
@Composable
private fun watchAuthorName(
note: Note,
accountViewModel: AccountViewModel,
): String {
val author = note.author ?: return note.event?.pubKey?.take(8) ?: ""
val userState by observeUserInfo(author, accountViewModel)
return userState?.info?.bestName() ?: author.pubkeyDisplayHex()
}
+4
View File
@@ -2818,6 +2818,10 @@
<string name="add_content_warning">Add content warning</string>
<string name="remove_content_warning">Remove content warning</string>
<string name="chat_system_created_channel">%1$s created the channel %2$s</string>
<string name="chat_system_created_channel_unnamed">%1$s created the channel</string>
<string name="chat_system_updated_channel">%1$s updated the channel profile</string>
<string name="chat_delivery_pending">Waiting for a relay to accept this message</string>
<string name="chat_delivery_accepted">Accepted by at least one relay</string>
<string name="chat_delivery_delivered_all">Delivered to all recipients\' relays</string>