From 6a02a165435ac7856069fd4a862248b0e05df018 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 3 Aug 2026 22:36:33 +0300 Subject: [PATCH] attachments: preview bar + combined send (Android + desktop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android: file picker → pending preview bar (name+size+X) → combined send with text; cache copy, save dialog for downloads Desktop: attach stores pending, preview bar before send, combined with text in onSendClicked C: attachment_sender.c — include filename in msg data for display --- .../utun/chat/ui/components/MessageBubble.kt | 256 +++++++++++------- .../com/utun/chat/ui/screens/ChatScreen.kt | 86 +++++- .../com/utun/chat/viewmodel/ChatViewModel.kt | 38 +++ .../jni_bridge/android_jni_bridge.c | 7 +- .../libutun_lite/attachment_sender.c | 20 +- tools/chatgui/src/inputbar.cpp | 88 +++++- tools/chatgui/src/inputbar.h | 10 + 7 files changed, 374 insertions(+), 131 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt index 1d1609a1..2da23a15 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.utun.chat.data.Message @@ -36,7 +37,8 @@ import kotlinx.coroutines.delay fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(), onPlayVoice: (String, Int) -> Unit = { _, _ -> }, onSeekVoice: (String, Int, Int) -> Unit = { _, _, _ -> }, - onDownloadFile: (Message) -> Unit = {}) { + onDownloadFile: (Message) -> Unit = {}, + onOpenFile: (Message) -> Unit = {}) { val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8) val textColor = if (message.isOutgoing) Color.White else Color.Black val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start @@ -63,10 +65,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = when { isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice) - isMedia -> FileBubble(message, textColor, onDownloadFile) + isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile) else -> { - Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f)) - Spacer(Modifier.height(2.dp)) Text(message.text, fontSize = 17.sp, color = textColor) } } @@ -171,7 +171,8 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean, } @Composable -private fun FileBubble(message: Message, textColor: Color, onDownload: (Message) -> Unit = {}) { +private fun FileBubble(message: Message, textColor: Color, onDownload: (Message) -> Unit = {}, + onOpen: (Message) -> Unit = {}) { val state = message.downloadState val stateIcon = when { state == "downloaded" -> "\u2705" @@ -179,13 +180,18 @@ private fun FileBubble(message: Message, textColor: Color, onDownload: (Message) else -> "\u2B07\uFE0F" } val stateText = when { - state == "downloaded" -> "Downloaded" + state == "downloaded" -> "Tap to save" state == "downloading" -> "Downloading..." else -> "Tap to download" } Column( - modifier = Modifier.clickable { if (state.isEmpty()) onDownload(message) } + modifier = Modifier.clickable { + when { + state == "downloaded" -> onOpen(message) + state.isEmpty() -> onDownload(message) + } + } ) { Row(verticalAlignment = Alignment.CenterVertically) { Text(stateIcon, fontSize = 18.sp) @@ -235,6 +241,9 @@ fun InputBar( onVoiceSend: () -> Unit = {}, onVoiceCancel: () -> Unit = {}, onAttachClicked: () -> Unit = {}, + pendingAttachmentName: String = "", + pendingAttachmentSize: Long = 0L, + onClearAttachment: () -> Unit = {}, modifier: Modifier = Modifier ) { val ctx = LocalContext.current @@ -251,126 +260,163 @@ fun InputBar( hasMicPerm = granted } - val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { _ -> } + val hasAttachment = pendingAttachmentName.isNotEmpty() - Row( - modifier = modifier.fillMaxWidth().padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - /* Attach button */ - if (!isRecording) { - IconButton(onClick = { filePicker.launch("*/*") }, enabled = enabled) { - Text("\uD83D\uDCCE", fontSize = 20.sp) - } - } else { - Spacer(Modifier.width(4.dp)) - } - - /* Recording overlay or text input */ - if (isRecording) { + Column(modifier = modifier.fillMaxWidth().padding(8.dp)) { + /* Attachment preview bar */ + if (hasAttachment) { Surface( - modifier = Modifier.weight(1f).height(48.dp), + modifier = Modifier.fillMaxWidth().height(36.dp).padding(bottom = 4.dp), shape = RoundedCornerShape(8.dp), - color = Color(0xFFE53935) + color = MaterialTheme.colorScheme.primaryContainer ) { Row( - modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically ) { + Text("\uD83D\uDCCE", fontSize = 14.sp) + Spacer(Modifier.width(6.dp)) Text( - text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}", - color = Color.White, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, + pendingAttachmentName, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) - val lvlPct = (recordingLevel * 40f).coerceIn(0f, 1f) - LinearProgressIndicator( - progress = { lvlPct }, - modifier = Modifier.width(60.dp).height(4.dp).padding(end = 8.dp), - color = Color.White, - trackColor = Color.White.copy(alpha = 0.3f), - ) + if (pendingAttachmentSize > 0) { + Text( + formatFileSize(pendingAttachmentSize), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) + ) + Spacer(Modifier.width(8.dp)) + } + IconButton( + onClick = onClearAttachment, + modifier = Modifier.size(24.dp) + ) { + Text("\u2715", fontSize = 14.sp) + } } } - } else { - OutlinedTextField( - value = text, - onValueChange = onTextChange, - enabled = enabled, - modifier = Modifier.weight(1f), - placeholder = { Text("Message...") }, - maxLines = 4 - ) } - Spacer(Modifier.width(4.dp)) - - if (isRecording && isRecordingLocked) { - /* Locked: cancel + send voice */ - IconButton(onClick = onVoiceCancel) { - Text("\u2715", fontSize = 20.sp, color = Color(0xFFE53935)) - } - IconButton(onClick = onVoiceSend) { - Text("\u25B6", fontSize = 20.sp, color = Color(0xFF4CAF50)) - } - } else if (!isRecording && text.isNotBlank()) { - /* Send button — round, primary color */ - val sendColor = MaterialTheme.colorScheme.primary - Box( - modifier = Modifier - .size(44.dp) - .background(sendColor, CircleShape) - .clickable { onSend() }, - contentAlignment = Alignment.Center - ) { - Text("\u27A4", fontSize = 22.sp, color = Color.White) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + /* Attach button */ + if (!isRecording) { + IconButton(onClick = onAttachClicked, enabled = enabled) { + Text("\uD83D\uDCCE", fontSize = 20.sp) + } + } else { + Spacer(Modifier.width(4.dp)) } - } else { - /* PTT Box — podcast/recording, same identity in idle and holding */ - val pttBg = if (isRecording) Color(0xFFE53935) else Color.Transparent - Box( - modifier = Modifier - .size(48.dp) - .background(pttBg, RoundedCornerShape(4.dp)) - .pointerInput(hasMicPerm) { - awaitPointerEventScope { - while (true) { - val down = awaitPointerEvent() - val ch = down.changes.firstOrNull() ?: continue - if (!ch.pressed) continue - ch.consume() - if (!hasMicPerm) { - permLauncher.launch(Manifest.permission.RECORD_AUDIO) - continue - } + /* Recording overlay or text input */ + if (isRecording) { + Surface( + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(8.dp), + color = Color(0xFFE53935) + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + modifier = Modifier.weight(1f) + ) + val lvlPct = (recordingLevel * 40f).coerceIn(0f, 1f) + LinearProgressIndicator( + progress = { lvlPct }, + modifier = Modifier.width(60.dp).height(4.dp).padding(end = 8.dp), + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f), + ) + } + } + } else { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + enabled = enabled, + modifier = Modifier.weight(1f), + placeholder = { Text("Message...") }, + maxLines = 4 + ) + } - onPttPressed() - var locked = false - var totalDragY = 0f - var lastY = ch.position.y + Spacer(Modifier.width(4.dp)) + if (isRecording && isRecordingLocked) { + /* Locked: cancel + send voice */ + IconButton(onClick = onVoiceCancel) { + Text("\u2715", fontSize = 20.sp, color = Color(0xFFE53935)) + } + IconButton(onClick = onVoiceSend) { + Text("\u25B6", fontSize = 20.sp, color = Color(0xFF4CAF50)) + } + } else if (!isRecording && (text.isNotBlank() || hasAttachment)) { + /* Send button — round, primary color */ + val sendColor = MaterialTheme.colorScheme.primary + Box( + modifier = Modifier + .size(44.dp) + .background(sendColor, CircleShape) + .clickable { onSend() }, + contentAlignment = Alignment.Center + ) { + Text("\u27A4", fontSize = 22.sp, color = Color.White) + } + } else { + /* PTT Box — podcast/recording, same identity in idle and holding */ + val pttBg = if (isRecording) Color(0xFFE53935) else Color.Transparent + Box( + modifier = Modifier + .size(48.dp) + .background(pttBg, RoundedCornerShape(4.dp)) + .pointerInput(hasMicPerm) { + awaitPointerEventScope { while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull() ?: break - if (change.pressed) { - val dy = change.position.y - lastY - lastY = change.position.y - totalDragY += dy - if (totalDragY < -lockThresholdPx && !locked) { locked = true; onPttLock() } - change.consume() - } else { - change.consume(); break + val down = awaitPointerEvent() + val ch = down.changes.firstOrNull() ?: continue + if (!ch.pressed) continue + ch.consume() + + if (!hasMicPerm) { + permLauncher.launch(Manifest.permission.RECORD_AUDIO) + continue + } + + onPttPressed() + var locked = false + var totalDragY = 0f + var lastY = ch.position.y + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull() ?: break + if (change.pressed) { + val dy = change.position.y - lastY + lastY = change.position.y + totalDragY += dy + if (totalDragY < -lockThresholdPx && !locked) { locked = true; onPttLock() } + change.consume() + } else { + change.consume(); break + } } + onPttReleased() } - onPttReleased() } - } - }, - contentAlignment = Alignment.Center - ) { - Text("\uD83C\uDFA4", fontSize = 20.sp, color = if (isRecording) Color.White else Color.Unspecified) + }, + contentAlignment = Alignment.Center + ) { + Text("\uD83C\uDFA4", fontSize = 20.sp, color = if (isRecording) Color.White else Color.Unspecified) + } } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 38780bd3..8fd60dfb 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -1,5 +1,10 @@ package com.utun.chat.ui.screens +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -10,6 +15,7 @@ import androidx.compose.material.icons.filled.People import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.utun.chat.data.Channel import com.utun.chat.data.Message @@ -17,7 +23,10 @@ import com.utun.chat.data.NativeLib import com.utun.chat.ui.components.InputBar import com.utun.chat.ui.components.MessageBubble import com.utun.chat.viewmodel.ChatViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -36,6 +45,47 @@ fun ChatScreen( val isRecordingLocked by viewModel.isRecordingLocked.collectAsState() val recordingDurationMs by viewModel.recordingDurationMs.collectAsState() var recordingLevel by remember { mutableStateOf(0f) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + + /* pending attachment state */ + var pendingAttachUri by remember { mutableStateOf(null) } + var pendingAttachName by remember { mutableStateOf("") } + var pendingAttachSize by remember { mutableStateOf(0L) } + + /* file picker */ + val filePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) { + val cr: ContentResolver = context.contentResolver + var name = "file" + var size = 0L + cr.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) + if (nameIdx >= 0) name = cursor.getString(nameIdx) ?: name + if (sizeIdx >= 0) size = cursor.getLong(sizeIdx) + } + } + pendingAttachUri = uri + pendingAttachName = name + pendingAttachSize = size + } + } + + /* save dialog for downloaded files */ + var pendingSaveFilePath by remember { mutableStateOf(null) } + val saveFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { saveUri -> + if (saveUri != null) { + val filePath = pendingSaveFilePath + pendingSaveFilePath = null + if (filePath != null) { + scope.launch(Dispatchers.IO) { + viewModel.saveDownloadedFile(filePath, saveUri) + } + } + } + } /* Poll peak level during recording */ LaunchedEffect(isRecording) { @@ -73,7 +123,13 @@ fun ChatScreen( MessageBubble(messages[idx], playbackState = playbackState, onPlayVoice = { path, durMs -> viewModel.playVoiceMessage(path, durMs) }, onSeekVoice = { path, seekMs, durMs -> viewModel.seekVoice(path, seekMs, durMs) }, - onDownloadFile = { msg -> viewModel.downloadAttachment(msg) }) + onDownloadFile = { msg -> viewModel.downloadAttachment(msg) }, + onOpenFile = { msg -> + if (msg.filePath.isNotEmpty()) { + pendingSaveFilePath = msg.filePath + saveFileLauncher.launch(msg.text) /* suggest original filename */ + } + }) } item { Spacer(Modifier.height(8.dp)) } } @@ -81,8 +137,23 @@ fun ChatScreen( text = text, onTextChange = { text = it }, onSend = { - onSendMessage(text) - text = "" + val attachUri = pendingAttachUri + val attachName = pendingAttachName + pendingAttachUri = null + pendingAttachName = "" + pendingAttachSize = 0L + if (attachUri != null) { + scope.launch(Dispatchers.IO) { + val cachePath = viewModel.copyUriToCache(context, attachUri, attachName) + if (cachePath != null) { + viewModel.sendAttachment(cachePath) + } + } + } + if (text.isNotBlank()) { + onSendMessage(text) + text = "" + } }, enabled = true, isRecording = isRecording, @@ -99,7 +170,14 @@ fun ChatScreen( viewModel.cancelVoiceRecording() recordingLevel = 0f }, - onAttachClicked = {}, + onAttachClicked = { filePickerLauncher.launch("*/*") }, + pendingAttachmentName = pendingAttachName, + pendingAttachmentSize = pendingAttachSize, + onClearAttachment = { + pendingAttachUri = null + pendingAttachName = "" + pendingAttachSize = 0L + }, modifier = Modifier.navigationBarsPadding() ) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 2cb63ce8..8c8f45e9 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -1,5 +1,8 @@ package com.utun.chat.viewmodel +import android.content.Context +import android.net.Uri +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -12,6 +15,8 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream class ChatViewModel : ViewModel() { @@ -443,6 +448,39 @@ class ChatViewModel : ViewModel() { }) } + fun copyUriToCache(context: Context, uri: Uri, fileName: String): String? { + try { + val cacheDir = File(context.cacheDir, "attachments") + cacheDir.mkdirs() + val cacheFile = File(cacheDir, fileName) + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(cacheFile).use { output -> input.copyTo(output) } + } ?: return null + LogManager.addLog("INFO", "VM", "copied attachment to cache: ${cacheFile.absolutePath}") + return cacheFile.absolutePath + } catch (e: Exception) { + LogManager.addLog("ERROR", "VM", "copyUriToCache failed: ${e.message}") + return null + } + } + + fun saveDownloadedFile(srcPath: String, saveUri: Uri) { + try { + val src = File(srcPath) + if (!src.exists()) { + LogManager.addLog("ERROR", "VM", "saveDownloadedFile: src not found $srcPath") + return + } + val ctx = ChatApplication.instance + ctx.contentResolver.openOutputStream(saveUri)?.use { output -> + src.inputStream().use { input -> input.copyTo(output) } + } + LogManager.addLog("INFO", "VM", "file saved: $srcPath -> $saveUri") + } catch (e: Exception) { + LogManager.addLog("ERROR", "VM", "saveDownloadedFile failed: ${e.message}") + } + } + fun clearState() { repo?.close() repo = null diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 767d0a23..781ce8f7 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -616,10 +616,9 @@ char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_i if (family == 4) snprintf(addr_str, sizeof(addr_str), "%d.%d.%d.%d", addr[0], addr[1], addr[2], addr[3]); else if (family == 6) { - const uint16_t* a = (const uint16_t*)addr; - snprintf(addr_str, sizeof(addr_str), "%04x:%04x:%04x:%04x:%04x:%04x:%04x:%04x", - ntohs(a[0]), ntohs(a[1]), ntohs(a[2]), ntohs(a[3]), - ntohs(a[4]), ntohs(a[5]), ntohs(a[6]), ntohs(a[7])); + char tmp[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, addr, tmp, sizeof(tmp)); + snprintf(addr_str, sizeof(addr_str), "%s", tmp); } } const char* pn; diff --git a/tools/chatgui-android/libutun_lite/attachment_sender.c b/tools/chatgui-android/libutun_lite/attachment_sender.c index 33191d60..80ff0c8b 100644 --- a/tools/chatgui-android/libutun_lite/attachment_sender.c +++ b/tools/chatgui-android/libutun_lite/attachment_sender.c @@ -21,14 +21,19 @@ int attachment_send(const char* channel_id, const char* src_file_path, const cha struct UASYNC* ua = instance_lite_get_uasync(); if (!ua) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: no uasync"); return -1; } - /* extract basename and ext */ + /* get original basename for display, then make unique filename for storage */ char path_copy[1024]; snprintf(path_copy, sizeof(path_copy), "%s", src_file_path); char* fname = basename(path_copy); + char original_basename[256]; + snprintf(original_basename, sizeof(original_basename), "%s", fname); char* dot = strrchr(fname, '.'); char ext[32] = ""; if (dot) { snprintf(ext, sizeof(ext), "%s", dot + 1); *dot = '\0'; } + struct stat st; memset(&st, 0, sizeof(st)); + stat(src_file_path, &st); + /* generate dt/suffix for unique filename */ time_t t = time(NULL); struct tm tm_buf; localtime_r(&t, &tm_buf); @@ -55,8 +60,10 @@ int attachment_send(const char* channel_id, const char* src_file_path, const cha snprintf(media_file, sizeof(media_file), "%s/%s_%s_%s.%s", media_dir, dt, fname, suffix, ext[0] ? ext : "bin"); - /* post chat_msg_submit — media_copy=1 so media_index copies src to dest */ - struct chat_msg_submit* req = u_calloc(1, sizeof(struct chat_msg_submit) + 1); + /* post chat_msg_submit — media_copy=1 so media_index copies src to dest + data = original_basename — becomes first part of message "d" field: "name.ext|size|..." */ + size_t datalen = strlen(original_basename); + struct chat_msg_submit* req = u_calloc(1, sizeof(struct chat_msg_submit) + datalen + 1); if (!req) return -1; snprintf(req->channel_id, sizeof(req->channel_id), "%s", channel_id); @@ -65,11 +72,12 @@ int attachment_send(const char* channel_id, const char* src_file_path, const cha snprintf(req->media_dest, sizeof(req->media_dest), "%s", media_file); req->media_copy = 1; req->data = (uint8_t*)(req + 1); - req->data_len = 0; + memcpy(req->data, original_basename, datalen); + req->data_len = (uint32_t)datalen; req->timestamp = 0; - DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "attachment_send: ch=%s src=%s dst=%s", - channel_id, src_file_path, media_file); + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "attachment_send: ch=%s src=%s dst=%s name=%s size=%lld", + channel_id, src_file_path, media_file, original_basename, (long long)st.st_size); uasync_post(ua, chat_core_submit_trampoline, req); return 0; diff --git a/tools/chatgui/src/inputbar.cpp b/tools/chatgui/src/inputbar.cpp index bdb7575e..f6c405d9 100644 --- a/tools/chatgui/src/inputbar.cpp +++ b/tools/chatgui/src/inputbar.cpp @@ -45,6 +45,16 @@ static QWidget *wrapBottomBtn(QPushButton *btn, int pad) { return w; } +QString InputBar::formatFileSize(qint64 bytes) { + if (bytes < 1024) return QString("%1B").arg(bytes); + double kb = bytes / 1024.0; + if (kb < 1024.0) return QString("%1KB").arg(kb, 0, 'f', kb < 10.0 ? 1 : 0); + double mb = kb / 1024.0; + if (mb < 1024.0) return QString("%1MB").arg(mb, 0, 'f', mb < 10.0 ? 1 : 0); + double gb = mb / 1024.0; + return QString("%1GB").arg(gb, 0, 'f', 1); +} + InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *parent) : QWidget(parent) , m_input(new QTextEdit(this)) @@ -53,8 +63,29 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par , m_emojiPanel(emojiPanel) , m_recorder(recorder) { - auto *layout = new QHBoxLayout(this); - layout->setContentsMargins(8, 4, 8, 4); + auto *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(8, 4, 8, 0); + mainLayout->setSpacing(2); + + /* Attachment preview bar (hidden until file selected) */ + m_attachPreview = new QWidget(this); + auto *prevLay = new QHBoxLayout(m_attachPreview); + prevLay->setContentsMargins(0, 2, 0, 2); + prevLay->setSpacing(4); + m_attachPreviewLabel = new QLabel(m_attachPreview); + m_attachPreviewLabel->setStyleSheet("background: palette(light); border-radius: 6px; padding: 2px 8px;"); + prevLay->addWidget(m_attachPreviewLabel, 1); + m_attachClearBtn = new QPushButton(QString::fromUtf8("\xE2\x9C\x95"), m_attachPreview); + m_attachClearBtn->setFixedSize(20, 20); + m_attachClearBtn->setCursor(Qt::PointingHandCursor); + m_attachClearBtn->setStyleSheet("QPushButton { border: none; background: transparent; color: palette(text); }" + "QPushButton:hover { color: #e74c3c; }"); + connect(m_attachClearBtn, &QPushButton::clicked, this, &InputBar::clearPendingAttachment); + prevLay->addWidget(m_attachClearBtn); + m_attachPreview->hide(); + mainLayout->addWidget(m_attachPreview); + + auto *layout = new QHBoxLayout(); layout->setSpacing(4); m_attachBtn = makeIconBtn(QIcon(":/icons/attach.png"), @@ -144,6 +175,8 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par connect(recorder, &AudioRecorder::recordingStopped, this, [this](int) { hideRecordOverlay(); }); connect(recorder, &AudioRecorder::durationChanged, this, &InputBar::updateRecordingUI); } + + mainLayout->addLayout(layout); } bool InputBar::eventFilter(QObject *obj, QEvent *event) { @@ -185,15 +218,29 @@ void InputBar::adjustInputHeight() { void InputBar::onSendClicked() { QString text = m_input->toPlainText(); - GUI_INFO("send: len=%d hasLF=%d text='%s'", - text.length(), text.contains('\n'), qPrintable(text)); while (!text.isEmpty() && (text.back() == ' ' || text.back() == '\t')) text.chop(1); - if (text.isEmpty()) return; - emit sendMessage(text); - m_input->clear(); - if (m_emojiPanel) m_emojiPanel->hide(); - m_input->setFocus(); + + bool hasAttach = !m_pendingAttachSrc.isEmpty(); + if (!hasAttach && text.isEmpty()) return; + + if (hasAttach) { + GUI_INFO("send: file=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName)); + emit sendFileAttachment(m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName); + m_pendingAttachSrc.clear(); + m_pendingAttachDest.clear(); + m_pendingAttachName.clear(); + m_pendingAttachSize = 0; + m_attachPreview->hide(); + } + if (!text.isEmpty()) { + GUI_INFO("send: len=%d hasLF=%d text='%s'", + text.length(), text.contains('\n'), qPrintable(text)); + emit sendMessage(text); + m_input->clear(); + if (m_emojiPanel) m_emojiPanel->hide(); + m_input->setFocus(); + } } void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& channelId) { @@ -284,9 +331,26 @@ void InputBar::onAttachClicked() { QString destPath = QString("%1/%2_%3_%4.%5") .arg(mediaPath, ts).arg(fi.completeBaseName()).arg(rnd, 4, 16, QChar('0')).arg(fi.suffix().isEmpty() ? "bin" : fi.suffix()); - GUI_INFO("InputBar::onAttachClicked: src=%s dest=%s", - qPrintable(srcPath), qPrintable(destPath)); - emit sendFileAttachment(srcPath, destPath, fi.fileName()); + m_pendingAttachSrc = srcPath; + m_pendingAttachDest = destPath; + m_pendingAttachName = fi.fileName(); + m_pendingAttachSize = fi.size(); + + QString labelText = QString::fromUtf8("\xF0\x9F\x93\x8E %1 (%2)") + .arg(fi.fileName(), formatFileSize(fi.size())); + m_attachPreviewLabel->setText(labelText); + m_attachPreview->show(); + + GUI_INFO("InputBar::onAttachClicked: pending %s size=%lld", + qPrintable(srcPath), (long long)fi.size()); +} + +void InputBar::clearPendingAttachment() { + m_pendingAttachSrc.clear(); + m_pendingAttachDest.clear(); + m_pendingAttachName.clear(); + m_pendingAttachSize = 0; + m_attachPreview->hide(); } void InputBar::resizeEvent(QResizeEvent* event) { diff --git a/tools/chatgui/src/inputbar.h b/tools/chatgui/src/inputbar.h index 5185b00a..e18e18a4 100644 --- a/tools/chatgui/src/inputbar.h +++ b/tools/chatgui/src/inputbar.h @@ -34,11 +34,14 @@ private slots: void onPttPressed(); void onPttReleased(); void onAttachClicked(); + void clearPendingAttachment(); void updateRecordingUI(int ms); void showRecordOverlay(); void hideRecordOverlay(); private: + static QString formatFileSize(qint64 bytes); + QTextEdit *m_input; QPushButton *m_emojiBtn; QPushButton *m_pttBtn; @@ -49,8 +52,15 @@ private: QLabel *m_recordLabel = nullptr; QPushButton *m_cancelRecBtn = nullptr; QProgressBar *m_levelBar = nullptr; + QWidget *m_attachPreview = nullptr; + QLabel *m_attachPreviewLabel = nullptr; + QPushButton *m_attachClearBtn = nullptr; bool m_pttActive = false; QString m_voiceFilePath; QString m_mediaDirBase; QString m_channelIdForRecord; + QString m_pendingAttachSrc; + QString m_pendingAttachDest; + QString m_pendingAttachName; + qint64 m_pendingAttachSize = 0; };