diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 091a0ebb..83363868 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -59,6 +59,7 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L val filePath: String = "", val waveform: List = emptyList(), val voiceDurationMs: Int = 0, val fileSize: Long = 0, val imageWidth: Int = 0, val imageHeight: Int = 0, + val videoWidth: Int = 0, val videoHeight: Int = 0, val videoDurationMs: Int = 0, val downloadState: String = "" /* "", "downloading", "downloaded", "error" */) data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false, @@ -335,6 +336,21 @@ class ChatRepository { } catch (_: Exception) {} } + var videoWidth = 0 + var videoHeight = 0 + var videoDurationMs = 0 + if (contentType.startsWith("video/")) { + try { + for (part in text.substringAfter('|').split("|")) { + when { + part.startsWith("w=") -> videoWidth = part.removePrefix("w=").toIntOrNull() ?: 0 + part.startsWith("h=") -> videoHeight = part.removePrefix("h=").toIntOrNull() ?: 0 + part.startsWith("dur=") -> videoDurationMs = part.removePrefix("dur=").toIntOrNull() ?: 0 + } + } + } catch (_: Exception) {} + } + list.add(Message( id = obj.getLong("id"), author = obj.getString("author"), @@ -348,6 +364,9 @@ class ChatRepository { fileSize = fileSize, imageWidth = imageWidth, imageHeight = imageHeight, + videoWidth = videoWidth, + videoHeight = videoHeight, + videoDurationMs = videoDurationMs, downloadState = downloadState )) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index d8de7fc4..e5a07c19 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -265,35 +265,39 @@ class ConfigProvider(private val context: Context) { return "$dir/utun.cfg" } - fun loadServersFromConfig(): List { + fun loadServersFromConfig(includeCommented: Boolean = false): List { val file = File(configFilePath()) if (!file.exists()) return emptyList() data class RawSrv(val ifName: String, val family: String, val port: Int, val protocol: String) val raw = mutableListOf() try { - val lines = file.readLines() - var ifName = ""; var family = ""; var addrPort = 0; var proto = "tcp"; var sectionOpen = false - for (line in lines) { + var ifName = ""; var family = ""; var addrPort = 0; var proto = "tcp" + var sectionOpen = false; var sectionCommented = false + for (line in file.readLines()) { val trimmed = line.trim() - if (trimmed.startsWith("[server:") && trimmed.endsWith("]")) { - if (sectionOpen && ifName.isNotEmpty() && addrPort > 0) + val commented = trimmed.startsWith("#") + val body = if (commented) trimmed.removePrefix("#").trim() else trimmed + if (body.startsWith("[server:") && body.endsWith("]")) { + if (sectionOpen && ifName.isNotEmpty() && addrPort > 0 && (!sectionCommented || includeCommented)) raw.add(RawSrv(ifName, family, addrPort, proto)) - val section = trimmed.removeSurrounding("[", "]").removePrefix("server:") + sectionOpen = false; sectionCommented = commented; addrPort = 0; proto = "tcp"; ifName = ""; family = "" + if (commented && !includeCommented) continue + val section = body.removeSurrounding("[", "]").removePrefix("server:") val lastUnderscore = section.lastIndexOf('_') if (lastUnderscore > 0) { ifName = section.substring(0, lastUnderscore) family = section.substring(lastUnderscore + 1) - } else { ifName = ""; family = "" } - addrPort = 0; proto = "tcp"; sectionOpen = true - } else if (trimmed.startsWith("addr=")) { - val addr = trimmed.removePrefix("addr=") + } + sectionOpen = true + } else if (sectionOpen && body.startsWith("addr=")) { + val addr = body.removePrefix("addr=") val colonIdx = addr.lastIndexOf(':') if (colonIdx >= 0) addrPort = addr.substring(colonIdx + 1).toIntOrNull() ?: 0 - } else if (trimmed.startsWith("transport=")) { - proto = if (trimmed.removePrefix("transport=") == "udp") "udp" else "tcp" + } else if (sectionOpen && body.startsWith("transport=")) { + proto = if (body.removePrefix("transport=") == "udp") "udp" else "tcp" } } - if (sectionOpen && ifName.isNotEmpty() && addrPort > 0) + if (sectionOpen && ifName.isNotEmpty() && addrPort > 0 && (!sectionCommented || includeCommented)) raw.add(RawSrv(ifName, family, addrPort, proto)) } catch (_: Exception) { return emptyList() } @@ -308,15 +312,15 @@ class ConfigProvider(private val context: Context) { } } - fun saveServersToConfig(servers: List) { - val text = buildConfigFromServers(servers) + fun saveServersToConfig(servers: List, commented: List = emptyList()) { + val text = buildConfigFromServers(servers, commented) File(configFilePath()).writeText(text) - LogManager.addLog("INFO", "CFG", "saveServersToConfig: ${servers.size} servers written" + - servers.joinToString(", ") { "${it.ifName}=${it.protocol}" }) + LogManager.addLog("INFO", "CFG", "saveServersToConfig: ${servers.size} active, ${commented.size} commented" + + servers.joinToString(", ") { "${it.ifName}=${it.protocol}:${it.port}" }) } /** Build complete INI text from server list (without touching config file) */ - private fun buildConfigFromServers(servers: List): String { + private fun buildConfigFromServers(servers: List, commented: List = emptyList()): String { val pubKey = getString("node.public_key") ?: "" val privKey = getString("node.private_key") ?: "" val nodeName = getString("node.name") ?: getDeviceName() @@ -330,15 +334,8 @@ class ConfigProvider(private val context: Context) { appendLine("db_sync_enabled=1") appendLine("chatserver_enabled=1") appendLine() - for (srv in servers) { - val family = if (srv.bindIp.contains(":")) "v6" else "v4" - appendLine("[server:${srv.ifName}_$family]") - appendLine("addr=${if (family == "v6") "[::]" else srv.bindIp.ifBlank { "0.0.0.0" }}:${srv.port}") - appendLine("type=public") - appendLine("transport=${srv.protocol}") - if (family == "v6") appendLine("netif=${srv.ifName}") - appendLine() - } + for (srv in servers) appendServerSection(this, srv, false) + for (srv in commented) appendServerSection(this, srv, true) appendLine("[allowed_keys]") appendLine("allow_all=yes") appendLine() @@ -386,6 +383,19 @@ class ConfigProvider(private val context: Context) { } } + /** Append one [server:...] section; comment=true prefixes each line with '#'. */ + private fun appendServerSection(sb: StringBuilder, srv: ServerEntry, comment: Boolean) { + val family = if (srv.bindIp.contains(":")) "v6" else "v4" + val p = if (comment) "#" else "" + val addr = if (family == "v6") "[::]" else srv.bindIp.ifBlank { "0.0.0.0" } + sb.appendLine("${p}[server:${srv.ifName}_$family]") + sb.appendLine("${p}addr=$addr:${srv.port}") + sb.appendLine("${p}type=public") + sb.appendLine("${p}transport=${srv.protocol}") + if (family == "v6") sb.appendLine("${p}netif=${srv.ifName}") + sb.appendLine() + } + /** Return all currently available network interface names (for dropdown) */ fun getAvailableInterfaces(): List> = runBlocking { val list = mutableListOf>() @@ -413,11 +423,17 @@ class ConfigProvider(private val context: Context) { fun buildConfigText(): String { if (getConnectionMode() == "auto") { val cfgFile = File(configFilePath()) - val saved = if (cfgFile.exists()) loadServersFromConfig() else emptyList() + val saved = if (cfgFile.exists()) loadServersFromConfig(includeCommented = true) else emptyList() val detected = runBlocking { detectInterfaces(saved) } LogManager.addLog("INFO", "CFG", "buildConfigText: auto mode, detected ${detected.size} interfaces" + - detected.joinToString(", ") { "${it.ifName}=${it.protocol}" }) - saveServersToConfig(detected) + detected.joinToString(", ") { "${it.ifName}=${it.protocol}:${it.port}" }) + val upKeys = detected.map { it.ifName to it.bindIp.contains(":") }.toSet() + val down = saved.filter { (it.ifName to it.bindIp.contains(":")) !in upKeys } + if (down.isNotEmpty()) { + LogManager.addLog("INFO", "CFG", "buildConfigText: ${down.size} interfaces down, port preserved" + + down.joinToString(", ") { "${it.ifName}=${it.port}" }) + } + saveServersToConfig(detected, down) return buildConfigFromServers(detected) } val cfgFile = File(configFilePath()) 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 516dcac6..8b165844 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 @@ -3,6 +3,7 @@ package com.utun.chat.ui.components import android.Manifest import android.content.pm.PackageManager import android.graphics.Bitmap +import android.icu.text.BreakIterator import android.media.MediaMetadataRetriever import androidx.core.content.ContextCompat import androidx.activity.compose.rememberLauncherForActivityResult @@ -43,6 +44,36 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +private fun isEmojiCodePoint(cp: Int): Boolean = + (cp in 0x1F000..0x1FAFF) || + (cp in 0x2600..0x27BF) || + (cp in 0x2300..0x23FF) || + (cp in 0x2B00..0x2BFF) || + (cp in 0xFE00..0xFE0F) || + cp == 0x200D || cp == 0x20E3 + +private fun detectEmojiOnly(text: String): Int { + val t = text.trim() + if (t.isEmpty()) return 0 + val it = BreakIterator.getCharacterInstance(java.util.Locale.US) + it.setText(t) + var count = 0 + var start = it.first() + var end = it.next() + while (end != BreakIterator.DONE) { + val cluster = t.substring(start, end) + if (!cluster.isBlank()) { + val allEmoji = cluster.codePoints().allMatch { cp -> isEmojiCodePoint(cp) } + if (!allEmoji) return 0 + count++ + if (count > 3) return 0 + } + start = end + end = it.next() + } + return if (count in 1..3) count else 0 +} + @Composable fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(), onPlayVoice: (String, Int) -> Unit = { _, _ -> }, @@ -63,6 +94,21 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = val isMedia = message.contentType.isNotEmpty() && !isVoice && message.contentType != "text/plain" val isActive = isVoice && playbackState.filePath == message.filePath + val emojiCount = if (isVoice || isVideo || isImage || isMedia) 0 else detectEmojiOnly(message.text) + if (emojiCount > 0) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = alignment + ) { + Text( + message.text.trim(), + fontSize = if (emojiCount == 1) 100.sp else 51.sp, + color = textColor + ) + } + return + } + Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp), horizontalArrangement = alignment @@ -332,12 +378,20 @@ private fun VideoBubble(message: Message, textColor: Color, maxLines = 1, overflow = TextOverflow.Ellipsis) Spacer(Modifier.height(2.dp)) Text( - "${formatFileSize(message.fileSize)} · " + when { - downloading -> "Downloading..." - isError -> "Failed, tap to retry" - !downloaded -> "Tap to download" - else -> "Tap to play" - }, + buildString { + if (message.videoWidth > 0 && message.videoHeight > 0) + append("${message.videoWidth}×${message.videoHeight} · ") + val sz = formatFileSize(message.fileSize) + if (sz.isNotEmpty()) append("$sz · ") + if (message.fileSize > 0 && message.videoDurationMs > 0) + append("${formatBitrate(message.fileSize, message.videoDurationMs)} · ") + append(when { + downloading -> "Downloading..." + isError -> "Failed, tap to retry" + !downloaded -> "Tap to download" + else -> "Tap to play" + }) + }.trimEnd(), fontSize = 12.sp, color = textColor.copy(alpha = 0.5f) ) } @@ -415,6 +469,12 @@ private fun formatFileSize(bytes: Long): String { return "%.1fGB".format(gb) } +private fun formatBitrate(bytes: Long, durMs: Int): String { + if (bytes <= 0 || durMs <= 0) return "" + val kbps = bytes * 8 / durMs + return if (kbps >= 1000) "%.1fMbps".format(kbps / 1000.0) else "${kbps}kbps" +} + private fun formatDurationMs(ms: Int): String { val secs = ms / 1000 return "%d:%02d".format(secs / 60, secs % 60) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt index 6a305a5c..07fb29bf 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt @@ -17,9 +17,12 @@ import androidx.compose.ui.platform.LocalDensity private const val MAX_ZOOM = 4f -private fun clampOffset(scale: Float, o: Offset, cW: Float, cH: Float): Offset { - val mx = ((cW * scale - cW) / 2f).coerceAtLeast(0f) - val my = ((cH * scale - cH) / 2f).coerceAtLeast(0f) +private fun clampOffset(scale: Float, o: Offset, cW: Float, cH: Float, iw: Float, ih: Float): Offset { + val fit = minOf(cW / iw, cH / ih) + val dw = iw * fit * scale + val dh = ih * fit * scale + val mx = ((dw - cW) / 2f).coerceAtLeast(0f) + val my = ((dh - cH) / 2f).coerceAtLeast(0f) return Offset(o.x.coerceIn(-mx, mx), o.y.coerceIn(-my, my)) } @@ -32,9 +35,11 @@ fun ZoomableImage(bitmap: Bitmap, modifier: Modifier = Modifier, contentScale: C val density = LocalDensity.current val cW = with(density) { maxWidth.toPx() } val cH = with(density) { maxHeight.toPx() } + val iw = bitmap.width.toFloat() + val ih = bitmap.height.toFloat() LaunchedEffect(cW, cH) { - zoomOffset = clampOffset(zoomScale, zoomOffset, cW, cH) + zoomOffset = clampOffset(zoomScale, zoomOffset, cW, cH, iw, ih) } Image( @@ -65,7 +70,7 @@ fun ZoomableImage(bitmap: Bitmap, modifier: Modifier = Modifier, contentScale: C zoomOffset.y * z + rel.y * (1f - z) + pan.y ) zoomScale = new - zoomOffset = clampOffset(new, raw, cW, cH) + zoomOffset = clampOffset(new, raw, cW, cH, iw, ih) } } ) 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 92adbaaf..96ca2da6 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,6 +1,7 @@ package com.utun.chat.viewmodel import android.content.Context +import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri import android.util.Log @@ -19,6 +20,8 @@ import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream +private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp") + class ChatViewModel : ViewModel() { data class PlaybackState( @@ -608,11 +611,33 @@ class ChatViewModel : ViewModel() { fun sendAttachment(filePath: String) { val r = repo ?: return val ch = _currentChannel.value ?: return - r.sendAttachment(ch.id, filePath) - viewModelScope.launch { - delay(500) - refreshMessages(ch.id) - refreshChannels() + val ext = filePath.substringAfterLast('.', "").lowercase() + if (ext in IMAGE_EXTENSIONS) { + viewModelScope.launch { + val dims = withContext(Dispatchers.IO) { + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(filePath, opts) + opts.outWidth to opts.outHeight + } + val (w, h) = dims + if (w > 0 && h > 0) { + LogManager.addLog("INFO", "VM", "sendAttachment(image) ch=${ch.id} ext=$ext ${w}x${h}") + r.sendPhoto(ch.id, filePath, w, h) + } else { + LogManager.addLog("WARN", "VM", "image decode failed, sent as file: $filePath") + r.sendAttachment(ch.id, filePath) + } + delay(500) + refreshMessages(ch.id) + refreshChannels() + } + } else { + r.sendAttachment(ch.id, filePath) + viewModelScope.launch { + delay(500) + refreshMessages(ch.id) + refreshChannels() + } } } diff --git a/tools/chatgui-android/libutun_lite/photo_sender.c b/tools/chatgui-android/libutun_lite/photo_sender.c index ef950021..fd9497ce 100644 --- a/tools/chatgui-android/libutun_lite/photo_sender.c +++ b/tools/chatgui-android/libutun_lite/photo_sender.c @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -34,7 +35,17 @@ int photo_send(const char* channel_id, const char* src_file_path, const char* db char original_basename[256]; snprintf(original_basename, sizeof(original_basename), "%s", fname); char* dot = strrchr(fname, '.'); - if (dot) *dot = '\0'; + char ext[32] = ""; + if (dot) { snprintf(ext, sizeof(ext), "%s", dot + 1); *dot = '\0'; } + for (char* p = ext; *p; p++) *p = (char)tolower((unsigned char)*p); + + const char* content_type = "image/jpeg"; + const char* use_ext = "jpg"; + if (strcmp(ext, "png") == 0) { content_type = "image/png"; use_ext = "png"; } + else if (strcmp(ext, "gif") == 0) { content_type = "image/gif"; use_ext = "gif"; } + else if (strcmp(ext, "bmp") == 0) { content_type = "image/bmp"; use_ext = "bmp"; } + else if (strcmp(ext, "webp") == 0) { content_type = "image/webp"; use_ext = "webp"; } + else if (strcmp(ext, "jpeg") == 0) { use_ext = "jpeg"; } time_t t = time(NULL); struct tm tm_buf; localtime_r(&t, &tm_buf); @@ -57,14 +68,14 @@ int photo_send(const char* channel_id, const char* src_file_path, const char* db } char media_file[1024]; - snprintf(media_file, sizeof(media_file), "%s/%s_%s_%s.jpg", media_dir, dt, fname, suffix); + snprintf(media_file, sizeof(media_file), "%s/%s_%s_%s.%s", media_dir, dt, fname, suffix, use_ext); 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); - snprintf(req->content_type, sizeof(req->content_type), "image/jpeg"); + snprintf(req->content_type, sizeof(req->content_type), "%s", content_type); snprintf(req->media_src, sizeof(req->media_src), "%s", src_file_path); snprintf(req->media_dest, sizeof(req->media_dest), "%s", media_file); req->media_copy = 1; @@ -76,9 +87,9 @@ int photo_send(const char* channel_id, const char* src_file_path, const char* db req->data_len = (uint32_t)datalen; req->timestamp = 0; - DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "photo_send: ch=%s src=%s dst=%s name=%s size=%lld %dx%d", - channel_id, src_file_path, media_file, original_basename, (long long)st.st_size, - width, height); + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "photo_send: ch=%s ct=%s src=%s dst=%s name=%s size=%lld %dx%d", + channel_id, content_type, src_file_path, media_file, original_basename, + (long long)st.st_size, width, height); uasync_post(ua, chat_core_submit_trampoline, req); return 0; diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 937ddbf0..6b1270ab 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -123,6 +123,7 @@ add_executable(vibechat src/media_blocks.cpp src/videoplayer_engine.cpp src/videoplayer_window.cpp + src/imageviewer_window.cpp src/audiodevicesettingspage.cpp src/storagesettingspage.cpp src/connmonitorwindow.cpp diff --git a/tools/chatgui/src/imageviewer_window.cpp b/tools/chatgui/src/imageviewer_window.cpp new file mode 100644 index 00000000..4d1314ff --- /dev/null +++ b/tools/chatgui/src/imageviewer_window.cpp @@ -0,0 +1,234 @@ +#include "imageviewer_window.h" +#include "../../lib/debug_config.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +ImageViewerWindow::ImageViewerWindow(QWidget *parent) + : QWidget(parent) +{ + setWindowTitle(QStringLiteral("Фото")); + setAttribute(Qt::WA_DeleteOnClose, false); + setMinimumSize(320, 240); + resize(800, 600); + setMouseTracking(true); + + m_closeBtn = new QPushButton(QStringLiteral("✕"), this); + m_closeBtn->setToolTip(QStringLiteral("Закрыть")); + m_closeBtn->setCursor(Qt::ArrowCursor); + m_closeBtn->setStyleSheet( + "QPushButton { background: rgba(0,0,0,0.5); color: white; border: none; " + "border-radius: 4px; font-size: 14px; }" + "QPushButton:hover { background: rgba(255,255,255,0.25); }"); + connect(m_closeBtn, &QPushButton::clicked, this, &ImageViewerWindow::close); + + m_closeEffect = new QGraphicsOpacityEffect(m_closeBtn); + m_closeBtn->setGraphicsEffect(m_closeEffect); + m_closeEffect->setOpacity(0.0); + + m_closeFade = new QPropertyAnimation(m_closeEffect, "opacity", this); + m_closeFade->setEasingCurve(QEasingCurve::InOutQuad); + + m_hintLabel = new QLabel(QStringLiteral("Ctrl+колесо — зум"), this); + m_hintLabel->setAlignment(Qt::AlignCenter); + m_hintLabel->setAttribute(Qt::WA_TransparentForMouseEvents); + m_hintLabel->setStyleSheet( + "QLabel { background: rgba(0,0,0,0.6); color: white; " + "border-radius: 12px; padding: 6px 16px; font-size: 13px; }"); + + m_hintEffect = new QGraphicsOpacityEffect(m_hintLabel); + m_hintLabel->setGraphicsEffect(m_hintEffect); + m_hintEffect->setOpacity(0.0); + + m_hintFade = new QPropertyAnimation(m_hintEffect, "opacity", this); + m_hintFade->setEasingCurve(QEasingCurve::InOutQuad); + + auto finishHide = [this]() { + if (m_closeEffect->opacity() < 0.01) m_closeBtn->hide(); + if (m_hintEffect->opacity() < 0.01) m_hintLabel->hide(); + }; + connect(m_closeFade, &QPropertyAnimation::finished, this, finishHide); + connect(m_hintFade, &QPropertyAnimation::finished, this, finishHide); + + m_hideTimer = new QTimer(this); + m_hideTimer->setSingleShot(true); + m_hideTimer->setInterval(1000); + connect(m_hideTimer, &QTimer::timeout, this, &ImageViewerWindow::hideOverlay); + + m_closeBtn->hide(); + m_hintLabel->hide(); +} + +void ImageViewerWindow::loadFile(const QString& path) { + QImageReader reader(path); + reader.setAutoTransform(true); + QImage im = reader.read(); + if (im.isNull()) { + DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "ImageViewer: cannot load %s", qPrintable(path)); + m_image = QPixmap(); + update(); + return; + } + m_image = QPixmap::fromImage(im); + resetView(); + update(); +} + +void ImageViewerWindow::resetView() { + m_scale = 1.0; + m_offset = QPointF(0, 0); + m_dragging = false; +} + +void ImageViewerWindow::clampOffset() { + if (m_image.isNull()) { m_offset = QPointF(0, 0); return; } + double baseW = (double)m_image.width() / width(); + double baseH = (double)m_image.height() / height(); + double effScale = (1.0 / qMax(baseW, baseH)) * m_scale; + double dw = m_image.width() * effScale; + double dh = m_image.height() * effScale; + double mx = qMax((dw - width()) / 2.0, 0.0); + double my = qMax((dh - height()) / 2.0, 0.0); + m_offset.setX(qBound(-mx, m_offset.x(), mx)); + m_offset.setY(qBound(-my, m_offset.y(), my)); +} + +void ImageViewerWindow::paintEvent(QPaintEvent*) { + QPainter p(this); + p.fillRect(rect(), QColor(20, 20, 22)); + + if (m_image.isNull()) { + p.setPen(QColor(150, 150, 150)); + p.drawText(rect(), Qt::AlignCenter, QStringLiteral("Не удалось загрузить фото")); + return; + } + + /* базовое вписывание: scale так, чтобы изображение влезло целиком */ + double baseW = (double)m_image.width() / width(); + double baseH = (double)m_image.height() / height(); + double fit = 1.0 / qMax(baseW, baseH); + double effScale = fit * m_scale; + + double dw = m_image.width() * effScale; + double dh = m_image.height() * effScale; + QPointF topLeft((width() - dw) / 2.0 + m_offset.x(), + (height() - dh) / 2.0 + m_offset.y()); + + p.setRenderHint(QPainter::SmoothPixmapTransform); + p.drawPixmap(QRectF(topLeft, QSizeF(dw, dh)), m_image, QRectF(m_image.rect())); +} + +void ImageViewerWindow::wheelEvent(QWheelEvent* e) { + if (!(e->modifiers() & Qt::ControlModifier)) { e->ignore(); return; } + if (m_image.isNull()) return; + + double factor = (e->angleDelta().y() > 0) ? 1.2 : (1.0 / 1.2); + double newScale = qBound(1.0, m_scale * factor, 8.0); + if (qFuzzyCompare(newScale, m_scale)) return; + + /* зум в точку курсора */ + QPointF cursor = e->position(); + QPointF center((double)width() / 2.0, (double)height() / 2.0); + double ratio = newScale / m_scale; + m_offset = (m_offset - (cursor - center)) * ratio + (cursor - center); + m_scale = newScale; + clampOffset(); + update(); + e->accept(); +} + +void ImageViewerWindow::mousePressEvent(QMouseEvent* e) { + if (e->button() == Qt::LeftButton && m_scale > 1.0) { + m_dragging = true; + m_dragStart = e->position(); + m_offsetStart = m_offset; + setCursor(Qt::ClosedHandCursor); + e->accept(); + return; + } + e->ignore(); +} + +void ImageViewerWindow::mouseMoveEvent(QMouseEvent* e) { + showOverlay(); + if (m_dragging) { + m_offset = m_offsetStart + (e->position() - m_dragStart); + clampOffset(); + update(); + e->accept(); + } +} + +void ImageViewerWindow::mouseReleaseEvent(QMouseEvent* e) { + if (m_dragging && e->button() == Qt::LeftButton) { + m_dragging = false; + unsetCursor(); + e->accept(); + } +} + +void ImageViewerWindow::mouseDoubleClickEvent(QMouseEvent* e) { + if (e->button() == Qt::LeftButton) { + resetView(); + update(); + e->accept(); + } +} + +void ImageViewerWindow::keyPressEvent(QKeyEvent* e) { + if (e->key() == Qt::Key_Escape) { + close(); + e->accept(); + return; + } + QWidget::keyPressEvent(e); +} + +void ImageViewerWindow::resizeEvent(QResizeEvent* e) { + clampOffset(); + m_closeBtn->setGeometry(width() - 48, 8, 40, 32); + m_closeBtn->raise(); + QSize hs = m_hintLabel->sizeHint(); + m_hintLabel->setGeometry((width() - hs.width()) / 2, height() - hs.height() - 24, + hs.width(), hs.height()); + m_hintLabel->raise(); + QWidget::resizeEvent(e); +} + +void ImageViewerWindow::showOverlay() { + m_closeBtn->show(); + m_hintLabel->show(); + fadeTo(m_closeFade, m_closeEffect, 100, 1.0); + fadeTo(m_hintFade, m_hintEffect, 100, 1.0); + m_hideTimer->start(); +} + +void ImageViewerWindow::hideOverlay() { + fadeTo(m_closeFade, m_closeEffect, 300, 0.0); + fadeTo(m_hintFade, m_hintEffect, 300, 0.0); +} + +void ImageViewerWindow::fadeTo(QPropertyAnimation* anim, QGraphicsOpacityEffect* eff, + int durMs, double target) { + anim->stop(); + anim->setDuration(durMs); + anim->setStartValue(eff->opacity()); + anim->setEndValue(target); + anim->start(); +} + +void ImageViewerWindow::closeEvent(QCloseEvent* e) { + emit closed(); + QWidget::closeEvent(e); +} diff --git a/tools/chatgui/src/imageviewer_window.h b/tools/chatgui/src/imageviewer_window.h new file mode 100644 index 00000000..48101f99 --- /dev/null +++ b/tools/chatgui/src/imageviewer_window.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +class QPushButton; +class QLabel; +class QTimer; +class QPropertyAnimation; +class QGraphicsOpacityEffect; + +class ImageViewerWindow : public QWidget { + Q_OBJECT +public: + explicit ImageViewerWindow(QWidget *parent = nullptr); + void loadFile(const QString& path); + +signals: + void closed(); + +protected: + void paintEvent(QPaintEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void mouseDoubleClickEvent(QMouseEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void closeEvent(QCloseEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + +private: + void resetView(); + void clampOffset(); + void showOverlay(); + void hideOverlay(); + void fadeTo(QPropertyAnimation* anim, QGraphicsOpacityEffect* eff, + int durMs, double target); + + QPixmap m_image; + double m_scale = 1.0; + QPointF m_offset; /* смещение изображения в пикселях виджета */ + bool m_dragging = false; + QPointF m_dragStart; + QPointF m_offsetStart; + QPushButton* m_closeBtn = nullptr; + QGraphicsOpacityEffect* m_closeEffect = nullptr; + QPropertyAnimation* m_closeFade = nullptr; + QLabel* m_hintLabel = nullptr; + QGraphicsOpacityEffect* m_hintEffect = nullptr; + QPropertyAnimation* m_hintFade = nullptr; + QTimer* m_hideTimer = nullptr; +}; diff --git a/tools/chatgui/src/inputbar.cpp b/tools/chatgui/src/inputbar.cpp index b26dc333..bb2208bb 100644 --- a/tools/chatgui/src/inputbar.cpp +++ b/tools/chatgui/src/inputbar.cpp @@ -28,6 +28,12 @@ extern "C" { #include "video/video.h" } +static bool isImageFile(const QString& suffix) { + QString s = suffix.toLower(); + return s == "jpg" || s == "jpeg" || s == "png" || s == "gif" || + s == "bmp" || s == "webp"; +} + static QPushButton *makeIconBtn(const QIcon& icon, const QString &tooltip, QWidget *parent) { auto *btn = new QPushButton(parent); btn->setIcon(icon); @@ -236,7 +242,11 @@ void InputBar::onSendClicked() { if (!hasAttach && text.isEmpty()) return; if (hasAttach) { - if (m_pendingAttachAsVideo) { + if (m_pendingAttachAsImage) { + GUI_INFO("send: image=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName)); + emit sendImageAttachment(m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName); + m_attachPreview->hide(); + } else if (m_pendingAttachAsVideo) { GUI_INFO("send: video=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName)); emit sendVideoAttachment(m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName); m_attachPreviewLabel->setText(QString::fromUtf8("\xF0\x9F\x8E\xAC Перекодирование… 0%")); @@ -252,6 +262,7 @@ void InputBar::onSendClicked() { m_pendingAttachName.clear(); m_pendingAttachSize = 0; m_pendingAttachAsVideo = false; + m_pendingAttachAsImage = false; } if (!text.isEmpty()) { emit sendMessage(text); @@ -347,8 +358,9 @@ void InputBar::onAttachClicked() { QString ts = now.toString("yyyyMMdd-HHmmss"); int rnd = rand() & 0xFFFF; + bool asImage = isImageFile(fi.suffix()); bool asVideo = false; - if (video_probe(srcPath.toUtf8().constData())) { + if (!asImage && video_probe(srcPath.toUtf8().constData())) { QMessageBox box(this); box.setWindowTitle(QString::fromUtf8("Отправить видео")); box.setText(QString::fromUtf8("Файл содержит видео. Как отправить?")); @@ -378,14 +390,15 @@ void InputBar::onAttachClicked() { m_pendingAttachName = fi.fileName(); m_pendingAttachSize = fi.size(); m_pendingAttachAsVideo = asVideo; + m_pendingAttachAsImage = asImage; 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 video=%d", - qPrintable(srcPath), (long long)fi.size(), asVideo ? 1 : 0); + GUI_INFO("InputBar::onAttachClicked: pending %s size=%lld video=%d image=%d", + qPrintable(srcPath), (long long)fi.size(), asVideo ? 1 : 0, asImage ? 1 : 0); } void InputBar::clearPendingAttachment() { @@ -394,6 +407,7 @@ void InputBar::clearPendingAttachment() { m_pendingAttachName.clear(); m_pendingAttachSize = 0; m_pendingAttachAsVideo = false; + m_pendingAttachAsImage = false; m_videoProgressTimer->stop(); m_attachPreview->hide(); } diff --git a/tools/chatgui/src/inputbar.h b/tools/chatgui/src/inputbar.h index e89c5fea..46698b16 100644 --- a/tools/chatgui/src/inputbar.h +++ b/tools/chatgui/src/inputbar.h @@ -26,6 +26,8 @@ signals: const QString& displayName); void sendVideoAttachment(const QString& mediaSrc, const QString& mediaDest, const QString& displayName); + void sendImageAttachment(const QString& mediaSrc, const QString& mediaDest, + const QString& displayName); protected: bool eventFilter(QObject *obj, QEvent *event) override; @@ -68,5 +70,6 @@ private: QString m_pendingAttachName; qint64 m_pendingAttachSize = 0; bool m_pendingAttachAsVideo = false; + bool m_pendingAttachAsImage = false; QTimer *m_videoProgressTimer = nullptr; }; diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index d06b3b44..c878b9c4 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -10,6 +10,7 @@ #include "channelsettingsdialog.h" #include "connmonitorwindow.h" #include "videoplayer_window.h" +#include "imageviewer_window.h" #include "creategroupdialog.h" #include "invite_link.h" #include "sound_manager.h" @@ -379,6 +380,7 @@ void MainWindow::setupConnects() { m_db->getUnreadCount(channelId, lastReadMsgId)); }); connect(m_messageList, &MessageList::openVideoRequested, this, &MainWindow::openVideo); + connect(m_messageList, &MessageList::openImageRequested, this, &MainWindow::openImage); } void MainWindow::setupTray() { @@ -497,6 +499,21 @@ void MainWindow::openVideo(const QString& filePath) { m_videoPlayer->playFile(filePath); } +void MainWindow::openImage(const QString& filePath) { + if (!m_imageViewer) { + m_imageViewer = new ImageViewerWindow(nullptr); + connect(m_imageViewer, &ImageViewerWindow::closed, this, [this] { + m_imageViewer->deleteLater(); + m_imageViewer = nullptr; + }); + } else { + m_imageViewer->raise(); + m_imageViewer->activateWindow(); + } + m_imageViewer->loadFile(filePath); + m_imageViewer->showFullScreen(); +} + void MainWindow::onMessageReceived(const char* ch_id, int ch_id_len, uint64_t author) { QString cid = QString::fromUtf8(ch_id, ch_id_len); if (m_db && author != m_db->myNodeId()) diff --git a/tools/chatgui/src/mainwindow.h b/tools/chatgui/src/mainwindow.h index 52588a1c..0396bd05 100644 --- a/tools/chatgui/src/mainwindow.h +++ b/tools/chatgui/src/mainwindow.h @@ -15,6 +15,7 @@ class UtunNode; class AudioRecorder; class ConnMonitorWindow; class VideoPlayerWindow; +class ImageViewerWindow; struct AutoPlayItem { QString channelId; @@ -45,6 +46,7 @@ private slots: void showSettings(); void showConnMonitor(); void openVideo(const QString& filePath); + void openImage(const QString& filePath); void showChannelSettings(const QString& channelId); void autoPlayTimerTick(); @@ -87,6 +89,7 @@ private: AudioRecorder *m_recorder = nullptr; ConnMonitorWindow *m_connMonitor = nullptr; VideoPlayerWindow *m_videoPlayer = nullptr; + ImageViewerWindow *m_imageViewer = nullptr; QQueue m_autoPlayQueue; QTimer* m_autoPlayTimer = nullptr; diff --git a/tools/chatgui/src/messagedelegate.cpp b/tools/chatgui/src/messagedelegate.cpp index 116ac7b2..cd89bba2 100644 --- a/tools/chatgui/src/messagedelegate.cpp +++ b/tools/chatgui/src/messagedelegate.cpp @@ -22,6 +22,7 @@ #include #include #include +#include extern "C" { #include "../transport/gui_bridge.h" @@ -106,6 +107,32 @@ bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model, return true; } + if (L.isImage) { + int dlState = index.data(MsgFileDownloadStateRole).toInt(); + qint64 msgId = index.data(MsgIdRole).toLongLong(); + QString chId = index.data(MsgChannelIdRole).toString(); + + if (dlState == 2) { /* скачано — открыть просмотрщик */ + QString fp = index.data(MsgVoiceFileRole).toString(); + if (!fp.isEmpty() && QFile::exists(fp) && m_imageOpenCb) { + m_imageOpenCb(fp); + return true; + } + return false; + } + if (dlState == 1) return false; /* скачивается */ + + if (chId.isEmpty() || msgId <= 0) return false; + struct attachment_dl_req* req = (struct attachment_dl_req*) + u_malloc(sizeof(struct attachment_dl_req)); + if (!req) return false; + memset(req, 0, sizeof(*req)); + strncpy(req->channel_id, chId.toUtf8().constData(), sizeof(req->channel_id) - 1); + req->msg_id = msgId; + gui_bridge_post_uasync_fn(chat_core_attachment_download_trampoline, req); + return true; + } + if (L.isFile) { int dlState = index.data(MsgFileDownloadStateRole).toInt(); qint64 msgId = index.data(MsgIdRole).toLongLong(); @@ -288,6 +315,13 @@ static QString formatFileSize(qint64 bytes) { return QString("%1GB").arg(gb, 0, 'f', 1); } +static QString formatBitrate(qint64 bytes, qint64 durMs) { + if (bytes <= 0 || durMs <= 0) return QString(); + qint64 kbps = bytes * 8 / durMs; + if (kbps >= 1000) return QString("%1Mbps").arg(kbps / 1000.0, 0, 'f', 1); + return QString("%1kbps").arg(kbps); +} + static int calcMaxLineWidth(const QFont &font, const QString &text, int availW) { int maxW = 0; int pos = 0; @@ -376,8 +410,9 @@ MessageDelegate::Layout MessageDelegate::calcLayout( if (L.isVideo) { int barW = 3, barPad = 4; int thW = 200, thH = 112; /* 16:9 превью */ + int infoH = 16; /* строка под пузырём: res·size·bitrate + время */ L.bubbleWidth = thW; - L.totalHeight = qMax(thH, kAvatar) + kItemGap; + L.totalHeight = qMax(thH, kAvatar) + infoH + kItemGap; int contentEdge = L.isOutgoing ? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)) @@ -386,6 +421,7 @@ MessageDelegate::Layout MessageDelegate::calcLayout( L.bubbleRect = QRect(bx, 0, thW, thH); L.videoThumbRect = L.bubbleRect; L.mediaBarRect = QRect(L.isOutgoing ? bx + thW + 1 : bx - barW - 1, 0, barW, thH); + L.statusRect = QRect(bx, thH + 2, thW, infoH - 2); int dlState = index.data(MsgFileDownloadStateRole).toInt(); int bd = index.data(MsgMediaBlocksDoneRole).toInt(); @@ -395,7 +431,42 @@ MessageDelegate::Layout MessageDelegate::calcLayout( if (dlState == 1) { L.progressBarRect = QRect(bx, thH - 3, thW, 3); } L.videoPlayBtnRect = QRect(bx + thW / 2 - 24, thH / 2 - 24, 48, 48); - L.videoDurBadgeRect = QRect(bx + thW - 116, thH - 26, 108, 18); + L.videoDurBadgeRect = QRect(bx + thW - 66, thH - 26, 58, 18); + + if (!L.narrow) { + int avatarY = L.bubbleRect.bottom() - kAvatar; + if (!L.isOutgoing) + L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar); + else + L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar); + } + return L; + } + + /* Image message detection */ + L.isImage = contentType.startsWith("image/"); + if (L.isImage) { + int barW = 3, barPad = 4; + int thW = 220, thH = 160; + L.bubbleWidth = thW; + L.totalHeight = qMax(thH, kAvatar) + kItemGap; + + int contentEdge = L.isOutgoing + ? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)) + : (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)); + int bx = L.isOutgoing ? (contentEdge - thW - barW - barPad) : contentEdge; + L.bubbleRect = QRect(bx, 0, thW, thH); + L.imageThumbRect = L.bubbleRect; + L.mediaBarRect = QRect(L.isOutgoing ? bx + thW + 1 : bx - barW - 1, 0, barW, thH); + + int dlState = index.data(MsgFileDownloadStateRole).toInt(); + int bd = index.data(MsgMediaBlocksDoneRole).toInt(); + int nb = index.data(MsgMediaNumBlocksRole).toInt(); + L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0; + if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb; + if (dlState == 1) { L.progressBarRect = QRect(bx, thH - 3, thW, 3); } + + L.imageBadgeRect = QRect(bx + thW - 150, thH - 26, 142, 18); if (!L.narrow) { int avatarY = L.bubbleRect.bottom() - kAvatar; @@ -790,15 +861,19 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio while (pos < trimmed.length()) { if (trimmed.at(pos).isSpace()) { ++pos; continue; } QString emojiStr; - for (const auto &cat : builtinEmojiSet()) + bool found = false; + for (const auto &cat : builtinEmojiSet()) { for (const auto &e : cat.items) { if (e.unicode.length() > trimmed.length() - pos) continue; if (QStringView(trimmed).mid(pos, e.unicode.length()) == e.unicode) { emojiStr = e.unicode; pos += e.unicode.length(); + found = true; break; } } + if (found) break; + } if (emojiStr.isEmpty()) { ++pos; continue; } bool animated = g_animatedEmojiMap.contains(emojiStr); @@ -1010,12 +1085,9 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio painter->drawPath(tri); } - /* бейдж: размер · длительность (m:ss) */ - qint64 fsz = index.data(MsgFileSizeRole).toLongLong(); - QString sizeStr = formatFileSize(fsz); + /* бейдж: длительность (m:ss) */ QString durStr = index.data(MsgVoiceDurationRole).toString(); - QString badge = durStr.isEmpty() ? sizeStr : sizeStr + " \xC2\xB7 " + durStr; - if (!badge.isEmpty()) { + if (!durStr.isEmpty()) { QFont durFont = option.font; durFont.setPointSize(durFont.pointSize() - 1); painter->setFont(durFont); @@ -1023,7 +1095,7 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio painter->setBrush(QColor(0, 0, 0, 160)); painter->drawRoundedRect(L.videoDurBadgeRect, 5, 5); painter->setPen(Qt::white); - painter->drawText(L.videoDurBadgeRect, Qt::AlignCenter, badge); + painter->drawText(L.videoDurBadgeRect, Qt::AlignCenter, durStr); } painter->restore(); @@ -1049,6 +1121,112 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); } } + /* строка под пузырём: res · size · bitrate + время */ + { + qint64 fsz = index.data(MsgFileSizeRole).toLongLong(); + qint64 durMs = index.data(MsgVideoDurationMsRole).toLongLong(); + int vw = index.data(MsgVideoWidthRole).toInt(); + int vh = index.data(MsgVideoHeightRole).toInt(); + QStringList info; + if (vw > 0 && vh > 0) info << QString("%1×%2").arg(vw).arg(vh); + QString sz = formatFileSize(fsz); + if (!sz.isEmpty()) info << sz; + QString br = formatBitrate(fsz, durMs); + if (!br.isEmpty()) info << br; + + QFont smallFont = option.font; + smallFont.setPointSize(smallFont.pointSize() - 2); + painter->setFont(smallFont); + painter->setPen(QColor(0x80, 0x80, 0x80)); + + if (!info.isEmpty()) + painter->drawText(L.statusRect, Qt::AlignLeft | Qt::AlignVCenter, info.join(" \xC2\xB7 ")); + painter->drawText(L.statusRect, Qt::AlignRight | Qt::AlignVCenter, + index.data(MsgTimeRole).toString()); + } + + painter->restore(); + return; + } + + if (L.isImage) { + drawBubble(painter, L, option); + if (!L.narrow) drawAvatar(painter, L, index); + + int dlState = index.data(MsgFileDownloadStateRole).toInt(); + QString filePath = index.data(MsgVoiceFileRole).toString(); + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing); + QPainterPath clip; + clip.addRoundedRect(L.imageThumbRect, kRadius, kRadius); + painter->setClipPath(clip); + + QPixmap img; + if (!filePath.isEmpty() && QFile::exists(filePath)) { + QImageReader reader(filePath); + reader.setAutoTransform(true); + reader.setScaledSize(L.imageThumbRect.size() * 2); + QImage im = reader.read(); + if (!im.isNull()) img = QPixmap::fromImage(im); + } + + if (!img.isNull()) { + painter->fillRect(L.imageThumbRect, QColor(20, 20, 24)); + QPixmap scaled = img.scaled(L.imageThumbRect.size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); + int dx = (L.imageThumbRect.width() - scaled.width()) / 2; + int dy = (L.imageThumbRect.height() - scaled.height()) / 2; + painter->drawPixmap(L.imageThumbRect.left() + dx, L.imageThumbRect.top() + dy, scaled); + } else { + painter->fillRect(L.imageThumbRect, QColor(30, 30, 35)); + QFont iconFont = option.font; + iconFont.setPointSize(iconFont.pointSize() + 10); + painter->setFont(iconFont); + painter->setPen(QColor(150, 150, 150)); + painter->drawText(L.imageThumbRect, Qt::AlignCenter, QString::fromUtf8("\xF0\x9F\x93\xB7")); + } + + /* бейдж: разрешение · размер */ + qint64 fsz = index.data(MsgFileSizeRole).toLongLong(); + int iw = index.data(MsgImageWidthRole).toInt(); + int ih = index.data(MsgImageHeightRole).toInt(); + QString res = (iw > 0 && ih > 0) ? QString("%1×%2").arg(iw).arg(ih) : QString(); + QString sizeStr = formatFileSize(fsz); + QString badge = res.isEmpty() ? sizeStr : res + " \xC2\xB7 " + sizeStr; + if (!badge.isEmpty()) { + QFont durFont = option.font; + durFont.setPointSize(durFont.pointSize() - 1); + painter->setFont(durFont); + painter->setPen(Qt::NoPen); + painter->setBrush(QColor(0, 0, 0, 160)); + painter->drawRoundedRect(L.imageBadgeRect, 5, 5); + painter->setPen(Qt::white); + painter->drawText(L.imageBadgeRect, Qt::AlignCenter, badge); + } + painter->restore(); + + /* progress overlay (скачивание) */ + if (dlState == 1) { + painter->setBrush(QColor(0, 0, 0, 140)); + painter->drawRect(L.imageThumbRect); + QFont pfont = option.font; + pfont.setPointSize(pfont.pointSize() + 2); + painter->setFont(pfont); + painter->setPen(Qt::white); + painter->drawText(L.imageThumbRect, Qt::AlignCenter, + QStringLiteral("%1%").arg((int)(L.progressFrac * 100))); + } + + /* media bar (левая кромка) */ + { QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80); + painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); } + if (L.progressBarRect.isValid()) { + painter->setBrush(QColor(0x44, 0x44, 0x44)); + painter->drawRoundedRect(L.progressBarRect, 2, 2); + int pw = (int)(L.progressBarRect.width() * L.progressFrac); + if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); } + } + painter->restore(); return; } diff --git a/tools/chatgui/src/messagedelegate.h b/tools/chatgui/src/messagedelegate.h index 2df1f8da..b94c04f9 100644 --- a/tools/chatgui/src/messagedelegate.h +++ b/tools/chatgui/src/messagedelegate.h @@ -34,6 +34,11 @@ enum MessageDataRole { MsgMediaBlocksDoneRole = Qt::UserRole + 28, MsgMediaNumBlocksRole = Qt::UserRole + 29, MsgVoiceDownloadPendingRole = Qt::UserRole + 30, + MsgImageWidthRole = Qt::UserRole + 31, + MsgImageHeightRole = Qt::UserRole + 32, + MsgVideoWidthRole = Qt::UserRole + 33, + MsgVideoHeightRole = Qt::UserRole + 34, + MsgVideoDurationMsRole = Qt::UserRole + 35, }; class MessageDelegate : public QStyledItemDelegate { @@ -53,6 +58,7 @@ public: static void stopAllAutoPlay(); void setVideoOpenCallback(std::function cb) { m_videoOpenCb = std::move(cb); } + void setImageOpenCallback(std::function cb) { m_imageOpenCb = std::move(cb); } private: struct Layout { @@ -76,6 +82,7 @@ private: bool isVoice = false; bool isFile = false; bool isVideo = false; + bool isImage = false; QRect voicePlayBtnRect; QRect voiceWaveformRect; QRect voiceDurationRect; @@ -88,9 +95,12 @@ private: QRect videoThumbRect; QRect videoPlayBtnRect; QRect videoDurBadgeRect; + QRect imageThumbRect; + QRect imageBadgeRect; }; std::function m_videoOpenCb; + std::function m_imageOpenCb; Layout calcLayout(const QStyleOptionViewItem &option, const QModelIndex &index) const; diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index a147acc4..5d9ca4e9 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -23,6 +23,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -220,10 +223,46 @@ static void setVoiceMessageRoles(QStandardItem* item, const QByteArray& data, QString d = QString("%1:%2").arg(s / 60).arg(s % 60, 2, 10, QChar('0')); item->setData(d, MsgVoiceDurationRole); } + item->setData((qlonglong)durMs, MsgVideoDurationMsRole); + } else if (p.startsWith("w=")) { + item->setData(p.mid(2).toInt(), MsgVideoWidthRole); + } else if (p.startsWith("h=")) { + item->setData(p.mid(2).toInt(), MsgVideoHeightRole); } } } + QJsonObject la = QJsonDocument::fromJson(localAttrs).object(); + QString st = la.value("st").toString(); + QString fp = la.value("fp").toString(); + int dlState = 0; + if (st == "fl" && !fp.isEmpty()) { + dlState = 2; + QString fullPath = mediaDirBase + "/" + channelId + "/" + fp; + item->setData(fullPath, MsgVoiceFileRole); + } + item->setData(dlState, MsgFileDownloadStateRole); + } + if (ct.startsWith("image/")) { + QString raw = QString::fromUtf8(body); + int pipePos = raw.indexOf('|'); + QString fname = (pipePos > 0) ? raw.left(pipePos) : raw; + item->setData(fname, MsgFileDisplayNameRole); + + if (pipePos > 0) { + QString meta = raw.mid(pipePos + 1); + QStringList parts = meta.split('|'); + if (parts.size() >= 1) + item->setData(parts[0].toULongLong(), MsgFileSizeRole); + if (parts.size() >= 3) + item->setData(parts[2].toUInt(), MsgMediaNumBlocksRole); + /* хвост: |w=|h= */ + for (const QString& p : parts) { + if (p.startsWith("w=")) item->setData(p.mid(2).toInt(), MsgImageWidthRole); + else if (p.startsWith("h=")) item->setData(p.mid(2).toInt(), MsgImageHeightRole); + } + } + QJsonObject la = QJsonDocument::fromJson(localAttrs).object(); QString st = la.value("st").toString(); QString fp = la.value("fp").toString(); @@ -274,9 +313,15 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent msgDelegate->setVideoOpenCallback([this](const QString& path) { emit openVideoRequested(path); }); + msgDelegate->setImageOpenCallback([this](const QString& path) { + emit openImageRequested(path); + }); m_view->setItemDelegate(msgDelegate); m_view->setEditTriggers(QAbstractItemView::NoEditTriggers); m_view->setSelectionMode(QAbstractItemView::NoSelection); + m_view->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_view, &QWidget::customContextMenuRequested, + this, &MessageList::onMessageContextMenu); m_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); m_view->setFrameShape(QFrame::NoFrame); @@ -399,6 +444,48 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent gui_bridge_post_uasync_fn(chat_core_submit_trampoline, req); }); + connect(m_inputBar, &InputBar::sendImageAttachment, this, [this](const QString& mediaSrc, const QString& mediaDest, + const QString& displayName) { + if (m_currentChannelId.isEmpty()) return; + + QByteArray textData = displayName.toUtf8(); + auto ts = QDateTime::currentMSecsSinceEpoch(); + + QFileInfo fi(displayName); + QString suf = fi.suffix().toLower(); + QString ct = "image/jpeg"; + if (suf == "png") ct = "image/png"; + else if (suf == "gif") ct = "image/gif"; + else if (suf == "bmp") ct = "image/bmp"; + else if (suf == "webp") ct = "image/webp"; + + int iw = 0, ih = 0; + QImageReader reader(mediaSrc); + reader.setAutoTransform(true); + if (reader.canRead()) { + QSize sz = reader.size(); + if (sz.width() > 0 && sz.height() > 0) { iw = sz.width(); ih = sz.height(); } + } + + struct chat_msg_submit* req = (struct chat_msg_submit*) + u_malloc(sizeof(struct chat_msg_submit) + textData.size()); + memset(req, 0, sizeof(*req)); + snprintf(req->channel_id, sizeof(req->channel_id), "%s", + m_currentChannelId.toUtf8().constData()); + snprintf(req->content_type, sizeof(req->content_type), "%s", ct.toUtf8().constData()); + snprintf(req->media_src, sizeof(req->media_src), "%s", mediaSrc.toUtf8().constData()); + snprintf(req->media_dest, sizeof(req->media_dest), "%s", mediaDest.toUtf8().constData()); + req->media_copy = (mediaSrc != mediaDest) ? 1 : 0; + req->media_video = 0; + req->width = iw; + req->height = ih; + req->data = (uint8_t*)(req + 1); + req->data_len = (uint32_t)textData.size(); + memcpy(req->data, textData.constData(), textData.size()); + req->timestamp = (uint64_t)ts; + gui_bridge_post_uasync_fn(chat_core_submit_trampoline, req); + }); + connect(AnimTimer::instance(), &AnimTimer::ticked, this, [this]() { m_view->viewport()->update(); }); @@ -765,3 +852,53 @@ void MessageList::onAttachmentDownloaded(const QString& chId, int64_t msgId) { } } } + +void MessageList::onMessageContextMenu(const QPoint& pos) { + QModelIndex idx = m_view->indexAt(pos); + if (!idx.isValid()) return; + + QString ct = idx.data(MsgContentTypeRole).toString(); + bool isVideo = (ct == "video/mp4"); + bool isImage = ct.startsWith("image/"); + if (!isVideo && !isImage) return; + + QString filePath = idx.data(MsgVoiceFileRole).toString(); + QString displayName = idx.data(MsgFileDisplayNameRole).toString(); + bool downloaded = !filePath.isEmpty() && QFile::exists(filePath); + + QMenu menu(m_view); + QAction* saveAct = menu.addAction(QString::fromUtf8("Сохранить в файл")); + saveAct->setEnabled(downloaded); + QAction* dlAct = nullptr; + if (!downloaded) dlAct = menu.addAction(QString::fromUtf8("Скачать")); + + QAction* chosen = menu.exec(m_view->viewport()->mapToGlobal(pos)); + + if (chosen == saveAct) { + QString base = displayName; + if (base.isEmpty()) base = QFileInfo(filePath).fileName(); + QString dest = QFileDialog::getSaveFileName(this, QString::fromUtf8("Сохранить файл"), base); + if (dest.isEmpty()) return; + if (!QFile::copy(filePath, dest)) { + DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "MessageList: save failed src=%s dst=%s", + qPrintable(filePath), qPrintable(dest)); + } else { + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "MessageList: saved %s -> %s", + qPrintable(filePath), qPrintable(dest)); + } + return; + } + + if (chosen == dlAct) { + qint64 msgId = idx.data(MsgIdRole).toLongLong(); + QString chId = idx.data(MsgChannelIdRole).toString(); + if (chId.isEmpty() || msgId <= 0) return; + struct attachment_dl_req* req = (struct attachment_dl_req*) + u_malloc(sizeof(struct attachment_dl_req)); + if (!req) return; + memset(req, 0, sizeof(*req)); + strncpy(req->channel_id, chId.toUtf8().constData(), sizeof(req->channel_id) - 1); + req->msg_id = msgId; + gui_bridge_post_uasync_fn(chat_core_attachment_download_trampoline, req); + } +} diff --git a/tools/chatgui/src/messagelist.h b/tools/chatgui/src/messagelist.h index bd88c383..6699b2eb 100644 --- a/tools/chatgui/src/messagelist.h +++ b/tools/chatgui/src/messagelist.h @@ -30,6 +30,7 @@ public: signals: void readPositionChanged(const QString& channelId, qint64 lastReadMsgId); void openVideoRequested(const QString& filePath); + void openImageRequested(const QString& filePath); private: ChatView *m_view; @@ -50,4 +51,5 @@ private: QString authorName(quint64 nodeId) const; void restoreScrollPosition(const QString& channelId); void updateReadPosition(); + void onMessageContextMenu(const QPoint& pos); };