From 0d00722553af5465e61aa1dc52828f73cd905d11 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 23:22:57 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TMJHLxbswamX91WDaPT2uR --- .../renderers/DirectMessageNotification.kt | 13 ++- .../commons/model/privateChats/ChatPreview.kt | 19 +++++ .../model/privateChats/ChatPreviewTest.kt | 44 ++++++++++ .../composeResources/values/strings.xml | 1 + .../commons/chats/ui/ChatGroupPosition.kt | 7 +- .../loggedIn/chats/feed/ChatFeedView.kt | 16 +++- .../loggedIn/chats/feed/ChatMessageCompose.kt | 4 +- .../feed/types/RenderChatSubjectChange.kt | 12 +-- .../chats/rooms/ChatroomHeaderCompose.kt | 11 +++ .../commons/chats/ui/ChatGroupPositionTest.kt | 81 +++++++++++++++++++ 10 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatGroupPositionTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt index 1b82cf7a04..fe05cfbfbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt @@ -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) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreview.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreview.kt index 55d278e140..2041695d4b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreview.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreview.kt @@ -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. diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTest.kt index b831f2d452..517bbfb430 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTest.kt @@ -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 diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index c1c2aad150..d13da0213e 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2327,6 +2327,7 @@ You renamed the conversation %1$s renamed the conversation to %2$s You renamed the conversation to %1$s + 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/ChatGroupPosition.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatGroupPosition.kt index b6698b6e7d..8a4f679db7 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatGroupPosition.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatGroupPosition.kt @@ -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 diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index aea25619aa..eb0b18e505 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -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), 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 eb05553806..fedecb173d 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 @@ -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)) { 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 index 9594cd4d5e..4e25640584 100644 --- 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 @@ -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. diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 346b8f7c43..80ba1ce955 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -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) diff --git a/commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatGroupPositionTest.kt b/commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatGroupPositionTest.kt new file mode 100644 index 0000000000..034c124e6a --- /dev/null +++ b/commonsUI/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatGroupPositionTest.kt @@ -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)) + } +}