Browse Source

chatgui/chatgui-android: просмотр изображений с зумом + инфо о медиа

- chatgui: просмотрщик изображений (ImageViewerWindow) с зумом/поворотом
- chatgui: отправка картинок как image с определением разрешения и типа
- chatgui: у видео-пузыря строка info (res·size·bitrate) и бейдж только длительность
- chatgui: контекстное меню медиа (сохранить в файл/скачать)
- chatgui-android: большие emoji-only сообщения, битрейт/разрешение у видео
- chatgui-android: сохранение неактивных интерфейсов как закомментированные секции
- chatgui-android: отправка картинок по расширению (jpg/png/gif/bmp/webp)
- photo_sender: content-type и расширение по типу файла
v2
evgeny 3 weeks ago
parent
commit
c474c37284
  1. 19
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  2. 78
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  3. 72
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  4. 15
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt
  5. 35
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  6. 23
      tools/chatgui-android/libutun_lite/photo_sender.c
  7. 1
      tools/chatgui/CMakeLists.txt
  8. 234
      tools/chatgui/src/imageviewer_window.cpp
  9. 53
      tools/chatgui/src/imageviewer_window.h
  10. 22
      tools/chatgui/src/inputbar.cpp
  11. 3
      tools/chatgui/src/inputbar.h
  12. 17
      tools/chatgui/src/mainwindow.cpp
  13. 3
      tools/chatgui/src/mainwindow.h
  14. 196
      tools/chatgui/src/messagedelegate.cpp
  15. 10
      tools/chatgui/src/messagedelegate.h
  16. 137
      tools/chatgui/src/messagelist.cpp
  17. 2
      tools/chatgui/src/messagelist.h

19
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<Float> = 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
))
}

78
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<ServerEntry> {
fun loadServersFromConfig(includeCommented: Boolean = false): List<ServerEntry> {
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<RawSrv>()
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<ServerEntry>) {
val text = buildConfigFromServers(servers)
fun saveServersToConfig(servers: List<ServerEntry>, commented: List<ServerEntry> = 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<ServerEntry>): String {
private fun buildConfigFromServers(servers: List<ServerEntry>, commented: List<ServerEntry> = 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<Pair<String, String>> = runBlocking {
val list = mutableListOf<Pair<String, String>>()
@ -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())

72
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)

15
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)
}
}
)

35
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()
}
}
}

23
tools/chatgui-android/libutun_lite/photo_sender.c

@ -7,6 +7,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <time.h>
#include <sys/stat.h>
#include <unistd.h>
@ -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;

1
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

234
tools/chatgui/src/imageviewer_window.cpp

@ -0,0 +1,234 @@
#include "imageviewer_window.h"
#include "../../lib/debug_config.h"
#include <QPainter>
#include <QImageReader>
#include <QWheelEvent>
#include <QMouseEvent>
#include <QKeyEvent>
#include <QCloseEvent>
#include <QResizeEvent>
#include <QPushButton>
#include <QLabel>
#include <QTimer>
#include <QPropertyAnimation>
#include <QGraphicsOpacityEffect>
#include <QEasingCurve>
#include <QtMath>
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);
}

53
tools/chatgui/src/imageviewer_window.h

@ -0,0 +1,53 @@
#pragma once
#include <QWidget>
#include <QPixmap>
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;
};

22
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();
}

3
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;
};

17
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())

3
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<AutoPlayItem> m_autoPlayQueue;
QTimer* m_autoPlayTimer = nullptr;

196
tools/chatgui/src/messagedelegate.cpp

@ -22,6 +22,7 @@
#include <QDesktopServices>
#include <QUrl>
#include <QDebug>
#include <QImageReader>
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;
}

10
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<void(const QString&)> cb) { m_videoOpenCb = std::move(cb); }
void setImageOpenCallback(std::function<void(const QString&)> 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<void(const QString&)> m_videoOpenCb;
std::function<void(const QString&)> m_imageOpenCb;
Layout calcLayout(const QStyleOptionViewItem &option,
const QModelIndex &index) const;

137
tools/chatgui/src/messagelist.cpp

@ -23,6 +23,9 @@
#include <QDebug>
#include <QFileInfo>
#include <QFile>
#include <QImageReader>
#include <QMenu>
#include <QFileDialog>
#include <vector>
#include <memory>
#include <thread>
@ -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=<w>|h=<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);
}
}

2
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);
};

Loading…
Cancel
Save