feat(desktop): bring group DM parity with Android

Makes the Desktop DM client a first-class group participant and tightens
the shared/Desktop DM paths so they match Android behavior.

- commons ChatNewMessageState: actually attach the composed NIP-14 subject
  to sent messages (the field was previously collected but dropped).
- commons ChatroomList: emit a `changes` SharedFlow on add/remove so list
  UIs can refresh reactively; dedupe the User overloads onto the room ones.
- Desktop NewDmDialog: multi-recipient selection (chips + confirm button) so
  a Desktop user can start a group, not only a 1:1.
- Desktop ChatPane/ChatroomHeader: show a group's NIP-14 subject in the
  header and add a rename dialog that broadcasts a subject change to all
  members.
- Desktop Main.kt DM ingest: route any ChatroomKeyable inner event into the
  room (covers kind 14/15 and future variants) and store self-authored
  NIP-37 drafts instead of dropping them.
- Desktop ChatroomListState: refresh reactively off ChatroomList.changes
  (with a slower safety poll), track real per-room unread via a last-seen
  mark, and hide rooms whose latest message isn't acceptable (mute/filter).

https://claude.ai/code/session_01VEukNczAYxNLBjLnqVEoZd
This commit is contained in:
Claude
2026-06-09 20:16:55 +00:00
parent 3dfe418150
commit 1fbefb2efe
7 changed files with 266 additions and 53 deletions
@@ -28,6 +28,8 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
class ChatroomList(
val ownerPubKey: HexKey,
@@ -35,6 +37,11 @@ class ChatroomList(
var rooms = LargeCache<ChatroomKey, Chatroom>()
private set
// Emits the affected [ChatroomKey] whenever a room gains or loses a message, so list UIs can
// refresh reactively instead of polling. Mirrors [MarmotGroupList.groupListChanges].
private val _changes = MutableSharedFlow<ChatroomKey>(0, 64, BufferOverflow.DROP_OLDEST)
val changes = _changes
// Account-level DM history paging cursors (one scope per account), held here so they share the
// lifetime of the cached messages and are dropped when the cache prunes them. The account-level
// history loaders bind their orchestrator to these. (Per-conversation NIP-04 cursors live on the
@@ -81,31 +88,19 @@ class ChatroomList(
if (msg.author?.pubkeyHex == ownerPubKey) {
privateChatroom.ownerSentMessage = true
}
_changes.tryEmit(room)
}
}
fun addMessage(
user: User,
msg: Note,
) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.messages) {
privateChatroom.addMessageSync(msg)
if (msg.author?.pubkeyHex == ownerPubKey) {
privateChatroom.ownerSentMessage = true
}
}
}
) = addMessage(ChatroomKey(persistentSetOf(user.pubkeyHex)), msg)
fun removeMessage(
user: User,
msg: Note,
) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.messages) {
privateChatroom.removeMessageSync(msg)
}
}
) = removeMessage(ChatroomKey(persistentSetOf(user.pubkeyHex)), msg)
fun removeMessage(
room: ChatroomKey,
@@ -114,6 +109,7 @@ class ChatroomList(
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg in privateChatroom.messages) {
privateChatroom.removeMessageSync(msg)
_changes.tryEmit(room)
}
}
@@ -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<BaseDMGroupEvent>()
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))
@@ -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 -> {
@@ -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")
}
},
)
}
@@ -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<User>,
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,
)
}
}
}
}
@@ -101,11 +101,22 @@ class ChatroomListState(
// Track pubkeys we've already requested metadata for
private val fetchedMetadataKeys = mutableSetOf<String>()
// 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<ChatroomKey, Long>()
init {
// Reactive: refresh as soon as a room gains/loses a message.
scope.launch(Dispatchers.IO) {
account.chatroomList.changes.collect {
refreshRooms()
}
}
// Safety poll: catches metadata/profile arrivals that don't emit a chatroom change.
scope.launch(Dispatchers.IO) {
while (isActive) {
refreshRooms()
delay(2000)
delay(10000)
}
}
}
@@ -116,6 +127,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 +218,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 +242,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 +259,7 @@ class ChatroomListState(
lastMessagePreview = lastPreview,
lastMessageTimestamp = lastTimestamp,
isGroup = key.users.size > 1,
hasUnread = !chatroom.ownerSentMessage && newestMessage != null,
hasUnread = hasUnread,
)
if (chatroom.ownerSentMessage) {
@@ -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<User>() }
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
}