feat(chats): always show attach button, auto-force NIP-17 with snackbar
- Attach button always visible in DM input (not gated on NIP-17 mode) - Drag-and-drop always enabled - When files are attached in NIP-04 mode, auto-switches to NIP-17 with snackbar: "Switched to NIP-17 — file attachments require encrypted messaging" - NIP-17 toggle remains for manual control Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+200
-185
@@ -50,6 +50,8 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -154,6 +156,20 @@ fun ChatPane(
|
|||||||
// File attachment state
|
// File attachment state
|
||||||
val attachedFiles = remember { mutableStateListOf<File>() }
|
val attachedFiles = remember { mutableStateListOf<File>() }
|
||||||
var isUploading by remember { mutableStateOf(false) }
|
var isUploading by remember { mutableStateOf(false) }
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
// Helper: attach files and auto-force NIP-17 if needed
|
||||||
|
fun attachFiles(files: List<File>) {
|
||||||
|
val mediaFiles = files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS }
|
||||||
|
if (mediaFiles.isEmpty()) return
|
||||||
|
attachedFiles.addAll(mediaFiles)
|
||||||
|
if (!isNip17) {
|
||||||
|
messageState.toggleNip17()
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar("Switched to NIP-17 — file attachments require encrypted messaging")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Drag-and-drop target for file attachments (NIP-17 only)
|
// Drag-and-drop target for file attachments (NIP-17 only)
|
||||||
var isDragOver by remember { mutableStateOf(false) }
|
var isDragOver by remember { mutableStateOf(false) }
|
||||||
@@ -168,7 +184,7 @@ fun ChatPane(
|
|||||||
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<File>
|
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<File>
|
||||||
attachedFiles.addAll(files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS })
|
attachFiles(files)
|
||||||
dropEvent.dropComplete(true)
|
dropEvent.dropComplete(true)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -195,185 +211,186 @@ fun ChatPane(
|
|||||||
messageState.load(roomKey)
|
messageState.load(roomKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
modifier =
|
Column(
|
||||||
modifier
|
modifier =
|
||||||
.fillMaxSize()
|
Modifier
|
||||||
.dragAndDropTarget(
|
.fillMaxSize()
|
||||||
shouldStartDragAndDrop = { isNip17 },
|
.dragAndDropTarget(
|
||||||
target = dropTarget,
|
shouldStartDragAndDrop = { true },
|
||||||
).then(
|
target = dropTarget,
|
||||||
if (isDragOver) {
|
).then(
|
||||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp))
|
if (isDragOver) {
|
||||||
} else {
|
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp))
|
||||||
Modifier
|
} else {
|
||||||
},
|
Modifier
|
||||||
),
|
|
||||||
) {
|
|
||||||
// Header
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
if (onBack != null) {
|
|
||||||
IconButton(
|
|
||||||
onClick = onBack,
|
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = "Back to conversations",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
|
||||||
if (isGroup) {
|
|
||||||
GroupChatroomHeader(
|
|
||||||
users = users,
|
|
||||||
onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
users.firstOrNull()?.let { user ->
|
|
||||||
ChatroomHeader(
|
|
||||||
user = user,
|
|
||||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
|
||||||
)
|
|
||||||
} ?: run {
|
|
||||||
// Fallback header with raw pubkey
|
|
||||||
Text(
|
|
||||||
text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
modifier = Modifier.padding(10.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
// Broadcast status banner
|
|
||||||
DmBroadcastBanner(status = dmBroadcastStatus)
|
|
||||||
|
|
||||||
// Message list
|
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
|
||||||
when (feedState) {
|
|
||||||
is FeedState.Loading -> {
|
|
||||||
LoadingState("Loading messages...")
|
|
||||||
}
|
|
||||||
|
|
||||||
is FeedState.Empty -> {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"No messages yet. Send the first one!",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is FeedState.Loaded -> {
|
|
||||||
val loaded = feedState as FeedState.Loaded
|
|
||||||
val loadedState by loaded.feed.collectAsState()
|
|
||||||
val messages = loadedState.list
|
|
||||||
|
|
||||||
MessageList(
|
|
||||||
messages = messages,
|
|
||||||
account = account,
|
|
||||||
cacheProvider = cacheProvider,
|
|
||||||
onAuthorClick = onNavigateToProfile,
|
|
||||||
onReaction = { note, emoji ->
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
sendWrappedReaction(note, emoji, roomKey, account)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to send reaction: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
}
|
) {
|
||||||
|
// Header
|
||||||
is FeedState.FeedError -> {
|
Row(
|
||||||
Box(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
contentAlignment = Alignment.Center,
|
) {
|
||||||
|
if (onBack != null) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Icon(
|
||||||
"Error loading messages",
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
contentDescription = "Back to conversations",
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider()
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
if (isGroup) {
|
||||||
// File attachment row (only when NIP-17 and files attached)
|
GroupChatroomHeader(
|
||||||
if (isNip17 && attachedFiles.isNotEmpty()) {
|
users = users,
|
||||||
MediaAttachmentRow(
|
onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } },
|
||||||
attachedFiles = attachedFiles,
|
)
|
||||||
isUploading = isUploading,
|
} else {
|
||||||
onAttach = {
|
users.firstOrNull()?.let { user ->
|
||||||
val files = DesktopFilePicker.pickMediaFiles()
|
ChatroomHeader(
|
||||||
attachedFiles.addAll(files)
|
user = user,
|
||||||
},
|
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||||
onPaste = {},
|
)
|
||||||
onRemove = { attachedFiles.remove(it) },
|
} ?: run {
|
||||||
)
|
// Fallback header with raw pubkey
|
||||||
}
|
Text(
|
||||||
|
text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown",
|
||||||
// Message input
|
style = MaterialTheme.typography.titleSmall,
|
||||||
MessageInput(
|
modifier = Modifier.padding(10.dp),
|
||||||
messageText = messageText.text,
|
|
||||||
isNip17 = isNip17,
|
|
||||||
requiresNip17 = requiresNip17,
|
|
||||||
canSend = messageState.canSend || attachedFiles.isNotEmpty(),
|
|
||||||
isUploading = isUploading,
|
|
||||||
hasAttachments = attachedFiles.isNotEmpty(),
|
|
||||||
onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) },
|
|
||||||
onToggleNip17 = { messageState.toggleNip17() },
|
|
||||||
onAttach = {
|
|
||||||
val files = DesktopFilePicker.pickMediaFiles()
|
|
||||||
attachedFiles.addAll(files)
|
|
||||||
},
|
|
||||||
onSend = {
|
|
||||||
scope.launch {
|
|
||||||
if (attachedFiles.isNotEmpty()) {
|
|
||||||
isUploading = true
|
|
||||||
try {
|
|
||||||
sendEncryptedFiles(
|
|
||||||
files = attachedFiles.toList(),
|
|
||||||
roomKey = roomKey,
|
|
||||||
account = account,
|
|
||||||
cacheProvider = cacheProvider,
|
|
||||||
)
|
)
|
||||||
attachedFiles.clear()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Keep files in attachment row for retry
|
|
||||||
println("Encrypted file send failed: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
isUploading = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Also send text message if present
|
}
|
||||||
if (messageState.canSend) {
|
}
|
||||||
if (messageState.send()) {
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// Broadcast status banner
|
||||||
|
DmBroadcastBanner(status = dmBroadcastStatus)
|
||||||
|
|
||||||
|
// Message list
|
||||||
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
when (feedState) {
|
||||||
|
is FeedState.Loading -> {
|
||||||
|
LoadingState("Loading messages...")
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedState.Empty -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"No messages yet. Send the first one!",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedState.Loaded -> {
|
||||||
|
val loaded = feedState as FeedState.Loaded
|
||||||
|
val loadedState by loaded.feed.collectAsState()
|
||||||
|
val messages = loadedState.list
|
||||||
|
|
||||||
|
MessageList(
|
||||||
|
messages = messages,
|
||||||
|
account = account,
|
||||||
|
cacheProvider = cacheProvider,
|
||||||
|
onAuthorClick = onNavigateToProfile,
|
||||||
|
onReaction = { note, emoji ->
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
sendWrappedReaction(note, emoji, roomKey, account)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to send reaction: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedState.FeedError -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Error loading messages",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// File attachment row (only when NIP-17 and files attached)
|
||||||
|
if (attachedFiles.isNotEmpty()) {
|
||||||
|
MediaAttachmentRow(
|
||||||
|
attachedFiles = attachedFiles,
|
||||||
|
isUploading = isUploading,
|
||||||
|
onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) },
|
||||||
|
onPaste = {},
|
||||||
|
onRemove = { attachedFiles.remove(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message input
|
||||||
|
MessageInput(
|
||||||
|
messageText = messageText.text,
|
||||||
|
isNip17 = isNip17,
|
||||||
|
requiresNip17 = requiresNip17,
|
||||||
|
canSend = messageState.canSend || attachedFiles.isNotEmpty(),
|
||||||
|
isUploading = isUploading,
|
||||||
|
hasAttachments = attachedFiles.isNotEmpty(),
|
||||||
|
onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) },
|
||||||
|
onToggleNip17 = { messageState.toggleNip17() },
|
||||||
|
onAttach = { attachFiles(DesktopFilePicker.pickMediaFiles()) },
|
||||||
|
onSend = {
|
||||||
|
scope.launch {
|
||||||
|
if (attachedFiles.isNotEmpty()) {
|
||||||
|
isUploading = true
|
||||||
|
try {
|
||||||
|
sendEncryptedFiles(
|
||||||
|
files = attachedFiles.toList(),
|
||||||
|
roomKey = roomKey,
|
||||||
|
account = account,
|
||||||
|
cacheProvider = cacheProvider,
|
||||||
|
)
|
||||||
|
attachedFiles.clear()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Keep files in attachment row for retry
|
||||||
|
println("Encrypted file send failed: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
isUploading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also send text message if present
|
||||||
|
if (messageState.canSend) {
|
||||||
|
if (messageState.send()) {
|
||||||
|
messageState.clear()
|
||||||
|
}
|
||||||
|
} else if (attachedFiles.isEmpty()) {
|
||||||
messageState.clear()
|
messageState.clear()
|
||||||
}
|
}
|
||||||
} else if (attachedFiles.isEmpty()) {
|
|
||||||
messageState.clear()
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
)
|
||||||
|
} // end Column
|
||||||
|
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 80.dp),
|
||||||
)
|
)
|
||||||
}
|
} // end Box
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -674,24 +691,22 @@ private fun MessageInput(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
// Paperclip attach button (NIP-17 only)
|
// Paperclip attach button — always visible, auto-switches to NIP-17 on send
|
||||||
if (isNip17) {
|
IconButton(
|
||||||
IconButton(
|
onClick = onAttach,
|
||||||
onClick = onAttach,
|
enabled = !isUploading,
|
||||||
enabled = !isUploading,
|
modifier = Modifier.size(40.dp),
|
||||||
modifier = Modifier.size(40.dp),
|
) {
|
||||||
) {
|
Icon(
|
||||||
Icon(
|
Icons.Default.AttachFile,
|
||||||
Icons.Default.AttachFile,
|
contentDescription = "Attach file",
|
||||||
contentDescription = "Attach file",
|
tint =
|
||||||
tint =
|
if (isUploading) {
|
||||||
if (isUploading) {
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
} else {
|
||||||
} else {
|
MaterialTheme.colorScheme.primary
|
||||||
MaterialTheme.colorScheme.primary
|
},
|
||||||
},
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
|||||||
Reference in New Issue
Block a user