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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMJHLxbswamX91WDaPT2uR
This commit is contained in:
Claude
2026-10-01 22:36:18 +00:00
parent ea139b964b
commit 79bb6d0651
4 changed files with 141 additions and 5 deletions
@@ -2323,6 +2323,10 @@
<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_system_renamed_conversation">%1$s renamed the conversation</string>
<string name="chat_system_renamed_conversation_you">You renamed the conversation</string>
<string name="chat_system_renamed_conversation_to">%1$s renamed the conversation to %2$s</string>
<string name="chat_system_renamed_conversation_to_you">You renamed the conversation to %1$s</string>
<string name="buzz_message_edited">(edited)</string>
<string name="buzz_diff_truncated">(diff truncated)</string>
<string name="buzz_system_member_joined">%1$s joined</string>
@@ -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) },
)
}
}
@@ -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.
@@ -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) }
},
)
}