mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+11
-15
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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 -> {
|
||||
|
||||
+104
@@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+17
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-3
@@ -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) {
|
||||
|
||||
+77
-16
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user