fix(nip17): treat text-less renames as system rows in grouping, previews and notifications

A rename that carries only a subject now renders as a system line, but the
rest of the app still treated it as a regular message:

- Grouping: the author's next message joined the rename's run, so it lost
  its author line in groups and drew a squared top corner against the
  pill. Renames no longer join author runs.
- Room list: the preview read "You: " or nothing. It now says who renamed
  the conversation and to what (ChatPreview.SubjectChange).
- Notifications: a received rename posted an empty body. It now reads
  "Renamed the conversation to X".
- The feed gives rename rows their own LazyColumn content type, so slots
  aren't recycled between the pill and the bubble layout.

The predicate moves to commons (isSubjectOnlyChatMessage) so all of these
share it.

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 23:22:57 +00:00
parent 79bb6d0651
commit 0d00722553
10 changed files with 193 additions and 15 deletions
@@ -24,7 +24,11 @@ import android.content.Context
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.privateChats.isSubjectOnlyChatMessage
import com.vitorpamplona.amethyst.commons.notifications.NotificationContent
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.chat_notification_renamed_conversation_to
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
@@ -33,6 +37,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyA
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
@@ -94,7 +99,13 @@ object DirectMessageNotification {
if (decrypt) {
NotificationContent.decryptContent(chatNote, account.signer) ?: return
} else {
chatNote.event?.content ?: return
val event = chatNote.event ?: return
// A rename with no text would post an empty notification; say what it did instead.
if (event.isSubjectOnlyChatMessage()) {
loadStringRes(Res.string.chat_notification_renamed_conversation_to, event.subject() ?: "")
} else {
event.content
}
}
val accountNpub = NotificationRoutes.accountNpub(account)
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.model.privateChats
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateReplaceableTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
@@ -58,6 +60,16 @@ fun Event.hasEncryptedContent(): Boolean =
else -> false
}
/**
* A NIP-17 message that only renames the conversation: it carries a `subject` tag and no text.
* It has nothing to show as a message body, so the feed, the room list and the notification each
* narrate it as a rename instead. A rename sent with an explanation is a regular message.
*
* Checks the content first: almost every message has text, and `isBlank` stops at its first
* non-space character, so the tag scan only runs for the rare empty one.
*/
fun Event.isSubjectOnlyChatMessage(): Boolean = this is ChatMessageEvent && content.isBlank() && subject() != null
/**
* What a chat row should render for a message, once the raw ciphertext is off the table.
*
@@ -70,6 +82,11 @@ sealed interface ChatPreview {
val text: String,
) : ChatPreview
/** A message that only renamed the conversation ([isSubjectOnlyChatMessage]) to [subject]. */
data class SubjectChange(
val subject: String,
) : ChatPreview
/** Encrypted, decryptable by this account, plaintext not available yet. */
data object Decrypting : ChatPreview
@@ -97,6 +114,8 @@ fun chatPreviewOf(
): ChatPreview {
if (event == null) return ChatPreview.Missing
if (event.isSubjectOnlyChatMessage()) return ChatPreview.SubjectChange(event.subject() ?: "")
if (!event.hasEncryptedContent()) return ChatPreview.Body(decrypted ?: event.content)
// Never trust `event.content` from here down: it is ciphertext.
@@ -59,6 +59,50 @@ class ChatPreviewTest {
sig = someSig,
)
private fun nip17Rename(content: String = "") =
ChatMessageEvent(
id = "rn".padEnd(64, '0'),
pubKey = other,
createdAt = 1_000,
tags = arrayOf(arrayOf("p", me), arrayOf("subject", "Weekend plans")),
content = content,
sig = someSig,
)
// ---- subject-only renames ------------------------------------------------
@Test
fun renameWithoutTextIsSubjectOnly() {
assertTrue(nip17Rename().isSubjectOnlyChatMessage())
assertTrue(nip17Rename(content = " \n").isSubjectOnlyChatMessage())
}
@Test
fun renameWithTextIsARegularMessage() {
assertFalse(nip17Rename(content = "new goals").isSubjectOnlyChatMessage())
}
@Test
fun messageWithoutSubjectIsNotARename() {
assertFalse(nip17Rumor().isSubjectOnlyChatMessage())
}
@Test
fun renamePreviewNamesTheNewSubject() {
assertEquals(
ChatPreview.SubjectChange("Weekend plans"),
chatPreviewOf(nip17Rename(), decrypted = null, myPubKey = me, canDecrypt = true),
)
}
@Test
fun renameWithTextPreviewsTheText() {
assertEquals(
ChatPreview.Body("new goals"),
chatPreviewOf(nip17Rename(content = "new goals"), decrypted = null, myPubKey = me, canDecrypt = true),
)
}
// ---- hasEncryptedContent -------------------------------------------------
@Test
@@ -2327,6 +2327,7 @@
<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="chat_notification_renamed_conversation_to">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>
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.privateChats.isSubjectOnlyChatMessage
import com.vitorpamplona.amethyst.commons.ui.note.dateFormatter
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMe
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeBottom
@@ -92,7 +93,8 @@ const val CHAT_GROUP_WINDOW_SECONDS = 10 * 60L
/**
* Event kinds that don't render as regular bubbles (zaps, raids, clips) or that
* read as system messages (channel admin events) never join an author run.
* read as system messages (channel admin events, NIP-17 renames) never join an
* author run.
*/
private fun isGroupableEvent(event: Event?): Boolean =
event != null &&
@@ -100,7 +102,8 @@ private fun isGroupableEvent(event: Event?): Boolean =
event !is LiveActivitiesRaidEvent &&
event !is LiveActivitiesClipEvent &&
event !is ChannelCreateEvent &&
event !is ChannelMetadataEvent
event !is ChannelMetadataEvent &&
!event.isSubjectOnlyChatMessage()
/**
* Whether [newer] continues the author run started by [older]. Mirrors the break
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.composer.DraftTagState
import com.vitorpamplona.amethyst.commons.model.privateChats.isSubjectOnlyChatMessage
import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError
@@ -55,6 +56,19 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import kotlinx.coroutines.launch
private const val SUBJECT_ONLY_CONTENT_TYPE = -2
/**
* The lazy-list content type of a chat row: its event kind, except that a NIP-17 rename with no
* text gets its own type. It renders as a system pill rather than a bubble, so letting it share
* kind 14's slot with ordinary messages would make the list rebuild one layout into the other
* whenever a recycled slot crossed between the two.
*/
private fun chatRowContentType(item: Note): Int {
val event = item.event ?: return -1
return if (event.isSubjectOnlyChatMessage()) SUBJECT_ONLY_CONTENT_TYPE else event.kind
}
/**
* A caller's own rendering for feed rows that are not chat bubbles.
*
@@ -245,7 +259,7 @@ fun ChatFeedLoaded(
reverseLayout = true,
state = listState,
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { index, item ->
itemsIndexed(items.list, key = { _, item -> item.idHex }, contentType = { _, item -> chatRowContentType(item) }) { index, item ->
val noteEvent = item.event
if (avoidDraft == null || noteEvent !is DraftWrapEvent || noteEvent.dTag() !in avoidDraft.usedDraftTags) {
// Reverse layout: index - 1 is the newer message (visually below),
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.chats.ui.ChatGroupPosition
import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiCount
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.model.privateChats.isSubjectOnlyChatMessage
import com.vitorpamplona.amethyst.commons.notifications.NotificationContent
import com.vitorpamplona.amethyst.commons.ui.components.LocalInlineQuoteRenderer
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -79,7 +80,6 @@ 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
@@ -180,7 +180,7 @@ 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)) {
} else if (event?.isSubjectOnlyChatMessage() == true) {
// 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)) {
@@ -25,6 +25,7 @@ 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.model.privateChats.isSubjectOnlyChatMessage
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
@@ -36,20 +37,13 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatTime
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.
* conversation" with Alice's avatar — under the subject divider that carries the new name. The
* divider alone left an empty bubble below it whose only content was the time.
*
* 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.
@@ -92,6 +92,8 @@ import com.vitorpamplona.amethyst.commons.resources.channel_invite_row_added_you
import com.vitorpamplona.amethyst.commons.resources.channel_invite_row_added_you_by
import com.vitorpamplona.amethyst.commons.resources.chat_preview_decrypting
import com.vitorpamplona.amethyst.commons.resources.chat_preview_you_prefix
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.concord_direct_invite_from
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_label
import com.vitorpamplona.amethyst.commons.resources.concord_home_title
@@ -1325,6 +1327,15 @@ private fun RowScope.LastMessagePreview(
val sentByMe = lastMessage.author?.pubkeyHex == accountViewModel.account.signer.pubKey
if (sentByMe) stringRes(Res.string.chat_preview_you_prefix, preview.text) else preview.text
}
is ChatPreview.SubjectChange -> {
// A rename with no text has no body to quote; say what it did instead of "You: ".
val authorHex = lastMessage.event?.pubKey
if (authorHex == accountViewModel.account.signer.pubKey) {
stringRes(Res.string.chat_system_renamed_conversation_to_you, preview.subject)
} else {
stringRes(Res.string.chat_system_renamed_conversation_to, observeUserNameByHex(authorHex, accountViewModel), preview.subject)
}
}
ChatPreview.Decrypting -> stringRes(Res.string.chat_preview_decrypting)
ChatPreview.Undecryptable -> stringRes(Res.string.could_not_decrypt_the_message)
ChatPreview.Missing -> stringRes(Res.string.referenced_event_not_found)
@@ -0,0 +1,81 @@
/*
* 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.chats.ui
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.UserContext
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* A NIP-17 rename with no text renders as a system line, not a bubble, so it must not join the
* author run of the bubbles around it — otherwise the author's next message is drawn as the
* continuation of a bubble that isn't there: no author line, and a squared top corner.
*/
class ChatGroupPositionTest {
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private val context = UserContext { addr -> AddressableNote(addr) }
private fun message(
id: Char,
createdAt: Long,
content: String,
subject: String? = null,
): Note {
val tags = listOfNotNull(arrayOf("p", bob), subject?.let { arrayOf("subject", it) }).toTypedArray()
val event = ChatMessageEvent(id.toString().repeat(64), alice, createdAt, tags, content, "")
return Note(event.id).also { it.loadEvent(event, User(alice, context), emptyList()) }
}
private val start = 1_800_000_000L
@Test
fun messageAfterARenameStartsANewRun() {
val rename = message('1', start, content = "", subject = "Weekend plans")
val next = message('2', start + 30, content = "hi all")
val after = message('3', start + 60, content = "who's in?")
assertEquals(ChatGroupPosition.SINGLE, computeChatGroupPosition(next, rename, null))
assertEquals(ChatGroupPosition.TOP, computeChatGroupPosition(after, next, rename))
}
@Test
fun messageBeforeARenameEndsItsRun() {
val first = message('1', start, content = "hey")
val before = message('2', start + 30, content = "one sec")
val rename = message('3', start + 60, content = "", subject = "Weekend plans")
assertEquals(ChatGroupPosition.BOTTOM, computeChatGroupPosition(rename, before, first))
}
@Test
fun renameWithTextStillLeadsTheRunAfterIt() {
val rename = message('1', start, content = "new goals", subject = "Weekend plans")
val next = message('2', start + 30, content = "hi all")
assertEquals(ChatGroupPosition.TOP, computeChatGroupPosition(next, rename, null))
assertEquals(ChatGroupPosition.BOTTOM, computeChatGroupPosition(null, next, rename))
}
}