diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt index f308bf5b82..e75f2371b5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.utils.Hex @@ -144,16 +145,19 @@ class ChatNewMessageState( } val replyHint = _replyTo.value?.toEventHint() + val subjectText = _subject.value.text.ifBlank { null } val template = if (replyHint == null) { ChatMessageEvent.build(messageText, pTags) { + subjectText?.let { changeSubject(it) } hashtags(findHashtags(messageText)) references(findURLs(messageText)) quotes(findNostrEventUris(messageText)) } } else { ChatMessageEvent.reply(messageText, replyHint) { + subjectText?.let { changeSubject(it) } hashtags(findHashtags(messageText)) references(findURLs(messageText)) quotes(findNostrEventUris(messageText)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index e6316d7172..fe9d78fa6d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -126,7 +126,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent @@ -1356,28 +1358,31 @@ fun MainContent( scope.launch { val innerEvent = event.unwrapAndUnsealOrNull(iAccount.signer) ?: return@launch when (innerEvent) { - is com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -> { - val innerNote = localCache.getOrCreateNote(innerEvent.id) - val innerAuthor = localCache.getOrCreateUser(innerEvent.pubKey) - if (innerNote.event == null) { - innerNote.loadEvent(innerEvent, innerAuthor, emptyList()) + // Any DM-group event (kind 14 text, kind 15 encrypted file, and any + // future NIP-17 variant) routes into the room by its participant set. + is ChatroomKeyable -> { + if (innerEvent.isIncluded(iAccount.pubKey)) { + val innerNote = localCache.getOrCreateNote(innerEvent.id) + val innerAuthor = localCache.getOrCreateUser(innerEvent.pubKey) + if (innerNote.event == null) { + innerNote.loadEvent(innerEvent, innerAuthor, emptyList()) + } + iAccount.chatroomList.addMessage( + innerEvent.chatroomKey(iAccount.pubKey), + innerNote, + ) } - iAccount.chatroomList.addMessage( - innerEvent.chatroomKey(iAccount.pubKey), - innerNote, - ) } - is com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent -> { - val innerNote = localCache.getOrCreateNote(innerEvent.id) - val innerAuthor = localCache.getOrCreateUser(innerEvent.pubKey) - if (innerNote.event == null) { - innerNote.loadEvent(innerEvent, innerAuthor, emptyList()) + // Self-authored NIP-37 draft wrapped to self. Store it so it isn't + // dropped; the desktop chat UI doesn't render drafts in the room feed + // yet, so it is intentionally not added to a chatroom. + is DraftWrapEvent -> { + val draftNote = localCache.getOrCreateNote(innerEvent.id) + val draftAuthor = localCache.getOrCreateUser(innerEvent.pubKey) + if (draftNote.event == null) { + draftNote.loadEvent(innerEvent, draftAuthor, emptyList()) } - iAccount.chatroomList.addMessage( - innerEvent.chatroomKey(iAccount.pubKey), - innerNote, - ) } is com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 09532abae1..c746d64487 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -39,9 +39,11 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface @@ -95,6 +97,8 @@ import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.ciphers.AESGCM import kotlinx.coroutines.launch @@ -188,11 +192,26 @@ fun ChatPane( val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) } val isGroup = users.size > 1 + // NIP-14 group subject/name, updated reactively as subject-tagged messages arrive. + val subjectFlow = remember(roomKey) { account.chatroomList.getOrCreatePrivateChatroom(roomKey).subject } + val subject by subjectFlow.collectAsState() + var showSubjectDialog by remember { mutableStateOf(false) } + // Load room into message state LaunchedEffect(roomKey) { messageState.load(roomKey) } + if (showSubjectDialog) { + GroupSubjectDialog( + roomKey = roomKey, + currentSubject = subject ?: "", + account = account, + cacheProvider = cacheProvider, + onClose = { showSubjectDialog = false }, + ) + } + Box(modifier = modifier.fillMaxSize()) { Column( modifier = @@ -230,6 +249,7 @@ fun ChatPane( if (isGroup) { GroupChatroomHeader( users = users, + subject = subject, onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } }, ) } else { @@ -248,6 +268,21 @@ fun ChatPane( } } } + + // Rename group (set NIP-14 subject) — groups only + if (isGroup) { + IconButton( + onClick = { showSubjectDialog = true }, + modifier = Modifier.size(40.dp), + ) { + Icon( + MaterialSymbols.Edit, + contentDescription = "Rename group", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } HorizontalDivider() @@ -874,3 +909,72 @@ private suspend fun sendEncryptedFiles( account.sendNip17EncryptedFile(template) } } + +/** + * Dialog to set or change a group's NIP-14 subject (name). Mirrors Android's + * NewChatroomSubjectDialog: it sends a normal NIP-17 message carrying a + * `subject` tag (plus an optional accompanying message) to every room member, + * so all participants pick up the new name. + */ +@Composable +private fun GroupSubjectDialog( + roomKey: ChatroomKey, + currentSubject: String, + account: IAccount, + cacheProvider: ICacheProvider, + onClose: () -> Unit, +) { + val scope = rememberCoroutineScope() + var groupName by remember { mutableStateOf(currentSubject) } + var message by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onClose, + title = { Text("Group name") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = groupName, + onValueChange = { groupName = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Subject") }, + placeholder = { Text("A name for this group") }, + singleLine = true, + ) + OutlinedTextField( + value = message, + onValueChange = { message = it }, + modifier = Modifier.fillMaxWidth().heightIn(min = 80.dp), + label = { Text("Message (optional)") }, + ) + } + }, + confirmButton = { + TextButton( + enabled = groupName.isNotBlank(), + onClick = { + scope.launch { + try { + val pTags = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it)?.toPTag() } + val template = + ChatMessageEvent.build(message, pTags) { + groupName.ifBlank { null }?.let { changeSubject(it) } + } + account.sendNip17PrivateMessage(template) + } catch (e: Exception) { + println("Failed to set group subject: ${e.message}") + } + } + onClose() + }, + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onClose) { + Text("Cancel") + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt index 760974048f..96a771e6d3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt @@ -91,16 +91,23 @@ fun ChatroomHeader( * Shared chatroom header for a group conversation. * Displays multiple user avatars and a combined room name. * + * When the group has a NIP-14 [subject] (group name), it is shown as the bold + * title with the participant list as a secondary line; otherwise the participant + * list is the title. + * * @param users List of users in the group conversation + * @param subject Optional NIP-14 group subject/name * @param modifier Layout modifier (defaults to standard padding) * @param onClick Called when the header is tapped */ @Composable fun GroupChatroomHeader( users: List, + subject: String? = null, modifier: Modifier = ChatStdPadding, onClick: () -> Unit, ) { + val participants = users.joinToString(", ") { it.toBestDisplayName() } Column( modifier = Modifier @@ -124,12 +131,21 @@ fun GroupChatroomHeader( Column(modifier = Modifier.padding(start = 10.dp)) { Text( - text = users.joinToString(", ") { it.toBestDisplayName() }, + text = subject?.takeIf { it.isNotBlank() } ?: participants, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (!subject.isNullOrBlank()) { + Text( + text = participants, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt index 18afcbc7bb..fc7dd95cc8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt @@ -101,6 +101,10 @@ class ChatroomListState( // Track pubkeys we've already requested metadata for private val fetchedMetadataKeys = mutableSetOf() + // Timestamp (createdAt) of the newest message seen by the user per room, set when a room is + // opened. A room is unread when its newest incoming message is newer than this mark. + private val lastSeen = mutableMapOf() + init { scope.launch(Dispatchers.IO) { while (isActive) { @@ -116,6 +120,14 @@ class ChatroomListState( fun selectRoom(roomKey: ChatroomKey) { _selectedRoom.value = roomKey + // Mark everything currently in the room as seen so it stops showing as unread. + val newest = + account.chatroomList.rooms + .get(roomKey) + ?.newestMessage + ?.createdAt() ?: 0L + lastSeen[roomKey] = maxOf(lastSeen[roomKey] ?: 0L, newest) + scope.launch(Dispatchers.IO) { refreshRooms() } } fun clearSelection() { @@ -199,6 +211,10 @@ class ChatroomListState( // Skip rooms with no messages if (chatroom.messages.isEmpty()) continue + // Hide rooms whose latest message is from a muted/blocked author or otherwise filtered. + val newestMessage = chatroom.newestMessage + if (newestMessage != null && !account.isAcceptable(newestMessage)) continue + val users = key.users.mapNotNull { cacheProvider.getUserIfExists(it) } // Collect pubkeys without profile info @@ -219,10 +235,14 @@ class ChatroomListState( ?.let { "$it..." } ?: "Unknown" } - val newestMessage = chatroom.newestMessage val lastPreview = decryptPreview(newestMessage?.event) val lastTimestamp = newestMessage?.createdAt() ?: 0L + // Unread when the newest message is incoming (not authored by us) and newer than the + // last time the user opened this room. + val incoming = newestMessage != null && newestMessage.author?.pubkeyHex != account.pubKey + val hasUnread = incoming && lastTimestamp > (lastSeen[key] ?: 0L) + val item = ConversationItem( roomKey = key, @@ -232,7 +252,7 @@ class ChatroomListState( lastMessagePreview = lastPreview, lastMessageTimestamp = lastTimestamp, isGroup = key.users.size > 1, - hasUnread = !chatroom.ownerSentMessage && newestMessage != null, + hasUnread = hasUnread, ) if (chatroom.ownerSentMessage) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt index 32e6e1de45..909b97e26d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt @@ -20,15 +20,19 @@ */ package com.vitorpamplona.amethyst.desktop.ui.chats +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -39,6 +43,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -49,6 +54,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.search.SearchResult import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard @@ -71,6 +77,15 @@ fun NewDmDialog( onDismiss: () -> Unit, ) { val scope = rememberCoroutineScope() + // Recipients selected so far. One → 1:1 chat, more than one → group chat. + val selected = remember { mutableStateListOf() } + + fun toggle(user: User) { + val existing = selected.firstOrNull { it.pubkeyHex == user.pubkeyHex } + if (existing != null) selected.remove(existing) else selected.add(user) + } + + fun isSelected(user: User) = selected.any { it.pubkeyHex == user.pubkeyHex } val searchState = remember { SearchBarState(cacheProvider, scope) } val searchText by searchState.searchText.collectAsState() val bech32Results by searchState.bech32Results.collectAsState() @@ -146,10 +161,38 @@ fun NewDmDialog( verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - "New Message", + if (selected.size > 1) "New Group" else "New Message", style = MaterialTheme.typography.titleLarge, ) + // Selected recipients as removable chips. Add more to form a group. + if (selected.isNotEmpty()) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(selected) { user -> + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.small, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 10.dp, end = 4.dp, top = 2.dp, bottom = 2.dp), + ) { + Text( + user.toBestDisplayName(), + style = MaterialTheme.typography.labelLarge, + ) + IconButton(onClick = { toggle(user) }, modifier = Modifier.size(24.dp)) { + Icon(MaterialSymbols.Clear, contentDescription = "Remove", modifier = Modifier.size(16.dp)) + } + } + } + } + } + } + OutlinedTextField( value = searchText, onValueChange = { searchState.updateSearchText(it) }, @@ -184,11 +227,8 @@ fun NewDmDialog( if (user != null) { UserSearchCard( user = user, - onClick = { - onUserSelected( - ChatroomKey(setOf(user.pubkeyHex)), - ) - }, + onClick = { toggle(user) }, + modifier = selectedModifier(isSelected(user)), ) } else { // Minimal card for unloaded users @@ -210,11 +250,8 @@ fun NewDmDialog( items(cachedUsers) { user -> UserSearchCard( user = user, - onClick = { - onUserSelected( - ChatroomKey(setOf(user.pubkeyHex)), - ) - }, + onClick = { toggle(user) }, + modifier = selectedModifier(isSelected(user)), ) } @@ -222,11 +259,8 @@ fun NewDmDialog( items(relaySearchResults) { user -> UserSearchCard( user = user, - onClick = { - onUserSelected( - ChatroomKey(setOf(user.pubkeyHex)), - ) - }, + onClick = { toggle(user) }, + modifier = selectedModifier(isSelected(user)), ) } @@ -262,7 +296,34 @@ fun NewDmDialog( } } } + + Button( + onClick = { + onUserSelected(ChatroomKey(selected.map { it.pubkeyHex }.toSet())) + }, + enabled = selected.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + when (selected.size) { + 0 -> "Select recipients" + 1 -> "Message" + else -> "Create group (${selected.size})" + }, + ) + } } } } } + +/** Border highlight for a selected recipient card in the new-DM picker. */ +@Composable +private fun selectedModifier(selected: Boolean): Modifier = + if (selected) { + Modifier + .fillMaxWidth() + .border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.medium) + } else { + Modifier + }