#include #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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include static std::vector> 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 #include #include // Портативная рация: один и тот же значок во всех состояниях кнопки. 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 &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 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=|w=|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=|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(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 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(); 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 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(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); }