You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1446 lines
65 KiB

#include <QCoreApplication>
#include "messagelist.h"
#include "debug_ui.h"
#include "chatview.h"
#include "messagedelegate.h"
#include "inputbar.h"
#include "media_async/attachment_send.h"
#include "emojipanel.h"
#include "emoji.h"
#include "animtimer.h"
#include "lottieicon.h"
#include "audiorecorder.h"
#include "voiceplayback.h"
#include "../db/db_manager.h"
#include "../../lib/debug_config.h"
#include <QVBoxLayout>
#include <QPainter>
#include <QPixmap>
#include <QStyle>
#include <QSignalBlocker>
#include <QDateTime>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QFile>
#include <QScrollBar>
#include <QApplication>
#include <QPointer>
#include <QDebug>
#include <QFileInfo>
#include <QFile>
#include <QImageReader>
#include <QMenu>
#include <QFileDialog>
#include <QToolTip>
#include <vector>
#include <memory>
#include <thread>
static std::vector<std::unique_ptr<LottieIcon>> s_icons;
extern "C" {
#include "../transport/gui_bridge.h"
#include "chat/chat_core.h"
#include "chat/chat_poll.h"
#include "dm/dm_core.h"
#include "dm/dm_media.h"
#include "video/video.h"
#include "radio/radio.h"
#include "../../lib/u_async.h"
#include "../../lib/mem.h"
#include "../../lib/audio_compressor.h"
#include "../../lib/debug_config.h"
void chat_core_save_ui_state_trampoline(void* arg);
}
#include "radio_audio_engine.h"
#include "radiopanel.h"
#include "flagpainter.h"
#include "avatars/avatarmanager.h"
#include "avatars/avatargallery.h"
#include <QLabel>
#include <QPushButton>
#include <QHBoxLayout>
// Портативная рация: один и тот же значок во всех состояниях кнопки.
static QIcon makeRadioIcon(const QColor& color) {
QPixmap pix(48, 48);
pix.fill(Qt::transparent);
QPainter p(&pix);
FlagPainter::drawRadio(&p, 0, 0, 48, color);
p.end();
QIcon icon;
// Явно задаём все режимы: Qt не перекрашивает значок при checked/disabled.
for (auto mode : {QIcon::Normal, QIcon::Disabled, QIcon::Active, QIcon::Selected}) {
icon.addPixmap(pix, mode, QIcon::Off);
icon.addPixmap(pix, mode, QIcon::On);
}
return icon;
}
static QPixmap makeAvatar(const QColor &color, const QString &letter, int size = MessageDelegate::kAvatar) {
QPixmap pix(size, size);
pix.fill(Qt::transparent);
QPainter p(&pix);
p.setRenderHint(QPainter::Antialiasing);
p.setBrush(color);
p.setPen(Qt::NoPen);
p.drawEllipse(0, 0, size, size);
p.setPen(Qt::white);
QFont f = p.font();
f.setBold(true);
f.setPointSize(size / 3);
p.setFont(f);
p.drawText(QRect(0, 0, size, size), Qt::AlignCenter, letter);
p.end();
return pix;
}
static QVariant makeReaction(const QString &emoji, int count,
const QList<QColor> &colors = {}) {
QVariantMap m;
m["emoji"] = emoji;
m["count"] = count;
if (!colors.isEmpty())
m["colors"] = QVariant::fromValue(colors);
return m;
}
static QString parseJsonField(const QByteArray& data, const char* field) {
QByteArray pat = QByteArray("\"") + field + "\":\"";
int idx = data.indexOf(pat);
if (idx < 0) return {};
idx += pat.size();
int end = data.indexOf('"', idx);
if (end < 0) return {};
return QString::fromUtf8(data.mid(idx, end - idx));
}
static QString parseJsonFieldNum(const QByteArray& data, const char* field) {
QByteArray pat = QByteArray("\"") + field + "\":";
int idx = data.indexOf(pat);
if (idx < 0) return {};
idx += pat.size();
int end = data.indexOf(',', idx);
if (end < 0) end = data.indexOf('}', idx);
if (end < 0) return {};
return QString::fromUtf8(data.mid(idx, end - idx));
}
static QString contentTypeFromJson(const QByteArray& data) {
return parseJsonField(data, "ct");
}
static void setVoiceMessageRoles(QStandardItem* item, const QByteArray& data,
const QString& contentType, const QString& mediaDirBase,
const QString& channelId, const QByteArray& localAttrs) {
QByteArray body = data;
if (body.startsWith('*')) {
int pipePos = body.indexOf('|');
if (pipePos > 1) {
QByteArray b64 = body.mid(1, pipePos - 1);
b64.replace('-', '+');
b64.replace('_', '/');
body = QByteArray::fromBase64(b64) + body.mid(pipePos);
}
}
QString ct = contentType;
if (ct.isEmpty()) ct = contentTypeFromJson(data);
if (ct.isEmpty()) ct = "text/plain";
item->setData(ct, MsgContentTypeRole);
item->setData(channelId, MsgChannelIdRole);
if (ct == "audio/opus") {
QString raw = QString::fromUtf8(body);
if (raw.startsWith('{')) return;
/* unified format: wf={base64};dur={ms};|{file_size}|{block_size}|{num_blocks}|{sigs} */
QString wfData;
int durMs = 0;
if (raw.startsWith("wf=")) {
int durPos = raw.indexOf(";dur=", 3);
int durEnd = raw.indexOf(';', durPos + 5);
if (durPos > 0 && durEnd > 0) {
wfData = raw.mid(3, durPos - 3); /* 100 base64 chars */
durMs = raw.mid(durPos + 5, durEnd - durPos - 5).toInt();
}
}
item->setData(QString::number(durMs / 1000.0, 'f', 1) + "s", MsgVoiceDurationRole);
/* block metadata after last ; */
int pipePos = raw.indexOf('|');
if (pipePos > 0) {
QString meta = raw.mid(pipePos + 1);
QStringList parts = meta.split('|');
if (parts.size() >= 4) {
item->setData(parts[0].toULongLong(), MsgVoiceFileSizeRole);
item->setData(parts[1].toULongLong(), MsgVoiceBlockSizeRole);
item->setData(parts[2].toUInt(), MsgVoiceNumBlocksRole);
item->setData(parts[3], MsgVoiceSigsRole);
item->setData(parts[2].toUInt(), MsgMediaNumBlocksRole);
}
}
/* file path from local_attrs */
QJsonObject la = QJsonDocument::fromJson(localAttrs).object();
QString fp = la.value("fp").toString();
QString st = la.value("st").toString();
int dlState = (st == "fl" && !fp.isEmpty()) ? 2 : 0;
item->setData(dlState, MsgFileDownloadStateRole);
if (!fp.isEmpty()) {
QString fullPath = mediaDirBase + "/" + channelId + "/" + fp;
item->setData(fullPath, MsgVoiceFileRole);
}
/* decode base64 waveform to normalized 0..1 float list */
if (!wfData.isEmpty()) {
QList<QVariant> wf;
for (int i = 0; i < wfData.size(); i++) {
QChar c = wfData[i];
int idx = 0;
if (c >= 'A' && c <= 'Z') idx = c.unicode() - 'A';
else if (c >= 'a' && c <= 'z') idx = c.unicode() - 'a' + 26;
else if (c >= '0' && c <= '9') idx = c.unicode() - '0' + 52;
else if (c == '+') idx = 62;
else if (c == '/') idx = 63;
wf.append((float)idx / 63.0f);
}
item->setData(wf, MsgVoiceWaveformRole);
}
}
if (ct == "application/octet-stream") {
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);
}
/* parse download state from local_attrs */
QJsonObject la = QJsonDocument::fromJson(localAttrs).object();
QString st = la.value("st").toString();
QString fp = la.value("fp").toString();
int dlState = 0; /* 0=not started, 2=downloaded */
if (st == "fl" && !fp.isEmpty()) {
dlState = 2; /* downloaded */
QString fullPath = mediaDirBase + "/" + channelId + "/" + fp;
item->setData(fullPath, MsgVoiceFileRole);
}
item->setData(dlState, MsgFileDownloadStateRole);
}
if (ct == "video/mp4") {
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);
/* dur/w/h в хвосте: |dur=<ms>|w=<w>|h=<h> */
for (const QString& p : parts) {
if (p.startsWith("dur=")) {
qint64 durMs = p.mid(4).toLongLong();
if (durMs > 0) {
qint64 s = durMs / 1000;
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();
int dlState = 0;
if (st == "fl" && !fp.isEmpty()) {
dlState = 2;
QString fullPath = mediaDirBase + "/" + channelId + "/" + fp;
item->setData(fullPath, MsgVoiceFileRole);
}
item->setData(dlState, MsgFileDownloadStateRole);
}
}
static void setMsg(QStandardItem *item,
const QString &author, const QString &text, const QString &time,
const QPixmap &avatar, bool edited,
bool hasQuote, const QString &qAuthor, const QString &qText,
const QVariantList &reactions) {
item->setData(author, MsgAuthorRole);
item->setData(text, MsgTextRole);
item->setData(time, MsgTimeRole);
item->setData(edited, MsgEditedRole);
item->setData(QVariant::fromValue(avatar), MsgAvatarRole);
item->setData(hasQuote, MsgHasQuoteRole);
if (hasQuote) {
item->setData(qAuthor, MsgQuoteAuthorRole);
item->setData(qText, MsgQuoteTextRole);
}
if (!reactions.isEmpty())
item->setData(reactions, MsgReactionsRole);
}
MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent)
: QWidget(parent)
, m_view(new ChatView(this))
, m_model(new QStandardItemModel(this))
, m_inputBar(nullptr)
, m_emojiPanel(nullptr)
, m_db(db)
, m_recorder(recorder)
{
auto *layout = new QVBoxLayout(this);
if (auto* manager = AvatarManager::instance()) connect(manager, &AvatarManager::changed, this,
[this, manager](const QString& channel, quint64 owner) {
if (channel != m_currentChannelId && !isDmChannel(m_currentChannelId)) return;
const QImage image = manager->image(m_currentChannelId, owner);
for (int row = 0; row < m_model->rowCount(); row++) {
auto* item = m_model->item(row);
if (item->data(MsgAuthorNodeIdRole).toULongLong() != owner) continue;
QPixmap avatar;
if (!image.isNull()) avatar = QPixmap::fromImage(image);
else {
const AccountRow account = m_db->getAccount(owner);
avatar = makeAvatar(QColor(account.avatarColor), account.avatarLetter.isEmpty() ? authorName(owner).left(1) : account.avatarLetter);
}
item->setData(avatar, MsgAvatarRole);
}
});
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
connect(m_view, &ChatView::linkActivated, this, &MessageList::linkActivated);
/* ── шапка чата: название канала + переключатель рации ── */
{
auto* header = new QWidget(this);
auto* hl = new QHBoxLayout(header);
hl->setContentsMargins(10, 6, 10, 6);
hl->setSpacing(8);
m_peerStatus = new QLabel(header);
m_peerStatus->setFixedSize(12, 12);
m_peerStatus->hide();
hl->addWidget(m_peerStatus);
m_channelTitle = new QLabel(header);
m_channelTitle->setStyleSheet("font-weight: bold; font-size: 14px;");
m_channelTitle->setText(QCoreApplication::translate("ChatGui", "Channel"));
hl->addWidget(m_channelTitle, 1);
m_pollBtn = new QPushButton(QCoreApplication::translate("ChatGui", "Create poll"), header);
hl->addWidget(m_pollBtn);
connect(m_pollBtn, &QPushButton::clicked, this, &MessageList::createPoll);
m_talkingLabel = new QLabel(header);
m_talkingLabel->setStyleSheet("color: #27ae60; font-size: 12px;");
m_talkingLabel->hide();
hl->addWidget(m_talkingLabel);
m_inviteBtn = new QPushButton(QStringLiteral("+"), header);
m_inviteBtn->setAccessibleName(QCoreApplication::translate("ChatGui", "Invite to group"));
m_inviteBtn->setToolTip(QCoreApplication::translate("ChatGui", "Invite to group"));
m_inviteBtn->setFixedSize(34, 30);
m_inviteBtn->setCursor(Qt::PointingHandCursor);
m_inviteBtn->hide();
hl->addWidget(m_inviteBtn);
connect(m_inviteBtn, &QPushButton::clicked, this, [this]() {
if (!m_db || !isDmChannel(m_currentChannelId)) return;
for (const auto& conv : m_db->getDmConversations()) {
if (conv.convId == dmConvId(m_currentChannelId)) {
emit inviteRequested(QString(), conv.peerNodeId, m_channelTitle->text());
return;
}
}
GUI_ERROR("invite PM: conversation not found target=%s", qPrintable(m_currentChannelId));
});
m_callBtn = new QPushButton(QStringLiteral("☎"), header);
m_callBtn->setAccessibleName(QCoreApplication::translate("ChatGui", "Call peer"));
m_callBtn->setFixedSize(34, 30);
m_callBtn->setCursor(Qt::PointingHandCursor);
m_callBtn->hide();
clearDmCallRoute();
hl->addWidget(m_callBtn);
connect(m_callBtn, &QPushButton::clicked, this, [this]() {
if (m_callBtn->isEnabled() && m_callPeer && isDmChannel(m_currentChannelId))
emit callRequested(m_currentChannelId, m_callPeer, m_channelTitle->text());
});
m_radioBtn = new QPushButton(header);
m_radioBtn->setIcon(makeRadioIcon(header->palette().color(QPalette::ButtonText)));
m_radioBtn->setIconSize(QSize(24, 24));
m_radioBtn->setAccessibleName(QCoreApplication::translate("ChatGui", "Radio"));
m_radioBtn->setCheckable(true);
m_radioBtn->setFixedSize(34, 30);
m_radioBtn->setCursor(Qt::PointingHandCursor);
m_radioBtn->setToolTip(QCoreApplication::translate("ChatGui", "Radio: toggle channel listening"));
m_radioBtn->setStyleSheet(
"QPushButton { border: 1px solid palette(mid); border-radius: 6px; background: palette(base); font-size: 15px; }"
"QPushButton:checked { background: #27ae60; border-color: #27ae60; }");
connect(m_radioBtn, &QPushButton::toggled, this, &MessageList::onRadioToggled);
hl->addWidget(m_radioBtn);
header->setStyleSheet("background: palette(window);");
layout->addWidget(header);
}
m_radioPanel = new RadioPanel(this);
m_radioPanel->hide();
layout->addWidget(m_radioPanel);
m_view->setModel(m_model);
auto* msgDelegate = new MessageDelegate(this);
m_view->setLinkHitTest([this, msgDelegate](const QModelIndex& index, const QPoint& point) {
QStyleOptionViewItem option; option.initFrom(m_view); option.widget = m_view; option.rect = m_view->visualRect(index);
return msgDelegate->linkAt(option, index, point);
});
msgDelegate->setVideoOpenCallback([this](const QString& path) {
emit openVideoRequested(path);
});
msgDelegate->setImageOpenCallback([this](const QString& path) {
emit openImageRequested(path);
});
connect(msgDelegate, &MessageDelegate::avatarRequested, this, [](const QString& channel, quint64 owner) {
showAvatarGallery(channel, owner);
});
connect(msgDelegate, &MessageDelegate::reactionRequested, this, &MessageList::requestReaction);
connect(msgDelegate, &MessageDelegate::pollRequested, this, &MessageList::showPoll);
connect(msgDelegate, &MessageDelegate::replyRequested, this, &MessageList::jumpToMessage);
connect(m_view, &ChatView::latestRequested, this, [this]() {
if (m_historyAnchor.isEmpty()) return;
m_historyAnchor.clear(); loadChannel(m_currentChannelId);
QTimer::singleShot(0, m_view, &QListView::scrollToBottom);
});
msgDelegate->setDownloadCallback([](const QModelIndex& index) {
QString target = index.data(MsgChannelIdRole).toString();
qint64 id = index.data(MsgIdRole).toLongLong();
if (target.isEmpty() || target.startsWith("dm:") || id <= 0) return false;
auto* req = (struct attachment_dl_req*)u_calloc(1, sizeof(struct attachment_dl_req));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment: download request allocation failed"); return false; }
req->inst = gui_bridge_get_inst(); req->msg_id = id;
snprintf(req->channel_id, sizeof(req->channel_id), "%s", target.toUtf8().constData());
gui_bridge_post_uasync_fn(chat_core_attachment_download_trampoline, req);
return true;
});
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);
m_view->setBackgroundImage("../resources/bg.png");
layout->addWidget(m_view, 1);
if (s_icons.empty()) {
for (const auto &cat : builtinEmojiSet()) {
for (const auto &e : cat.items) {
if (e.type == EmojiType::Animation && !e.animPath.isEmpty()) {
QFile f(e.animPath);
if (f.open(QIODevice::ReadOnly)) {
QByteArray raw = f.readAll();
auto icon = std::make_unique<LottieIcon>(raw, e.animPath);
if (icon->isValid()) {
AnimTimer::instance()->registerIcon(e.animPath, icon.get());
s_icons.push_back(std::move(icon));
}
}
}
}
}
}
m_emojiPanel = new EmojiPanel(this);
layout->addWidget(m_emojiPanel);
m_inputBar = new InputBar(m_emojiPanel, recorder, this);
layout->addWidget(m_inputBar);
m_inputBar->installFileDropTarget(m_view->viewport());
connect(m_inputBar, &InputBar::sendMessage, this, [this](const QString &text, const QByteArray& reply, quint64 request) {
if (!gui_bridge_get_inst() || !gui_bridge_is_uasync_ready()) {
GUI_ERROR("send: core unavailable"); m_inputBar->submissionResult(request, false); return;
}
if (m_currentChannelId.isEmpty()) {
qWarning("MessageList: no channel selected, message not sent");
m_inputBar->submissionResult(request, false);
return;
}
if (isDmChannel(m_currentChannelId)) {
QByteArray textData = text.toUtf8();
QString conv = dmConvId(m_currentChannelId);
struct dm_send_req* req = (struct dm_send_req*)u_malloc(sizeof(struct dm_send_req) + textData.size());
if (!req) return;
memset(req, 0, sizeof(*req));
req->inst = gui_bridge_get_inst();
strncpy(req->conv_id, conv.toUtf8().constData(), sizeof(req->conv_id) - 1);
req->data = (uint8_t*)(req + 1);
req->data_len = (uint32_t)textData.size();
memcpy(req->data, textData.constData(), textData.size());
gui_bridge_post_uasync_fn(dm_send_trampoline, req);
return;
}
QByteArray textData = text.toUtf8();
auto ts = QDateTime::currentMSecsSinceEpoch();
struct chat_msg_submit* req = (struct chat_msg_submit*)
u_malloc(sizeof(struct chat_msg_submit) + textData.size());
if (!req) { GUI_ERROR("send: text request allocation failed"); m_inputBar->submissionResult(request, false); return; }
memset(req, 0, sizeof(*req));
if (reply.size() == 32) memcpy(req->reply_to, reply.constData(), 32);
req->request_id = request;
req->inst = gui_bridge_get_inst();
snprintf(req->channel_id, sizeof(req->channel_id), "%s",
m_currentChannelId.toUtf8().constData());
snprintf(req->content_type, sizeof(req->content_type), "text/plain");
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(m_inputBar, &InputBar::submitAttachment, this, [this](const QString& target, struct attachment_send_req* req) {
if (!gui_bridge_get_inst() || !gui_bridge_is_uasync_ready()) {
GUI_ERROR("send: attachment core unavailable"); m_inputBar->submissionResult(req->request_id, false);
if (req->pcm_release) req->pcm_release(req->pcm_owner); else u_free((void*)req->pcm);
audio_compressor_destroy(req->compressor); u_free(req); return;
}
req->inst = gui_bridge_get_inst(); req->is_dm = isDmChannel(target);
QByteArray id = (req->is_dm ? dmConvId(target) : target).toUtf8();
snprintf(req->target, sizeof(req->target), "%s", id.constData());
gui_bridge_post_uasync_fn(attachment_send_trampoline, req);
});
connect(m_inputBar, &InputBar::sendFileAttachment, this, [this](const QString& target, const QString& mediaSrc, const QString& mediaDest,
const QString& displayName, const QByteArray& reply, quint64 request) {
if (target.isEmpty() || !gui_bridge_get_inst() || !gui_bridge_is_uasync_ready()) {
GUI_ERROR("send: file target/core unavailable"); m_inputBar->submissionResult(request, false); return;
}
if (isDmChannel(target)) {
auto* req = (struct dm_file_req*)u_calloc(1, sizeof(struct dm_file_req));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "PM file request allocation failed"); return; }
req->inst = gui_bridge_get_inst();
QByteArray conv = dmConvId(target).toUtf8(), path = mediaSrc.toUtf8(), name = displayName.toUtf8();
if (path.size() >= (int)sizeof(req->path) || name.size() >= (int)sizeof(req->info.name)) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "PM attachment path or name is too long"); u_free(req); return;
}
snprintf(req->conv, sizeof(req->conv), "%s", conv.constData());
snprintf(req->path, sizeof(req->path), "%s", path.constData());
req->info.kind = ATTACHMENT_FILE;
snprintf(req->info.name, sizeof(req->info.name), "%s", name.constData());
gui_bridge_post_uasync_fn(dm_send_file_trampoline, req);
return;
}
QByteArray textData = displayName.toUtf8();
auto ts = QDateTime::currentMSecsSinceEpoch();
struct chat_msg_submit* req = (struct chat_msg_submit*)
u_malloc(sizeof(struct chat_msg_submit) + textData.size());
if (!req) { GUI_ERROR("send: file request allocation failed"); m_inputBar->submissionResult(request, false); return; }
memset(req, 0, sizeof(*req));
if (reply.size() == 32) memcpy(req->reply_to, reply.constData(), 32);
req->request_id = request;
req->inst = gui_bridge_get_inst();
snprintf(req->channel_id, sizeof(req->channel_id), "%s",
target.toUtf8().constData());
snprintf(req->content_type, sizeof(req->content_type), "application/octet-stream");
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->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(m_inputBar, &InputBar::sendImageAttachment, this, [this](const QString& target, const QString& mediaSrc, const QString& mediaDest,
const QString& displayName, const QByteArray& reply, quint64 request) {
if (target.isEmpty() || !gui_bridge_get_inst() || !gui_bridge_is_uasync_ready()) {
GUI_ERROR("send: image target/core unavailable"); m_inputBar->submissionResult(request, false); 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());
if (!req) { GUI_ERROR("send: image request allocation failed"); m_inputBar->submissionResult(request, false); return; }
memset(req, 0, sizeof(*req));
if (reply.size() == 32) memcpy(req->reply_to, reply.constData(), 32);
req->request_id = request;
req->inst = gui_bridge_get_inst();
snprintf(req->channel_id, sizeof(req->channel_id), "%s",
target.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();
});
connect(m_view, &ChatView::animationTriggered, AnimTimer::instance(), &AnimTimer::play);
m_readDebounce = new QTimer(this);
m_readDebounce->setSingleShot(true);
m_readDebounce->setInterval(300);
connect(m_readDebounce, &QTimer::timeout, this, &MessageList::updateReadPosition);
connect(m_view->verticalScrollBar(), &QScrollBar::valueChanged,
m_readDebounce, QOverload<>::of(&QTimer::start));
}
void MessageList::loadChannel(const QString& channelId) {
QWidget* prevFocus = QApplication::focusWidget();
bool sameChannel = (m_currentChannelId == channelId);
/* Save state of current channel before switching */
if (!m_currentChannelId.isEmpty()) {
if (!sameChannel) {
saveChannelState();
/* выключаем рацию предыдущего канала при переключении (деактивирует подписку в core) */
if (m_radioOn) {
onRadioToggled(false);
m_radioBtn->blockSignals(true);
m_radioBtn->setChecked(false);
m_radioBtn->blockSignals(false);
}
m_talkingLabel->hide();
} else {
QModelIndex topIdx = m_view->indexAt(QPoint(0, 0));
m_savedTopMsgId = topIdx.isValid() ? topIdx.data(MsgIdRole).toLongLong() : 0;
QScrollBar* sb = m_view->verticalScrollBar();
m_wasAtBottom = sb && (sb->value() >= sb->maximum());
}
}
if (!sameChannel) m_historyAnchor.clear();
m_view->setHistoricalWindow(!m_historyAnchor.isEmpty());
m_model->clear();
m_avatarCache.clear();
if (m_currentChannelId != channelId) { m_feedback.clear(); m_pendingPoll.clear(); }
m_currentChannelId = channelId;
m_pollBtn->setVisible(!isDmChannel(channelId));
clearDmCallRoute();
m_callBtn->setVisible(isDmChannel(channelId));
m_inviteBtn->setVisible(isDmChannel(channelId));
m_peerStatus->setVisible(isDmChannel(channelId));
m_radioBtn->setVisible(!isDmChannel(channelId));
m_inputBar->setRecordingContext(m_db ? m_db->dbPath() + "/media" : QString(), channelId);
m_readDebounce->stop();
/* шапка: название канала */
QString title = channelId;
if (m_db && !isDmChannel(channelId)) {
for (const auto& ch : m_db->getChannels()) {
if (ch.channelId == channelId) { title = ch.name; break; }
}
}
if (m_channelTitle) m_channelTitle->setText(title);
m_inputBar->setDmMode(isDmChannel(channelId));
/* История и состояния PM поступают из ядра в одном снимке. */
if (isDmChannel(channelId)) {
m_radioBtn->setEnabled(false);
if (m_db) {
for (const auto& conv : m_db->getDmConversations()) {
if (conv.convId != dmConvId(channelId)) continue;
QString name = m_db->getDisplayName(conv.peerNodeId);
if (name.isEmpty()) name = conv.peerName;
if (name.isEmpty()) name = QStringLiteral("0x%1").arg(conv.peerNodeId, 16, 16, QChar('0'));
m_channelTitle->setText(name);
break;
}
}
refreshDmCallRoute();
requestDmMessages(channelId);
if (prevFocus)
prevFocus->setFocus();
else
m_inputBar->setFocus();
return;
}
m_radioBtn->setEnabled(true);
m_inputBar->setMediaEnabled(true);
if (!m_db || !m_db->isOpen()) return;
m_localNicks = m_db->getLocalNicknames(channelId);
auto msgs = m_db->getMessages(channelId, 200, m_historyAnchor);
for (const auto& m : msgs) {
AccountRow acc = m_db->getAccount(m.authorNodeId);
QString author = authorName(m.authorNodeId);
QString letter = acc.avatarLetter.isEmpty() ? author.mid(0, 1) : acc.avatarLetter;
QColor color(acc.avatarColor);
if (!m_avatarCache.contains(m.authorNodeId)) {
m_avatarCache[m.authorNodeId] = color;
}
auto avatar = makeAvatar(color, letter);
if (auto* manager = AvatarManager::instance()) {
const QImage image = manager->image(m_currentChannelId, m.authorNodeId);
if (!image.isNull()) avatar = QPixmap::fromImage(image);
}
auto time = QDateTime::fromMSecsSinceEpoch(m.timestamp).toString("HH:mm");
QVariantList reacts;
auto *item = new QStandardItem();
setMsg(item, author, QString::fromUtf8(m.data), time, avatar, false,
false, QString(), QString(), reacts);
item->setData(m.id, MsgIdRole);
item->setData(QVariant::fromValue(m.authorNodeId), MsgAuthorNodeIdRole);
item->setData(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole);
setVoiceMessageRoles(item, m.data, m.contentType,
m_db ? m_db->dbPath() + "/media" : QString(), channelId, m.localAttrs);
m_model->appendRow(item);
setupFeedback(item, m, channelId);
updateReply(item, m.replyTo);
}
if (sameChannel) {
bool wasBottom = m_wasAtBottom;
qint64 savedTop = m_savedTopMsgId;
QTimer::singleShot(0, this, [this, wasBottom, savedTop]() {
if (wasBottom || savedTop == 0) {
m_view->scrollToBottom();
} else {
for (int row = 0; row < m_model->rowCount(); row++) {
if (m_model->item(row)->data(MsgIdRole).toLongLong() == savedTop) {
m_view->scrollTo(m_model->index(row, 0), QAbstractItemView::PositionAtTop);
break;
}
}
}
m_readDebounce->start();
});
} else {
m_lastReadMsgId = m_db->getUiStateInt64(
QStringLiteral("ch_%1_last_read").arg(channelId), 0);
restoreScrollPosition(channelId);
m_readDebounce->start();
}
if (prevFocus)
prevFocus->setFocus();
else
m_inputBar->setFocus();
}
void MessageList::refresh() {
if (m_currentChannelId.isEmpty()) return;
if (isDmChannel(m_currentChannelId)) {
requestDmMessages(m_currentChannelId);
return;
}
if (!m_db || !m_db->isOpen()) return;
refreshReplies();
if (!m_historyAnchor.isEmpty()) return;
QSet<QString> present;
for (int row = 0; row < m_model->rowCount(); row++) present.insert(m_model->item(row)->data(MsgMessageKeyRole).toString());
auto newMsgs = m_db->getMessages(m_currentChannelId, 200);
if (newMsgs.isEmpty()) return;
m_localNicks = m_db->getLocalNicknames(m_currentChannelId);
QScrollBar* sb = m_view->verticalScrollBar();
bool wasAtBottom = sb && (sb->value() >= sb->maximum());
for (const auto& m : newMsgs) {
if (present.contains(QString::fromLatin1(m.messageId.toHex()))) continue;
AccountRow acc = m_db->getAccount(m.authorNodeId);
QString author = authorName(m.authorNodeId);
QString letter = acc.avatarLetter.isEmpty() ? author.mid(0, 1) : acc.avatarLetter;
QColor color(acc.avatarColor);
if (!m_avatarCache.contains(m.authorNodeId))
m_avatarCache[m.authorNodeId] = color;
auto avatar = makeAvatar(color, letter);
if (auto* manager = AvatarManager::instance()) {
const QImage image = manager->image(m_currentChannelId, m.authorNodeId);
if (!image.isNull()) avatar = QPixmap::fromImage(image);
}
auto time = QDateTime::fromMSecsSinceEpoch(m.timestamp).toString("HH:mm");
auto *item = new QStandardItem();
setMsg(item, author, QString::fromUtf8(m.data), time, avatar, false,
false, QString(), QString(), QVariantList{});
item->setData(m.id, MsgIdRole);
item->setData(QVariant::fromValue(m.authorNodeId), MsgAuthorNodeIdRole);
item->setData(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole);
setVoiceMessageRoles(item, m.data, m.contentType,
m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId, m.localAttrs);
m_model->appendRow(item);
setupFeedback(item, m, m_currentChannelId);
updateReply(item, m.replyTo);
while (m_model->rowCount() > 200)
m_model->removeRow(0);
}
m_model->setSortRole(MsgRawTimestampRole); m_model->sort(0);
if (wasAtBottom) m_view->scrollToBottom();
m_readDebounce->start();
}
void MessageList::saveChannelState() {
if (!m_historyAnchor.isEmpty()) return; // Просмотр цитаты сохраняет прежнюю позицию чтения свежей истории.
if (m_currentChannelId.isEmpty() || m_model->rowCount() == 0) return;
QScrollBar* sb = m_view->verticalScrollBar();
bool atBottom = sb && (sb->value() >= sb->maximum());
qint64 lastPosMsgId = 0;
if (!atBottom) {
QModelIndex topIdx = m_view->indexAt(QPoint(0, 0));
if (topIdx.isValid())
lastPosMsgId = topIdx.data(MsgIdRole).toLongLong();
}
qint64 lastReadMsgId = m_lastReadMsgId;
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "MessageList::saveChannelState ch=%s bottom=%d read=%lld pos=%lld",
qPrintable(m_currentChannelId), (int)atBottom, (long long)lastReadMsgId, (long long)lastPosMsgId);
QByteArray cid = m_currentChannelId.toUtf8();
auto postSaveKey = [&cid](const char* suffix, qint64 val) {
QByteArray key = "ch_" + cid + suffix;
QByteArray valStr = QByteArray::number(val);
size_t keyLen = (size_t)key.size(), valLen = (size_t)valStr.size();
size_t total = keyLen + 1 + valLen + 1;
if (total > 256) return;
struct save_ui_state_arg* arg = (struct save_ui_state_arg*)u_malloc(sizeof(struct save_ui_state_arg));
if (!arg) return;
arg->inst = gui_bridge_get_inst();
memcpy(arg->data, key.constData(), keyLen + 1);
memcpy(arg->data + keyLen + 1, valStr.constData(), valLen + 1);
gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg);
};
if (lastReadMsgId > 0)
postSaveKey("_last_read", lastReadMsgId);
postSaveKey("_last_pos", lastPosMsgId);
}
QString MessageList::authorName(quint64 nodeId) const {
auto it = m_localNicks.find(nodeId);
if (it != m_localNicks.end() && !it->isEmpty()) return *it;
return m_db->getDisplayName(nodeId);
}
void MessageList::reloadLocalNicks() {
if (m_currentChannelId.isEmpty() || !m_db) return;
m_localNicks = m_db->getLocalNicknames(m_currentChannelId);
for (int r = 0; r < m_model->rowCount(); r++) {
auto* item = m_model->item(r);
if (!item) continue;
QVariant v = item->data(MsgAuthorNodeIdRole);
if (!v.isValid()) continue;
quint64 nid = v.value<quint64>();
item->setData(authorName(nid), MsgAuthorRole);
}
if (!isDmChannel(m_currentChannelId)) refreshReplies();
m_view->viewport()->update();
}
void MessageList::restoreScrollPosition(const QString& channelId) {
if (!m_db || m_model->rowCount() == 0) {
return;
}
qint64 lastPosMsgId = m_db->getUiStateInt64(
QStringLiteral("ch_%1_last_pos").arg(channelId), 0);
if (lastPosMsgId > 0) {
for (int row = 0; row < m_model->rowCount(); row++) {
QStandardItem* item = m_model->item(row);
if (item && item->data(MsgIdRole).toLongLong() == lastPosMsgId) {
m_view->scrollTo(m_model->index(row, 0), QAbstractItemView::PositionAtTop);
return;
}
}
}
m_view->scrollToBottom();
}
void MessageList::updateReadPosition() {
if (!m_historyAnchor.isEmpty()) return;
if (m_currentChannelId.isEmpty() || m_model->rowCount() == 0) return;
int vpHeight = m_view->viewport()->height();
int lastFullyVisibleRow = -1;
qint64 newReadMsgId = m_lastReadMsgId;
for (int row = m_model->rowCount() - 1; row >= 0; --row) {
QModelIndex idx = m_model->index(row, 0);
QRect vr = m_view->visualRect(idx);
if (vr.isValid() && vr.bottom() <= vpHeight) {
lastFullyVisibleRow = row;
break;
}
}
if (lastFullyVisibleRow >= 0) {
qint64 msgId = m_model->item(lastFullyVisibleRow)->data(MsgIdRole).toLongLong();
if (msgId > m_lastReadMsgId)
newReadMsgId = msgId;
}
QModelIndex topIdx = m_view->indexAt(QPoint(0, 0));
QScrollBar* sb = m_view->verticalScrollBar();
bool atBottom = sb && (sb->value() >= sb->maximum());
qint64 lastPosMsgId = (!atBottom && topIdx.isValid()) ? topIdx.data(MsgIdRole).toLongLong() : 0;
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT,
"MessageList::updateReadPosition ch=%s oldRead=%lld newRead=%lld pos=%lld bottom=%d lastVisRow=%d/%d",
qPrintable(m_currentChannelId), (long long)m_lastReadMsgId,
(long long)newReadMsgId, (long long)lastPosMsgId, (int)atBottom,
lastFullyVisibleRow, m_model->rowCount());
QByteArray cid = m_currentChannelId.toUtf8();
auto postSaveKey = [&cid](const char* suffix, qint64 val) {
QByteArray key = "ch_" + cid + suffix;
QByteArray valStr = QByteArray::number(val);
size_t keyLen = (size_t)key.size(), valLen = (size_t)valStr.size();
size_t total = keyLen + 1 + valLen + 1;
if (total > 256) return;
struct save_ui_state_arg* arg = (struct save_ui_state_arg*)u_malloc(sizeof(struct save_ui_state_arg));
if (!arg) return;
arg->inst = gui_bridge_get_inst();
memcpy(arg->data, key.constData(), keyLen + 1);
memcpy(arg->data + keyLen + 1, valStr.constData(), valLen + 1);
gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg);
};
if (newReadMsgId > m_lastReadMsgId) {
m_lastReadMsgId = newReadMsgId;
postSaveKey("_last_read", m_lastReadMsgId);
emit readPositionChanged(m_currentChannelId, m_lastReadMsgId);
}
postSaveKey("_last_pos", lastPosMsgId);
}
void MessageList::cleanupAnimations() {
size_t n = s_icons.size();
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "MessageList: cleanupAnimations clearing %zu icons", n);
s_icons.clear();
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "MessageList: cleanupAnimations done");
}
void MessageList::updateMessageProgress(const QString& chId, int64_t msgId,
int blocksDone, int numBlocks) {
if (chId != m_currentChannelId) return;
for (int r = 0; r < m_model->rowCount(); r++) {
QStandardItem* item = m_model->item(r);
if (!item) continue;
if (item->data(MsgIdRole).toLongLong() == msgId) {
item->setData(blocksDone, MsgMediaBlocksDoneRole);
item->setData(numBlocks, MsgMediaNumBlocksRole);
item->setData(1, MsgFileDownloadStateRole);
m_view->viewport()->update();
return;
}
}
}
void MessageList::onAttachmentDownloaded(const QString& chId, int64_t msgId) {
if (chId != m_currentChannelId || !m_db || !m_db->isOpen()) return;
MessageRow m = m_db->getMessageById(chId, msgId);
if (m.id == 0) return;
int row = -1;
bool wasPending = false;
for (int r = 0; r < m_model->rowCount(); r++) {
QStandardItem* it = m_model->item(r);
if (it && it->data(MsgIdRole).toLongLong() == msgId) {
row = r;
wasPending = it->data(MsgVoiceDownloadPendingRole).toBool();
break;
}
}
if (row < 0) return;
QStandardItem* item = m_model->item(row);
setVoiceMessageRoles(item, m.data, m.contentType,
m_db->dbPath() + "/media", chId, m.localAttrs);
item->setData(0, MsgMediaBlocksDoneRole);
item->setData(false, MsgVoiceDownloadPendingRole);
if (m.contentType == "audio/opus" && wasPending) {
QString fp = item->data(MsgVoiceFileRole).toString();
if (!fp.isEmpty() && QFile::exists(fp)) {
QTimer::singleShot(500, this, [this, fp]() {
MessageDelegate::stopAllAutoPlay();
VoicePlayer::playOpusFile(fp, this, [fp](float actualDur, uint64_t playbackId) { MessageDelegate::startAutoPlay(fp, actualDur, playbackId); });
});
}
}
if (m.contentType == "video/mp4") prepareVideoThumbnail(item->data(MsgVoiceFileRole).toString());
}
/* Общие превью для каналов/PM; не более двух decoder workers, без запуска из paint. */
void MessageList::prepareVideoThumbnail(const QString& path) {
if (path.isEmpty() || m_thumbnailJobs.contains(path) || !QFile::exists(path) || QFile::exists(path + ".jpg")) return;
m_thumbnailPool.setMaxThreadCount(2);
m_thumbnailJobs.insert(path);
QPointer<MessageList> self(this);
QObject* dispatcher = QCoreApplication::instance();
m_thumbnailPool.start([self, dispatcher, path]() {
QString output = path + ".jpg";
int rc = video_extract_thumbnail(path.toUtf8().constData(), output.toUtf8().constData(), 320);
if (rc) DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "thumbnail failed path=%s error=%d", qPrintable(path), rc);
else DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "thumbnail ready path=%s", qPrintable(output));
QMetaObject::invokeMethod(dispatcher, [self, path]() {
if (!self) return;
self->m_thumbnailJobs.remove(path);
self->m_view->viewport()->update();
}, Qt::QueuedConnection);
});
}
/* Цитаты разрешаются независимо от окна истории; поздняя синхронизация обновляет уже видимые ответы. */
void MessageList::updateReply(QStandardItem* item, const QByteArray& original) {
item->setData(QString::fromLatin1(original.toHex()), MsgReplyToRole);
item->setData(!original.isEmpty(), MsgHasQuoteRole);
if (original.isEmpty() || !m_db) return;
const auto message = m_db->getMessageByKey(m_currentChannelId, original);
const QString author = message.messageId.isEmpty() ? QCoreApplication::translate("ChatGui", "Reply") : authorName(message.authorNodeId);
const QString preview = message.messageId.isEmpty()
? QCoreApplication::translate("ChatGui", "Original message is not available yet") : DbManager::messagePreview(message);
if (item->data(MsgQuoteAuthorRole).toString() == author && item->data(MsgQuoteTextRole).toString() == preview) return;
item->setData(author, MsgQuoteAuthorRole); item->setData(preview, MsgQuoteTextRole);
GUI_DEBUG("reply: preview ch=%s original=%s resolved=%d", qPrintable(m_currentChannelId), original.toHex().constData(),
!message.messageId.isEmpty());
}
void MessageList::refreshReplies() {
for (int row = 0; row < m_model->rowCount(); ++row) {
auto* item = m_model->item(row);
const QByteArray key = QByteArray::fromHex(item->data(MsgReplyToRole).toString().toLatin1());
if (!key.isEmpty()) updateReply(item, key);
}
}
/* Перезагружаем только ограниченное окно вокруг цели; обычный refresh сохраняет открытый участок истории. */
void MessageList::jumpToMessage(const QString& messageId) {
if (!m_db || isDmChannel(m_currentChannelId)) return;
const QByteArray key = QByteArray::fromHex(messageId.toLatin1());
if (key.size() != 32) { GUI_ERROR("reply: invalid navigation key=%s", qPrintable(messageId)); return; }
bool present = false;
for (int row = 0; row < m_model->rowCount(); ++row)
if (m_model->item(row)->data(MsgMessageKeyRole).toString() == messageId) { present = true; break; }
if (!present) {
if (m_db->getMessageByKey(m_currentChannelId, key).messageId.isEmpty()) {
GUI_WARN("reply: navigation original unavailable ch=%s id=%s", qPrintable(m_currentChannelId), qPrintable(messageId));
QToolTip::showText(QCursor::pos(), QCoreApplication::translate("ChatGui", "Original message is not available yet"), m_view);
return;
}
m_historyAnchor = key;
loadChannel(m_currentChannelId);
}
GUI_DEBUG("reply: navigate ch=%s id=%s loaded_window=%d", qPrintable(m_currentChannelId), qPrintable(messageId), !present);
const QString channel = m_currentChannelId;
QTimer::singleShot(0, this, [this, channel, messageId]() {
if (channel != m_currentChannelId) return;
for (int row = 0; row < m_model->rowCount(); ++row) {
const QModelIndex index = m_model->index(row, 0);
if (index.data(MsgMessageKeyRole).toString() != messageId) continue;
m_view->scrollTo(index, QAbstractItemView::PositionAtCenter);
m_model->setData(index, true, MsgHighlightRole);
QTimer::singleShot(1500, this, [this, index = QPersistentModelIndex(index)]() {
if (index.isValid()) m_model->setData(index, false, MsgHighlightRole);
});
return;
}
GUI_WARN("reply: navigation target disappeared ch=%s id=%s", qPrintable(channel), qPrintable(messageId));
});
}
void MessageList::onMessageSubmitted(const uint8_t* data, int len) {
if (!data || len != 9 || data[8] > 1) { GUI_ERROR("reply: invalid submission result bytes=%d", len); return; }
quint64 request = 0;
for (unsigned i = 0; i < 8; ++i) request |= quint64(data[i]) << (8 * i);
m_inputBar->submissionResult(request, data[8] != 0);
}
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/");
bool isDmFile = isDmChannel(idx.data(MsgChannelIdRole).toString()) && ct == "application/octet-stream";
bool isDm = isDmChannel(idx.data(MsgChannelIdRole).toString());
QString filePath = idx.data(MsgVoiceFileRole).toString();
QString displayName = idx.data(MsgFileDisplayNameRole).toString();
bool downloaded = !filePath.isEmpty() && QFile::exists(filePath);
QMenu menu(m_view);
QString messageId = idx.data(MsgMessageKeyRole).toString();
if (!isDm && messageId.size() == 64) {
const QString channel = idx.data(MsgChannelIdRole).toString();
connect(menu.addAction(QCoreApplication::translate("ChatGui", "Reply")), &QAction::triggered, this, [this, channel, messageId]() {
if (channel != m_currentChannelId || !m_db) return;
const auto original = m_db->getMessageByKey(channel, QByteArray::fromHex(messageId.toLatin1()));
if (original.messageId.isEmpty()) { GUI_WARN("reply: selected original disappeared ch=%s", qPrintable(channel)); return; }
m_inputBar->setReply(original.messageId, authorName(original.authorNodeId), DbManager::messagePreview(original));
});
if (ct == "poll") connect(menu.addAction(QCoreApplication::translate("ChatGui", "Vote / results")), &QAction::triggered,
this, [this, messageId]() { showPoll(messageId); });
auto* reactions = menu.addMenu(QCoreApplication::translate("ChatGui", "React"));
addReactionActions(reactions, idx.data(MsgChannelIdRole).toString(), messageId);
connect(menu.addAction(QCoreApplication::translate("ChatGui", "Clear reactions")), &QAction::triggered,
this, [this, messageId]() { sendFeedback({{"cmd", "react"}, {"message_id", messageId}, {"clear", true}}); });
}
QAction* saveAct = nullptr;
if (isVideo || isImage || isDmFile) {
saveAct = menu.addAction(QCoreApplication::translate("ChatGui", "Save to file")); saveAct->setEnabled(downloaded);
}
QAction* dlAct = nullptr;
if ((isVideo || isImage) && !downloaded && !isDm) dlAct = menu.addAction(QCoreApplication::translate("ChatGui", "Download"));
QAction* chosen = menu.exec(m_view->viewport()->mapToGlobal(pos));
if (saveAct && chosen == saveAct) {
QString base = displayName;
if (base.isEmpty()) base = QFileInfo(filePath).fileName();
QString dest = QFileDialog::getSaveFileName(this, QCoreApplication::translate("ChatGui", "Save file"), base);
if (dest.isEmpty()) return;
std::thread([filePath, dest]() {
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));
}).detach();
return;
}
if (dlAct && 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));
req->inst = gui_bridge_get_inst();
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);
}
}
/* ── DM (p2p чат) ── */
void MessageList::clearDmCallRoute() {
m_callPeer = 0; m_callGroup = 0;
m_callBtn->setEnabled(false);
m_callBtn->setToolTip(QCoreApplication::translate("ChatGui", "Peer unavailable"));
m_peerStatus->setStyleSheet("background: #E53935; border-radius: 6px;");
m_peerStatus->setToolTip(QCoreApplication::translate("ChatGui", "Offline"));
}
void MessageList::refreshDmCallRoute() {
if (!isDmChannel(m_currentChannelId)) return;
auto* inst = gui_bridge_get_inst();
if (!inst) { clearDmCallRoute(); return; }
auto* req = (struct dm_messages_req*)u_calloc(1, sizeof(struct dm_messages_req));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "PM route request: allocation failed"); clearDmCallRoute(); return; }
req->inst = inst;
QByteArray conv = dmConvId(m_currentChannelId).toUtf8();
snprintf(req->conv_id, sizeof(req->conv_id), "%s", conv.constData());
gui_bridge_post_uasync_fn(dm_call_route_trampoline, req);
}
void MessageList::onDmCallRoute(const uint8_t* data, int len) {
if (!data || len < 17 || len != 17 + data[0]) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "PM route snapshot: invalid size=%d", len); return;
}
QString conv = QString::fromUtf8((const char*)data + 1, data[0]);
if (m_currentChannelId != QStringLiteral("dm:") + conv) return;
quint64 peer = 0, group = 0;
memcpy(&peer, data + 1 + data[0], 8);
memcpy(&group, data + 9 + data[0], 8);
if (m_callPeer != peer || m_callGroup != group)
DEBUG_INFO(DEBUG_CATEGORY_CALL, "PM button: conv=%s peer=%llu group=%llu enabled=%d",
conv.toUtf8().constData(), (unsigned long long)peer, (unsigned long long)group, peer && group);
m_callPeer = peer; m_callGroup = group;
m_callBtn->setEnabled(peer && group);
m_peerStatus->setStyleSheet(group ? "background: #4CAF50; border-radius: 6px;" : "background: #E53935; border-radius: 6px;");
m_peerStatus->setToolTip(group ? QCoreApplication::translate("ChatGui", "Online") : QCoreApplication::translate("ChatGui", "Offline"));
m_callBtn->setToolTip(group ? QCoreApplication::translate("ChatGui", "Call peer") : QCoreApplication::translate("ChatGui", "Peer unavailable"));
}
bool MessageList::isDmChannel(const QString& channelId) {
return channelId.startsWith(QStringLiteral("dm:"));
}
QString MessageList::dmConvId(const QString& channelId) {
return channelId.mid(3);
}
void MessageList::requestDmMessages(const QString& channelId) {
QString conv = dmConvId(channelId);
struct dm_messages_req* req = (struct dm_messages_req*)u_malloc(sizeof(*req));
if (!req) return;
memset(req, 0, sizeof(*req));
req->inst = gui_bridge_get_inst();
strncpy(req->conv_id, conv.toUtf8().constData(), sizeof(req->conv_id) - 1);
gui_bridge_post_uasync_fn(dm_messages_trampoline, req);
}
void MessageList::onAttachmentPreparation(const uint8_t* data, int len) {
if (!data || len < 18 || len != 18 + data[0] || data[len - 1] > 2) {
DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "Invalid attachment preparation event bytes=%d", len); return;
}
int length = data[0];
m_inputBar->updatePreparation(QString::fromUtf8((const char*)data + 1, length),
QByteArray((const char*)data + 1 + length, 16), data[len - 1]);
}
void MessageList::onDmMessages(const uint8_t* data, int len) {
if (len < 3) return;
uint8_t cl = data[0];
if (len < 1 + (int)cl + 2) return;
QString convId = QString::fromUtf8((const char*)data + 1, cl);
if (m_currentChannelId != QStringLiteral("dm:") + convId) return; /* stale response */
QJsonParseError error;
auto document = QJsonDocument::fromJson(QByteArray((const char*)data + 1 + cl, len - 1 - cl), &error);
if (error.error != QJsonParseError::NoError || !document.isArray()) {
DEBUG_ERROR(DEBUG_CATEGORY_DM, "Invalid PM history JSON: %s", qPrintable(error.errorString())); return;
}
auto* scrollbar = m_view->verticalScrollBar();
bool atBottom = m_model->rowCount() == 0 || scrollbar->value() >= scrollbar->maximum();
int previousScroll = scrollbar->value();
m_model->clear();
m_avatarCache.clear();
for (const auto& entry : document.array()) {
auto message = entry.toObject();
int dir = message.value("dir").toInt();
qint64 seq = (qint64)message.value("seq").toDouble();
qint64 ts = (qint64)message.value("ts").toDouble();
quint64 author = message.value("author").toString().toULongLong();
QString text = message.value("data").toString();
const bool invite = message.value("ct").toString() == QStringLiteral("invite");
const QString inviteLink = invite ? text : QString();
if (invite) {
const QString groupName = message.value("tags").toObject().value("group_name").toString();
text = QCoreApplication::translate("ChatGui", "Invitation to group “%1”").arg(groupName) + '\n' + inviteLink;
GUI_DEBUG("invite PM: rendered conv=%s seq=%lld group_name_bytes=%d link_bytes=%d", qPrintable(m_currentChannelId),
(long long)seq, (int)groupName.toUtf8().size(), (int)inviteLink.size());
}
bool file = message.value("ct").toString() == "file";
QString state = message.value("media_status").toString();
QString status;
if (file) {
if (state == "delivered") status = QCoreApplication::translate("ChatGui", "Delivered");
else if (state == "expired") status = QCoreApplication::translate("ChatGui", "Storage period expired");
else if (dir == 1) status = QCoreApplication::translate("ChatGui", "Awaiting delivery");
else if (state == "ready") status = QCoreApplication::translate("ChatGui", "Received");
else status = QCoreApplication::translate("ChatGui", "Downloading…");
} else if (dir == 1) status = message.value("pending").toBool() ? QCoreApplication::translate("ChatGui",
"Awaiting delivery") : QCoreApplication::translate("ChatGui",
"Delivered");
AccountRow acc = m_db ? m_db->getAccount(author) : AccountRow();
QString name = authorName(author);
QString letter = acc.avatarLetter.isEmpty() ? name.mid(0, 1) : acc.avatarLetter;
QColor color(acc.avatarColor);
if (!m_avatarCache.contains(author))
m_avatarCache[author] = color;
auto avatar = makeAvatar(color, letter);
if (auto* manager = AvatarManager::instance()) {
const QImage image = manager->image(m_currentChannelId, author);
if (!image.isNull()) avatar = QPixmap::fromImage(image);
}
auto time = QDateTime::fromMSecsSinceEpoch((qint64)ts).toString("HH:mm");
if (!file && !status.isEmpty()) time += QStringLiteral(" · ") + status;
auto *item = new QStandardItem();
setMsg(item, name, text, time, avatar, false,
false, QString(), QString(), QVariantList{});
item->setData((qint64)seq, MsgIdRole);
item->setData(QVariant::fromValue(author), MsgAuthorNodeIdRole);
item->setData(dir == 1 ? 1 : 0, MsgIsOutgoingRole);
item->setData(m_currentChannelId, MsgChannelIdRole);
QString type = message.value("content_type").toString(QStringLiteral("text/plain"));
item->setData(type, MsgContentTypeRole);
if (invite) item->setData(inviteLink, MsgInviteLinkRole);
if (file) {
QString path = message.value("media_path").toString();
item->setData(path, MsgVoiceFileRole);
item->setData(text, MsgFileDisplayNameRole);
item->setData((qint64)message.value("media_size").toDouble(), MsgFileSizeRole);
item->setData(path.isEmpty() ? (state == "expired" ? 0 : 1) : 2, MsgFileDownloadStateRole);
item->setData(status, MsgDeliveryStateRole);
int duration = message.value("duration_ms").toInt();
if (type == "audio/opus") {
item->setData(QString::number(duration / 1000.0, 'f', 1) + "s", MsgVoiceDurationRole);
item->setData((qint64)message.value("media_size").toDouble(), MsgVoiceFileSizeRole);
QVariantList waveform;
const QString levels = QStringLiteral("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/");
for (QChar level : message.value("waveform").toString()) waveform.append(qMax(0, levels.indexOf(level)) / 63.0);
item->setData(waveform, MsgVoiceWaveformRole);
} else if (type == "video/mp4") {
item->setData(duration, MsgVideoDurationMsRole);
item->setData(message.value("width").toInt(), MsgVideoWidthRole);
item->setData(message.value("height").toInt(), MsgVideoHeightRole);
prepareVideoThumbnail(path);
}
}
m_model->appendRow(item);
}
if (atBottom) m_view->scrollToBottom();
else scrollbar->setValue(previousScroll);
}
/* ── рация ── */
void MessageList::onRadioTalk(quint64 groupId, quint64 srcNodeId, bool on) {
if (m_currentChannelId.isEmpty()) return;
if (groupId != m_currentChannelId.toULongLong()) return;
if (!m_talkingLabel) return;
if (on) {
QString name = authorName(srcNodeId);
if (name.isEmpty()) name = QStringLiteral("0x%1").arg(srcNodeId, 0, 16);
m_talkingLabel->setText(QCoreApplication::translate("ChatGui", "🎙 %1 is speaking").arg(name));
m_talkingLabel->show();
} else {
m_talkingLabel->hide();
}
}
void MessageList::onRadioToggled(bool on) {
if (m_currentChannelId.isEmpty() || isDmChannel(m_currentChannelId)) return;
quint64 groupId = m_currentChannelId.toULongLong();
if (groupId == 0) return;
struct radio_set_active_arg* a = (struct radio_set_active_arg*)u_malloc(sizeof(*a));
if (!a) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioPanel: active request allocation failed group=%016llx", (unsigned long long)groupId);
QSignalBlocker blocker(m_radioBtn);
m_radioBtn->setChecked(m_radioOn);
return;
}
a->inst = gui_bridge_get_inst();
a->group_id = groupId;
a->on = on ? 1 : 0;
gui_bridge_post_uasync_fn(radio_set_active_trampoline, a);
if (on) {
if (RadioAudioEngine::instance()->start(groupId) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioPanel: radio start failed group=%016llx", (unsigned long long)groupId);
auto* undo = static_cast<radio_set_active_arg*>(u_calloc(1, sizeof(radio_set_active_arg)));
if (undo) {
undo->inst = gui_bridge_get_inst(); undo->group_id = groupId;
gui_bridge_post_uasync_fn(radio_set_active_trampoline, undo);
} else DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioPanel: cannot cancel failed radio subscription");
QSignalBlocker blocker(m_radioBtn);
m_radioBtn->setChecked(false);
return;
}
} else {
RadioAudioEngine::instance()->stop();
m_talkingLabel->hide();
}
m_radioOn = on;
updateRadioUi();
}
void MessageList::updateRadioUi() {
bool show = m_radioOn && !isDmChannel(m_currentChannelId);
m_radioPanel->setVisible(show);
}