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 1ec51debdf..d13da0213e 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2323,6 +2323,11 @@ %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 + 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/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/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 0edc893ad8..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 @@ -67,6 +68,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 @@ -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 (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)) { // 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..4e25640584 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderChatSubjectChange.kt @@ -0,0 +1,98 @@ +/* + * 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.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 +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.nip14Subject.subject +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent + +/** + * 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. 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. + * + * 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) } + }, + ) +} 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)) + } +}