From 8a6b6589846f3e7e02d2fd39e5ff08d4e87de332 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 14:04:03 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX --- .../loggedIn/chats/feed/ChatMessageCompose.kt | 13 +-- .../chats/feed/layouts/ChatSystemMessage.kt | 94 +++++++++++++++++++ .../feed/types/RenderChannelAdminNote.kt | 87 +++++++++++++++++ amethyst/src/main/res/values/strings.xml | 4 + 4 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChannelAdminNote.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 2ae14f5087..f7df0334d4 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 @@ -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) 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 new file mode 100644 index 0000000000..79fbfd5f12 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatSystemMessage.kt @@ -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") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChannelAdminNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChannelAdminNote.kt new file mode 100644 index 0000000000..b483f436aa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChannelAdminNote.kt @@ -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() +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fe644627bb..c10d2fc861 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2818,6 +2818,10 @@ Add content warning Remove content warning + %1$s created the channel %2$s + %1$s created the channel + %1$s updated the channel profile + Waiting for a relay to accept this message Accepted by at least one relay Delivered to all recipients\' relays