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))
+ }
+}