Browse Source

attachments: preview bar + combined send (Android + desktop)

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
topo_upd
evgeny 2 months ago
parent
commit
6a02a16543
  1. 68
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  2. 82
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  3. 38
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  4. 7
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  5. 20
      tools/chatgui-android/libutun_lite/attachment_sender.c
  6. 80
      tools/chatgui/src/inputbar.cpp
  7. 10
      tools/chatgui/src/inputbar.h

68
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.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.utun.chat.data.Message import com.utun.chat.data.Message
@ -36,7 +37,8 @@ import kotlinx.coroutines.delay
fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(), fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(),
onPlayVoice: (String, Int) -> Unit = { _, _ -> }, onPlayVoice: (String, Int) -> Unit = { _, _ -> },
onSeekVoice: (String, Int, 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 bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8)
val textColor = if (message.isOutgoing) Color.White else Color.Black val textColor = if (message.isOutgoing) Color.White else Color.Black
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
@ -63,10 +65,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
when { when {
isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice) isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice)
isMedia -> FileBubble(message, textColor, onDownloadFile) isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile)
else -> { 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) Text(message.text, fontSize = 17.sp, color = textColor)
} }
} }
@ -171,7 +171,8 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
} }
@Composable @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 state = message.downloadState
val stateIcon = when { val stateIcon = when {
state == "downloaded" -> "\u2705" state == "downloaded" -> "\u2705"
@ -179,13 +180,18 @@ private fun FileBubble(message: Message, textColor: Color, onDownload: (Message)
else -> "\u2B07\uFE0F" else -> "\u2B07\uFE0F"
} }
val stateText = when { val stateText = when {
state == "downloaded" -> "Downloaded" state == "downloaded" -> "Tap to save"
state == "downloading" -> "Downloading..." state == "downloading" -> "Downloading..."
else -> "Tap to download" else -> "Tap to download"
} }
Column( 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) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(stateIcon, fontSize = 18.sp) Text(stateIcon, fontSize = 18.sp)
@ -235,6 +241,9 @@ fun InputBar(
onVoiceSend: () -> Unit = {}, onVoiceSend: () -> Unit = {},
onVoiceCancel: () -> Unit = {}, onVoiceCancel: () -> Unit = {},
onAttachClicked: () -> Unit = {}, onAttachClicked: () -> Unit = {},
pendingAttachmentName: String = "",
pendingAttachmentSize: Long = 0L,
onClearAttachment: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val ctx = LocalContext.current val ctx = LocalContext.current
@ -251,15 +260,51 @@ fun InputBar(
hasMicPerm = granted hasMicPerm = granted
} }
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { _ -> } val hasAttachment = pendingAttachmentName.isNotEmpty()
Column(modifier = modifier.fillMaxWidth().padding(8.dp)) {
/* Attachment preview bar */
if (hasAttachment) {
Surface(
modifier = Modifier.fillMaxWidth().height(36.dp).padding(bottom = 4.dp),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer
) {
Row( Row(
modifier = modifier.fillMaxWidth().padding(8.dp), modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("\uD83D\uDCCE", fontSize = 14.sp)
Spacer(Modifier.width(6.dp))
Text(
pendingAttachmentName,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
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)
}
}
}
}
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
/* Attach button */ /* Attach button */
if (!isRecording) { if (!isRecording) {
IconButton(onClick = { filePicker.launch("*/*") }, enabled = enabled) { IconButton(onClick = onAttachClicked, enabled = enabled) {
Text("\uD83D\uDCCE", fontSize = 20.sp) Text("\uD83D\uDCCE", fontSize = 20.sp)
} }
} else { } else {
@ -314,7 +359,7 @@ fun InputBar(
IconButton(onClick = onVoiceSend) { IconButton(onClick = onVoiceSend) {
Text("\u25B6", fontSize = 20.sp, color = Color(0xFF4CAF50)) Text("\u25B6", fontSize = 20.sp, color = Color(0xFF4CAF50))
} }
} else if (!isRecording && text.isNotBlank()) { } else if (!isRecording && (text.isNotBlank() || hasAttachment)) {
/* Send button — round, primary color */ /* Send button — round, primary color */
val sendColor = MaterialTheme.colorScheme.primary val sendColor = MaterialTheme.colorScheme.primary
Box( Box(
@ -375,6 +420,7 @@ fun InputBar(
} }
} }
} }
}
private fun formatDuration(ms: Int): String { private fun formatDuration(ms: Int): String {
val secs = ms / 1000 val secs = ms / 1000

82
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

@ -1,5 +1,10 @@
package com.utun.chat.ui.screens 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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@ -10,6 +15,7 @@ import androidx.compose.material.icons.filled.People
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.utun.chat.data.Channel import com.utun.chat.data.Channel
import com.utun.chat.data.Message 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.InputBar
import com.utun.chat.ui.components.MessageBubble import com.utun.chat.ui.components.MessageBubble
import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -36,6 +45,47 @@ fun ChatScreen(
val isRecordingLocked by viewModel.isRecordingLocked.collectAsState() val isRecordingLocked by viewModel.isRecordingLocked.collectAsState()
val recordingDurationMs by viewModel.recordingDurationMs.collectAsState() val recordingDurationMs by viewModel.recordingDurationMs.collectAsState()
var recordingLevel by remember { mutableStateOf(0f) } var recordingLevel by remember { mutableStateOf(0f) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
/* pending attachment state */
var pendingAttachUri by remember { mutableStateOf<Uri?>(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<String?>(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 */ /* Poll peak level during recording */
LaunchedEffect(isRecording) { LaunchedEffect(isRecording) {
@ -73,7 +123,13 @@ fun ChatScreen(
MessageBubble(messages[idx], playbackState = playbackState, MessageBubble(messages[idx], playbackState = playbackState,
onPlayVoice = { path, durMs -> viewModel.playVoiceMessage(path, durMs) }, onPlayVoice = { path, durMs -> viewModel.playVoiceMessage(path, durMs) },
onSeekVoice = { path, seekMs, durMs -> viewModel.seekVoice(path, seekMs, 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)) } item { Spacer(Modifier.height(8.dp)) }
} }
@ -81,8 +137,23 @@ fun ChatScreen(
text = text, text = text,
onTextChange = { text = it }, onTextChange = { text = it },
onSend = { onSend = {
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) onSendMessage(text)
text = "" text = ""
}
}, },
enabled = true, enabled = true,
isRecording = isRecording, isRecording = isRecording,
@ -99,7 +170,14 @@ fun ChatScreen(
viewModel.cancelVoiceRecording() viewModel.cancelVoiceRecording()
recordingLevel = 0f recordingLevel = 0f
}, },
onAttachClicked = {}, onAttachClicked = { filePickerLauncher.launch("*/*") },
pendingAttachmentName = pendingAttachName,
pendingAttachmentSize = pendingAttachSize,
onClearAttachment = {
pendingAttachUri = null
pendingAttachName = ""
pendingAttachSize = 0L
},
modifier = Modifier.navigationBarsPadding() modifier = Modifier.navigationBarsPadding()
) )
} }

38
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -1,5 +1,8 @@
package com.utun.chat.viewmodel package com.utun.chat.viewmodel
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -12,6 +15,8 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
class ChatViewModel : ViewModel() { 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() { fun clearState() {
repo?.close() repo?.close()
repo = null repo = null

7
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) if (family == 4)
snprintf(addr_str, sizeof(addr_str), "%d.%d.%d.%d", addr[0], addr[1], addr[2], addr[3]); snprintf(addr_str, sizeof(addr_str), "%d.%d.%d.%d", addr[0], addr[1], addr[2], addr[3]);
else if (family == 6) { else if (family == 6) {
const uint16_t* a = (const uint16_t*)addr; char tmp[INET6_ADDRSTRLEN];
snprintf(addr_str, sizeof(addr_str), "%04x:%04x:%04x:%04x:%04x:%04x:%04x:%04x", inet_ntop(AF_INET6, addr, tmp, sizeof(tmp));
ntohs(a[0]), ntohs(a[1]), ntohs(a[2]), ntohs(a[3]), snprintf(addr_str, sizeof(addr_str), "%s", tmp);
ntohs(a[4]), ntohs(a[5]), ntohs(a[6]), ntohs(a[7]));
} }
} }
const char* pn; const char* pn;

20
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(); struct UASYNC* ua = instance_lite_get_uasync();
if (!ua) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: no uasync"); return -1; } 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]; char path_copy[1024];
snprintf(path_copy, sizeof(path_copy), "%s", src_file_path); snprintf(path_copy, sizeof(path_copy), "%s", src_file_path);
char* fname = basename(path_copy); char* fname = basename(path_copy);
char original_basename[256];
snprintf(original_basename, sizeof(original_basename), "%s", fname);
char* dot = strrchr(fname, '.'); char* dot = strrchr(fname, '.');
char ext[32] = ""; char ext[32] = "";
if (dot) { snprintf(ext, sizeof(ext), "%s", dot + 1); *dot = '\0'; } 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 */ /* generate dt/suffix for unique filename */
time_t t = time(NULL); time_t t = time(NULL);
struct tm tm_buf; localtime_r(&t, &tm_buf); 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", snprintf(media_file, sizeof(media_file), "%s/%s_%s_%s.%s",
media_dir, dt, fname, suffix, ext[0] ? ext : "bin"); media_dir, dt, fname, suffix, ext[0] ? ext : "bin");
/* post chat_msg_submit — media_copy=1 so media_index copies src to dest */ /* 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); 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; if (!req) return -1;
snprintf(req->channel_id, sizeof(req->channel_id), "%s", channel_id); 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); snprintf(req->media_dest, sizeof(req->media_dest), "%s", media_file);
req->media_copy = 1; req->media_copy = 1;
req->data = (uint8_t*)(req + 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; req->timestamp = 0;
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "attachment_send: ch=%s src=%s dst=%s", DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "attachment_send: ch=%s src=%s dst=%s name=%s size=%lld",
channel_id, src_file_path, media_file); channel_id, src_file_path, media_file, original_basename, (long long)st.st_size);
uasync_post(ua, chat_core_submit_trampoline, req); uasync_post(ua, chat_core_submit_trampoline, req);
return 0; return 0;

80
tools/chatgui/src/inputbar.cpp

@ -45,6 +45,16 @@ static QWidget *wrapBottomBtn(QPushButton *btn, int pad) {
return w; 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) InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *parent)
: QWidget(parent) : QWidget(parent)
, m_input(new QTextEdit(this)) , m_input(new QTextEdit(this))
@ -53,8 +63,29 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
, m_emojiPanel(emojiPanel) , m_emojiPanel(emojiPanel)
, m_recorder(recorder) , m_recorder(recorder)
{ {
auto *layout = new QHBoxLayout(this); auto *mainLayout = new QVBoxLayout(this);
layout->setContentsMargins(8, 4, 8, 4); 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); layout->setSpacing(4);
m_attachBtn = makeIconBtn(QIcon(":/icons/attach.png"), 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::recordingStopped, this, [this](int) { hideRecordOverlay(); });
connect(recorder, &AudioRecorder::durationChanged, this, &InputBar::updateRecordingUI); connect(recorder, &AudioRecorder::durationChanged, this, &InputBar::updateRecordingUI);
} }
mainLayout->addLayout(layout);
} }
bool InputBar::eventFilter(QObject *obj, QEvent *event) { bool InputBar::eventFilter(QObject *obj, QEvent *event) {
@ -185,16 +218,30 @@ void InputBar::adjustInputHeight() {
void InputBar::onSendClicked() { void InputBar::onSendClicked() {
QString text = m_input->toPlainText(); 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')) while (!text.isEmpty() && (text.back() == ' ' || text.back() == '\t'))
text.chop(1); text.chop(1);
if (text.isEmpty()) return;
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); emit sendMessage(text);
m_input->clear(); m_input->clear();
if (m_emojiPanel) m_emojiPanel->hide(); if (m_emojiPanel) m_emojiPanel->hide();
m_input->setFocus(); m_input->setFocus();
} }
}
void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& channelId) { void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& channelId) {
m_mediaDirBase = mediaDirBase; m_mediaDirBase = mediaDirBase;
@ -284,9 +331,26 @@ void InputBar::onAttachClicked() {
QString destPath = QString("%1/%2_%3_%4.%5") 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()); .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", m_pendingAttachSrc = srcPath;
qPrintable(srcPath), qPrintable(destPath)); m_pendingAttachDest = destPath;
emit sendFileAttachment(srcPath, destPath, fi.fileName()); 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) { void InputBar::resizeEvent(QResizeEvent* event) {

10
tools/chatgui/src/inputbar.h

@ -34,11 +34,14 @@ private slots:
void onPttPressed(); void onPttPressed();
void onPttReleased(); void onPttReleased();
void onAttachClicked(); void onAttachClicked();
void clearPendingAttachment();
void updateRecordingUI(int ms); void updateRecordingUI(int ms);
void showRecordOverlay(); void showRecordOverlay();
void hideRecordOverlay(); void hideRecordOverlay();
private: private:
static QString formatFileSize(qint64 bytes);
QTextEdit *m_input; QTextEdit *m_input;
QPushButton *m_emojiBtn; QPushButton *m_emojiBtn;
QPushButton *m_pttBtn; QPushButton *m_pttBtn;
@ -49,8 +52,15 @@ private:
QLabel *m_recordLabel = nullptr; QLabel *m_recordLabel = nullptr;
QPushButton *m_cancelRecBtn = nullptr; QPushButton *m_cancelRecBtn = nullptr;
QProgressBar *m_levelBar = nullptr; QProgressBar *m_levelBar = nullptr;
QWidget *m_attachPreview = nullptr;
QLabel *m_attachPreviewLabel = nullptr;
QPushButton *m_attachClearBtn = nullptr;
bool m_pttActive = false; bool m_pttActive = false;
QString m_voiceFilePath; QString m_voiceFilePath;
QString m_mediaDirBase; QString m_mediaDirBase;
QString m_channelIdForRecord; QString m_channelIdForRecord;
QString m_pendingAttachSrc;
QString m_pendingAttachDest;
QString m_pendingAttachName;
qint64 m_pendingAttachSize = 0;
}; };

Loading…
Cancel
Save