mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #4304 from vitorpamplona/claude/nice-clarke-q9p5kp
Add NIP-17 group chat subject rename system message
This commit is contained in:
+12
-1
@@ -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)
|
||||
|
||||
+19
@@ -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.
|
||||
|
||||
+44
@@ -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
|
||||
|
||||
@@ -2323,6 +2323,11 @@
|
||||
<string name="chat_system_created_channel">%1$s created the channel %2$s</string>
|
||||
<string name="chat_system_created_channel_unnamed">%1$s created the channel</string>
|
||||
<string name="chat_system_updated_channel">%1$s updated the channel profile</string>
|
||||
<string name="chat_system_renamed_conversation">%1$s renamed the conversation</string>
|
||||
<string name="chat_system_renamed_conversation_you">You renamed the conversation</string>
|
||||
<string name="chat_system_renamed_conversation_to">%1$s renamed the conversation to %2$s</string>
|
||||
<string name="chat_system_renamed_conversation_to_you">You renamed the conversation to %1$s</string>
|
||||
<string name="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>
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
+28
-5
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -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),
|
||||
|
||||
+5
@@ -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.
|
||||
|
||||
+98
@@ -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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
+11
@@ -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)
|
||||
|
||||
+81
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user