From 79bb6d0651c9cf447e99bd009acb7c056b167368 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 22:36:18 +0000 Subject: [PATCH] feat(nip17): render subject-only renames as a system line instead of an empty bubble A NIP-17 message that only sets a subject (no text) drew an empty bubble under the subject divider, whose only content was the time and delivery ticks. Render it as a centered system pill instead: the renamer's avatar, "Alice renamed the conversation" / "You renamed the conversation", and the tappable time with delivery ticks. Inside a reply quote, where no divider names the subject, the sentence includes the new name. ChatSystemMessage gains a trailing slot for the time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TMJHLxbswamX91WDaPT2uR --- .../composeResources/values/strings.xml | 4 + .../commons/chats/ui/ChatSystemMessage.kt | 33 +++++- .../loggedIn/chats/feed/ChatMessageCompose.kt | 5 + .../feed/types/RenderChatSubjectChange.kt | 104 ++++++++++++++++++ 4 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChatSubjectChange.kt diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 1ec51debdf..c1c2aad150 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2323,6 +2323,10 @@ %1$s created the channel %2$s %1$s created the channel %1$s updated the channel profile + %1$s renamed the conversation + You renamed the conversation + %1$s renamed the conversation to %2$s + You renamed the conversation to %1$s (edited) (diff truncated) %1$s joined diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatSystemMessage.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatSystemMessage.kt index cc9edb2952..dc6d971f37 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatSystemMessage.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatSystemMessage.kt @@ -54,12 +54,17 @@ import com.vitorpamplona.amethyst.commons.ui.theme.grayText * [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. + * + * [trailing] is an optional slot rendered inside the pill, after the text — used by + * system lines that are real messages (a NIP-17 rename) to keep their time and + * delivery ticks without wrapping them in an otherwise empty bubble. */ @Composable fun ChatSystemMessage( text: String, onClick: (() -> Unit)? = null, leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, ) { Row( modifier = @@ -78,16 +83,27 @@ fun ChatSystemMessage( Modifier }, ) { - if (leading == null) { + if (leading == null && trailing == null) { SystemMessageText(text) } else { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(start = 8.dp), + modifier = + Modifier.padding( + start = if (leading != null) 8.dp else 0.dp, + end = if (trailing != null) 10.dp else 0.dp, + ), ) { - leading() - SystemMessageText(text, startPadding = 0.dp) + leading?.invoke() + SystemMessageText( + text = text, + startPadding = if (leading != null) 0.dp else 12.dp, + endPadding = if (trailing != null) 0.dp else 12.dp, + // Lets a long sentence wrap instead of pushing the trailing slot out of the pill. + modifier = Modifier.weight(1f, fill = false), + ) + trailing?.invoke() } } } @@ -98,13 +114,15 @@ fun ChatSystemMessage( private fun SystemMessageText( text: String, startPadding: Dp = 12.dp, + endPadding: Dp = 12.dp, + modifier: Modifier = Modifier, ) { Text( text = text, fontSize = Font12SP, color = MaterialTheme.colorScheme.grayText, textAlign = TextAlign.Center, - modifier = Modifier.padding(start = startPadding, end = 12.dp, top = 5.dp, bottom = 5.dp), + modifier = modifier.padding(start = startPadding, end = endPadding, top = 5.dp, bottom = 5.dp), ) } @@ -118,5 +136,10 @@ private fun ChatSystemMessagePreview() { "Bob was added by Alice", leading = { Box(Modifier.size(Size18dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) }, ) + ChatSystemMessage( + "You renamed the conversation", + leading = { Box(Modifier.size(Size18dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) }, + trailing = { Text("10:42", fontSize = Font12SP, color = MaterialTheme.colorScheme.grayText) }, + ) } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 0edc893ad8..eb05553806 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.Re import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderChannelAdminSystemMessage import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderChatClip import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderChatRaid +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderChatSubjectChange import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderChatZap import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderConcordTimerNotice import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent @@ -78,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.Re import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2 import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.hasMip04Media import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.isSubjectOnlyChatMessage import com.vitorpamplona.amethyst.commons.ui.theme.ReactionRowZapraiser import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel @@ -178,6 +180,9 @@ fun ChatroomMessageCompose( } else if (event is ConcordTimerNoticeEvent) { // Concord kind-1740: "Alice set disappearing messages to 30 days" (CORD-08 §4). RenderConcordTimerNotice(baseNote, accountViewModel, nav) + } else if (isSubjectOnlyChatMessage(event)) { + // NIP-17 rename with no text: a system line under the subject divider, not an empty bubble. + RenderChatSubjectChange(baseNote, innerQuote, accountViewModel, nav) } 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. diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChatSubjectChange.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChatSubjectChange.kt new file mode 100644 index 0000000000..9594cd4d5e --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChatSubjectChange.kt @@ -0,0 +1,104 @@ +/* + * 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.commons.ui.screen.loggedIn.chats.feed.types + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.chats.ui.ChatSystemMessage +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.chat_system_renamed_conversation +import com.vitorpamplona.amethyst.commons.resources.chat_system_renamed_conversation_to +import com.vitorpamplona.amethyst.commons.resources.chat_system_renamed_conversation_to_you +import com.vitorpamplona.amethyst.commons.resources.chat_system_renamed_conversation_you +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.UserPicture +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatTimeWithDelivery +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.Size18dp +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent + +/** + * A NIP-17 message that only renames the conversation: it carries a `subject` tag and no text. + * The room's subject divider already announces the new name above it, so drawing it as a bubble + * left an empty balloon under the divider whose only content was the time. + */ +fun isSubjectOnlyChatMessage(event: Event?): Boolean = event is ChatMessageEvent && event.content.isBlank() && event.subject() != null + +/** + * A rename ([isSubjectOnlyChatMessage]) as a centered system line — "Alice renamed the + * conversation" with Alice's avatar — under the subject divider that carries the new name. + * + * Unlike the other system lines this one is a real message with a sender and a delivery, so the + * pill keeps the bubble's tappable time and, on our own renames, its relay-acceptance ticks. + * + * Inside a reply quote there is no divider to name the subject, so the sentence includes it and + * the time is dropped, as on any quoted message. + */ +@Composable +fun RenderChatSubjectChange( + note: Note, + innerQuote: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? ChatMessageEvent ?: return + val isLoggedInUser = remember(event.pubKey) { accountViewModel.isLoggedUser(event.pubKey) } + + val text = + if (innerQuote) { + val subject = remember(event) { event.subject() ?: "" } + if (isLoggedInUser) { + stringRes(Res.string.chat_system_renamed_conversation_to_you, subject) + } else { + stringRes(Res.string.chat_system_renamed_conversation_to, observeUserNameByHex(event.pubKey, accountViewModel), subject) + } + } else { + if (isLoggedInUser) { + stringRes(Res.string.chat_system_renamed_conversation_you) + } else { + stringRes(Res.string.chat_system_renamed_conversation, observeUserNameByHex(event.pubKey, accountViewModel)) + } + } + + ChatSystemMessage( + text = text, + onClick = { nav.nav(Route.Profile(event.pubKey)) }, + leading = { + UserPicture( + userHex = event.pubKey, + size = Size18dp, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + trailing = + if (innerQuote) { + null + } else { + { ChatTimeWithDelivery(note, isLoggedInUser, accountViewModel, nav) } + }, + ) +}