Новый модуль src/radio (radio/radio_audio/radio_headless) — push-to-talk радио поверх topo-групп, доставка аудио в канал, headless-режим и тесты. Интеграция в chatgui (ptt_hotkey_manager, radio_audio_engine, радионастройки, вызовы) и chatgui-android (RadioAudioEngine, RadioOverlayService, UI).
1153 lines
45 KiB
C++
1153 lines
45 KiB
C++
#include "messagelist.h"
|
|
#include "chatview.h"
|
|
#include "messagedelegate.h"
|
|
#include "inputbar.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 <QDateTime>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QFile>
|
|
#include <QScrollBar>
|
|
#include <QApplication>
|
|
#include <QPointer>
|
|
#include <QDebug>
|
|
#include <QFileInfo>
|
|
#include <QFile>
|
|
#include <QImageReader>
|
|
#include <QMenu>
|
|
#include <QFileDialog>
|
|
#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 "dm/dm_core.h"
|
|
#include "video/video.h"
|
|
#include "radio/radio.h"
|
|
#include "../../lib/u_async.h"
|
|
#include "../../lib/mem.h"
|
|
#include "../../lib/debug_config.h"
|
|
void chat_core_save_ui_state_trampoline(void* arg);
|
|
}
|
|
|
|
#include "radio_audio_engine.h"
|
|
#include <QLabel>
|
|
#include <QPushButton>
|
|
#include <QHBoxLayout>
|
|
|
|
static QPixmap makeAvatar(const QColor &color, const QString &letter, int size = 36) {
|
|
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);
|
|
layout->setContentsMargins(0, 0, 0, 0);
|
|
layout->setSpacing(0);
|
|
|
|
/* ── шапка чата: название канала + переключатель рации ── */
|
|
{
|
|
auto* header = new QWidget(this);
|
|
auto* hl = new QHBoxLayout(header);
|
|
hl->setContentsMargins(10, 6, 10, 6);
|
|
hl->setSpacing(8);
|
|
|
|
m_channelTitle = new QLabel(header);
|
|
m_channelTitle->setStyleSheet("font-weight: bold; font-size: 14px;");
|
|
m_channelTitle->setText(QStringLiteral("Канал"));
|
|
hl->addWidget(m_channelTitle, 1);
|
|
|
|
m_talkingLabel = new QLabel(header);
|
|
m_talkingLabel->setStyleSheet("color: #27ae60; font-size: 12px;");
|
|
m_talkingLabel->hide();
|
|
hl->addWidget(m_talkingLabel);
|
|
|
|
m_radioBtn = new QPushButton(QStringLiteral("\xF0\x9F\x93\xBB"), header);
|
|
m_radioBtn->setCheckable(true);
|
|
m_radioBtn->setFixedSize(34, 30);
|
|
m_radioBtn->setCursor(Qt::PointingHandCursor);
|
|
m_radioBtn->setToolTip(QStringLiteral("Рация: включить/выключить прослушивание канала"));
|
|
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_view->setModel(m_model);
|
|
auto* msgDelegate = new MessageDelegate(this);
|
|
msgDelegate->setVideoOpenCallback([this](const QString& path) {
|
|
emit openVideoRequested(path);
|
|
});
|
|
msgDelegate->setImageOpenCallback([this](const QString& path) {
|
|
emit openImageRequested(path);
|
|
});
|
|
m_view->setItemDelegate(msgDelegate);
|
|
m_view->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
|
m_view->setSelectionMode(QAbstractItemView::NoSelection);
|
|
m_view->setContextMenuPolicy(Qt::CustomContextMenu);
|
|
connect(m_view, &QWidget::customContextMenuRequested,
|
|
this, &MessageList::onMessageContextMenu);
|
|
m_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
m_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
|
|
m_view->setFrameShape(QFrame::NoFrame);
|
|
m_view->setBackgroundImage("../resources/bg.jpg");
|
|
layout->addWidget(m_view, 1);
|
|
|
|
/* ── плавающая PTT-кнопка поверх чата (видна при включённой рации) ── */
|
|
m_pttBtn = new QPushButton(QStringLiteral("\xF0\x9F\x8E\xA4"), this);
|
|
m_pttBtn->setFixedSize(96, 96);
|
|
m_pttBtn->setCursor(Qt::PointingHandCursor);
|
|
m_pttBtn->setToolTip(QStringLiteral("Держите и говорите"));
|
|
m_pttBtn->setStyleSheet(
|
|
"QPushButton { border: 3px solid white; border-radius: 48px; background: #e74c3c; color: white; font-size: 36px; }"
|
|
"QPushButton:pressed { background: #c0392b; }");
|
|
m_pttBtn->hide();
|
|
connect(m_pttBtn, &QPushButton::pressed, this, []() {
|
|
RadioAudioEngine::instance()->talkBegin();
|
|
});
|
|
connect(m_pttBtn, &QPushButton::released, this, []() {
|
|
RadioAudioEngine::instance()->talkEnd();
|
|
});
|
|
|
|
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);
|
|
|
|
connect(m_inputBar, &InputBar::sendMessage, this, [this](const QString &text) {
|
|
if (m_currentChannelId.isEmpty()) {
|
|
qWarning("MessageList: no channel selected, message not sent");
|
|
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());
|
|
memset(req, 0, sizeof(*req));
|
|
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::sendVoiceMessage, this, [this](const QString& mediaSrc, const QString& mediaDest,
|
|
int durationMs, const QString& wfStr) {
|
|
if (m_currentChannelId.isEmpty()) return;
|
|
|
|
QByteArray textData = wfStr.toUtf8();
|
|
auto ts = QDateTime::currentMSecsSinceEpoch();
|
|
|
|
struct chat_msg_submit* req = (struct chat_msg_submit*)
|
|
u_malloc(sizeof(struct chat_msg_submit) + textData.size());
|
|
memset(req, 0, sizeof(*req));
|
|
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), "audio/opus");
|
|
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 = 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::sendFileAttachment, this, [this](const QString& mediaSrc, const QString& mediaDest,
|
|
const QString& displayName) {
|
|
if (m_currentChannelId.isEmpty()) 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());
|
|
memset(req, 0, sizeof(*req));
|
|
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), "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::sendVideoAttachment, this, [this](const QString& mediaSrc, const QString& mediaDest,
|
|
const QString& displayName) {
|
|
if (m_currentChannelId.isEmpty()) 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());
|
|
memset(req, 0, sizeof(*req));
|
|
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), "video/mp4");
|
|
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 = 0;
|
|
req->media_video = 1;
|
|
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& mediaSrc, const QString& mediaDest,
|
|
const QString& displayName) {
|
|
if (m_currentChannelId.isEmpty()) return;
|
|
|
|
QByteArray textData = displayName.toUtf8();
|
|
auto ts = QDateTime::currentMSecsSinceEpoch();
|
|
|
|
QFileInfo fi(displayName);
|
|
QString suf = fi.suffix().toLower();
|
|
QString ct = "image/jpeg";
|
|
if (suf == "png") ct = "image/png";
|
|
else if (suf == "gif") ct = "image/gif";
|
|
else if (suf == "bmp") ct = "image/bmp";
|
|
else if (suf == "webp") ct = "image/webp";
|
|
|
|
int iw = 0, ih = 0;
|
|
QImageReader reader(mediaSrc);
|
|
reader.setAutoTransform(true);
|
|
if (reader.canRead()) {
|
|
QSize sz = reader.size();
|
|
if (sz.width() > 0 && sz.height() > 0) { iw = sz.width(); ih = sz.height(); }
|
|
}
|
|
|
|
struct chat_msg_submit* req = (struct chat_msg_submit*)
|
|
u_malloc(sizeof(struct chat_msg_submit) + textData.size());
|
|
memset(req, 0, sizeof(*req));
|
|
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), "%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::hoveredIndexChanged, this, [this](const QModelIndex &idx) {
|
|
bool hasAnim = false;
|
|
if (idx.isValid()) {
|
|
QString text = idx.data(MsgTextRole).toString();
|
|
for (auto it = g_animatedEmojiMap.begin(); it != g_animatedEmojiMap.end(); ++it) {
|
|
if (text.contains(it.key())) { hasAnim = true; break; }
|
|
}
|
|
if (!hasAnim) {
|
|
QVariantList reactions = idx.data(MsgReactionsRole).toList();
|
|
for (const QVariant &rv : reactions) {
|
|
if (g_animatedEmojiMap.contains(rv.toMap()["emoji"].toString()))
|
|
{ hasAnim = true; break; }
|
|
}
|
|
}
|
|
}
|
|
bool wasActive = m_animHoverActive;
|
|
m_animHoverActive = hasAnim;
|
|
if (!wasActive && m_animHoverActive) AnimTimer::instance()->activate();
|
|
else if (wasActive && !m_animHoverActive) AnimTimer::instance()->deactivate();
|
|
});
|
|
|
|
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();
|
|
/* выключаем рацию предыдущего канала при переключении */
|
|
if (m_radioOn) {
|
|
m_radioBtn->blockSignals(true);
|
|
m_radioBtn->setChecked(false);
|
|
m_radioBtn->blockSignals(false);
|
|
m_radioOn = false;
|
|
RadioAudioEngine::instance()->stop();
|
|
updateRadioUi();
|
|
}
|
|
} 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());
|
|
}
|
|
}
|
|
|
|
m_model->clear();
|
|
m_avatarCache.clear();
|
|
m_currentChannelId = 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);
|
|
|
|
/* DM: только текст; сообщения расшифровываются в dm_core (uasync-поток) */
|
|
if (isDmChannel(channelId)) {
|
|
m_radioBtn->setEnabled(false);
|
|
m_inputBar->setMediaEnabled(false);
|
|
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);
|
|
|
|
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);
|
|
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);
|
|
}
|
|
|
|
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;
|
|
|
|
qint64 maxId = 0;
|
|
for (int r = 0; r < m_model->rowCount(); r++)
|
|
if (m_model->item(r)->data(MsgIdRole).toLongLong() > maxId)
|
|
maxId = m_model->item(r)->data(MsgIdRole).toLongLong();
|
|
|
|
auto newMsgs = m_db->getMessagesSince(m_currentChannelId, maxId);
|
|
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) {
|
|
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);
|
|
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);
|
|
while (m_model->rowCount() > 200)
|
|
m_model->removeRow(0);
|
|
}
|
|
|
|
if (wasAtBottom) m_view->scrollToBottom();
|
|
m_readDebounce->start();
|
|
}
|
|
|
|
void MessageList::saveChannelState() {
|
|
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_DEBUG, "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);
|
|
}
|
|
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_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_DEBUG,
|
|
"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();
|
|
float actualDur = VoicePlayer::playOpusFile(fp);
|
|
MessageDelegate::startAutoPlay(fp, actualDur);
|
|
});
|
|
}
|
|
}
|
|
|
|
if (m.contentType == "video/mp4") {
|
|
QString fp = item->data(MsgVoiceFileRole).toString();
|
|
QString thumbPath = fp + ".jpg";
|
|
if (!fp.isEmpty() && QFile::exists(fp) && !QFile::exists(thumbPath)) {
|
|
QPointer<MessageList> self(this);
|
|
std::thread([self, fp, thumbPath]() {
|
|
int rc = video_extract_thumbnail(fp.toUtf8().constData(),
|
|
thumbPath.toUtf8().constData(), 320);
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "MessageList: thumbnail %s rc=%d",
|
|
qPrintable(thumbPath), rc);
|
|
if (self) {
|
|
QMetaObject::invokeMethod(self, [self]() { self->m_view->viewport()->update(); },
|
|
Qt::QueuedConnection);
|
|
}
|
|
}).detach();
|
|
}
|
|
}
|
|
}
|
|
|
|
void MessageList::onMessageContextMenu(const QPoint& pos) {
|
|
QModelIndex idx = m_view->indexAt(pos);
|
|
if (!idx.isValid()) return;
|
|
|
|
QString ct = idx.data(MsgContentTypeRole).toString();
|
|
bool isVideo = (ct == "video/mp4");
|
|
bool isImage = ct.startsWith("image/");
|
|
if (!isVideo && !isImage) return;
|
|
|
|
QString filePath = idx.data(MsgVoiceFileRole).toString();
|
|
QString displayName = idx.data(MsgFileDisplayNameRole).toString();
|
|
bool downloaded = !filePath.isEmpty() && QFile::exists(filePath);
|
|
|
|
QMenu menu(m_view);
|
|
QAction* saveAct = menu.addAction(QString::fromUtf8("Сохранить в файл"));
|
|
saveAct->setEnabled(downloaded);
|
|
QAction* dlAct = nullptr;
|
|
if (!downloaded) dlAct = menu.addAction(QString::fromUtf8("Скачать"));
|
|
|
|
QAction* chosen = menu.exec(m_view->viewport()->mapToGlobal(pos));
|
|
|
|
if (chosen == saveAct) {
|
|
QString base = displayName;
|
|
if (base.isEmpty()) base = QFileInfo(filePath).fileName();
|
|
QString dest = QFileDialog::getSaveFileName(this, QString::fromUtf8("Сохранить файл"), base);
|
|
if (dest.isEmpty()) return;
|
|
if (!QFile::copy(filePath, dest)) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "MessageList: save failed src=%s dst=%s",
|
|
qPrintable(filePath), qPrintable(dest));
|
|
} else {
|
|
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "MessageList: saved %s -> %s",
|
|
qPrintable(filePath), qPrintable(dest));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (chosen == dlAct) {
|
|
qint64 msgId = idx.data(MsgIdRole).toLongLong();
|
|
QString chId = idx.data(MsgChannelIdRole).toString();
|
|
if (chId.isEmpty() || msgId <= 0) return;
|
|
struct attachment_dl_req* req = (struct attachment_dl_req*)
|
|
u_malloc(sizeof(struct attachment_dl_req));
|
|
if (!req) return;
|
|
memset(req, 0, sizeof(*req));
|
|
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 чат) ── */
|
|
|
|
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::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 */
|
|
|
|
uint16_t count; memcpy(&count, data + 1 + cl, 2);
|
|
const uint8_t* p = data + 1 + cl + 2;
|
|
const uint8_t* end = data + len;
|
|
|
|
m_model->clear();
|
|
m_avatarCache.clear();
|
|
|
|
for (int i = 0; i < count; i++) {
|
|
if (p + 1 + 8 + 8 + 8 + 1 > end) break;
|
|
int dir = p[0]; p += 1;
|
|
uint64_t seq; memcpy(&seq, p, 8); p += 8;
|
|
uint64_t ts; memcpy(&ts, p, 8); p += 8;
|
|
quint64 author; memcpy(&author, p, 8); p += 8;
|
|
uint8_t ctl = *p; p += 1;
|
|
if (p + ctl > end) break;
|
|
p += ctl; /* ct игнорируем: DM v1 — текст */
|
|
if (p + 2 > end) break;
|
|
uint16_t dlen; memcpy(&dlen, p, 2); p += 2;
|
|
if (p + dlen > end) break;
|
|
QString text = QString::fromUtf8((const char*)p, dlen);
|
|
p += dlen;
|
|
|
|
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);
|
|
auto time = QDateTime::fromMSecsSinceEpoch((qint64)ts).toString("HH:mm");
|
|
|
|
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);
|
|
m_model->appendRow(item);
|
|
}
|
|
|
|
m_view->scrollToBottom();
|
|
}
|
|
|
|
/* ── рация ── */
|
|
|
|
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(QStringLiteral("\xF0\x9F\x8E\x99 %1 говорит").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) 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);
|
|
|
|
m_radioOn = on;
|
|
if (on) {
|
|
RadioAudioEngine::instance()->start(groupId);
|
|
} else {
|
|
RadioAudioEngine::instance()->stop();
|
|
}
|
|
updateRadioUi();
|
|
}
|
|
|
|
void MessageList::updateRadioUi() {
|
|
if (!m_pttBtn) return;
|
|
bool show = m_radioOn && !isDmChannel(m_currentChannelId);
|
|
m_pttBtn->setVisible(show);
|
|
if (show) {
|
|
m_pttBtn->raise();
|
|
layoutPttButton();
|
|
}
|
|
}
|
|
|
|
void MessageList::layoutPttButton() {
|
|
if (!m_pttBtn || !m_pttBtn->isVisible()) return;
|
|
int w = width();
|
|
int x = (w - m_pttBtn->width()) / 2;
|
|
int y = 48; /* под шапкой, поверх списка сообщений */
|
|
m_pttBtn->move(x, y);
|
|
}
|
|
|
|
void MessageList::resizeEvent(QResizeEvent* event) {
|
|
QWidget::resizeEvent(event);
|
|
layoutPttButton();
|
|
}
|