You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1307 lines
56 KiB
1307 lines
56 KiB
#include <QCoreApplication> |
|
#include "messagelist.h" |
|
#include "debug_ui.h" |
|
#include "chatview.h" |
|
#include "messagedelegate.h" |
|
#include "inputbar.h" |
|
#include "media_async/attachment_send.h" |
|
#include "emojipanel.h" |
|
#include "emoji.h" |
|
#include "animtimer.h" |
|
#include "lottieicon.h" |
|
#include "audiorecorder.h" |
|
#include "voiceplayback.h" |
|
#include "../db/db_manager.h" |
|
#include "../../lib/debug_config.h" |
|
#include <QVBoxLayout> |
|
#include <QPainter> |
|
#include <QPixmap> |
|
#include <QStyle> |
|
#include <QSignalBlocker> |
|
#include <QDateTime> |
|
#include <QJsonDocument> |
|
#include <QJsonObject> |
|
#include <QJsonArray> |
|
#include <QFile> |
|
#include <QScrollBar> |
|
#include <QApplication> |
|
#include <QPointer> |
|
#include <QDebug> |
|
#include <QFileInfo> |
|
#include <QFile> |
|
#include <QImageReader> |
|
#include <QMenu> |
|
#include <QFileDialog> |
|
#include <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 "dm/dm_media.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 "radiopanel.h" |
|
#include "flagpainter.h" |
|
#include "avatars/avatarmanager.h" |
|
#include <QLabel> |
|
#include <QPushButton> |
|
#include <QHBoxLayout> |
|
|
|
// Портативная рация: один и тот же значок во всех состояниях кнопки. |
|
static QIcon makeRadioIcon(const QColor& color) { |
|
QPixmap pix(48, 48); |
|
pix.fill(Qt::transparent); |
|
QPainter p(&pix); |
|
FlagPainter::drawRadio(&p, 0, 0, 48, color); |
|
p.end(); |
|
QIcon icon; |
|
// Явно задаём все режимы: Qt не перекрашивает значок при checked/disabled. |
|
for (auto mode : {QIcon::Normal, QIcon::Disabled, QIcon::Active, QIcon::Selected}) { |
|
icon.addPixmap(pix, mode, QIcon::Off); |
|
icon.addPixmap(pix, mode, QIcon::On); |
|
} |
|
return icon; |
|
} |
|
|
|
static QPixmap makeAvatar(const QColor &color, const QString &letter, int size = 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); |
|
if (auto* manager = AvatarManager::instance()) connect(manager, &AvatarManager::changed, this, |
|
[this, manager](const QString& channel, quint64 owner) { |
|
if (channel != m_currentChannelId) return; |
|
const QImage image = manager->image(channel, 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_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); |
|
}); |
|
msgDelegate->setDownloadCallback([](const QModelIndex& index) { |
|
QString target = index.data(MsgChannelIdRole).toString(); |
|
qint64 id = index.data(MsgIdRole).toLongLong(); |
|
if (target.isEmpty() || target.startsWith("dm:") || id <= 0) return false; |
|
auto* req = (struct attachment_dl_req*)u_calloc(1, sizeof(struct attachment_dl_req)); |
|
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment: download request allocation failed"); return false; } |
|
req->inst = gui_bridge_get_inst(); req->msg_id = id; |
|
snprintf(req->channel_id, sizeof(req->channel_id), "%s", target.toUtf8().constData()); |
|
gui_bridge_post_uasync_fn(chat_core_attachment_download_trampoline, req); |
|
return true; |
|
}); |
|
m_view->setItemDelegate(msgDelegate); |
|
m_view->setEditTriggers(QAbstractItemView::NoEditTriggers); |
|
m_view->setSelectionMode(QAbstractItemView::NoSelection); |
|
m_view->setContextMenuPolicy(Qt::CustomContextMenu); |
|
connect(m_view, &QWidget::customContextMenuRequested, |
|
this, &MessageList::onMessageContextMenu); |
|
m_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); |
|
m_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); |
|
m_view->setFrameShape(QFrame::NoFrame); |
|
m_view->setBackgroundImage("../resources/bg.png"); |
|
layout->addWidget(m_view, 1); |
|
|
|
if (s_icons.empty()) { |
|
for (const auto &cat : builtinEmojiSet()) { |
|
for (const auto &e : cat.items) { |
|
if (e.type == EmojiType::Animation && !e.animPath.isEmpty()) { |
|
QFile f(e.animPath); |
|
if (f.open(QIODevice::ReadOnly)) { |
|
QByteArray raw = f.readAll(); |
|
auto icon = std::make_unique<LottieIcon>(raw, e.animPath); |
|
if (icon->isValid()) { |
|
AnimTimer::instance()->registerIcon(e.animPath, icon.get()); |
|
s_icons.push_back(std::move(icon)); |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
m_emojiPanel = new EmojiPanel(this); |
|
layout->addWidget(m_emojiPanel); |
|
|
|
m_inputBar = new InputBar(m_emojiPanel, recorder, this); |
|
layout->addWidget(m_inputBar); |
|
|
|
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::submitAttachment, this, [this](const QString& target, struct attachment_send_req* req) { |
|
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) { |
|
if (target.isEmpty()) 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()); |
|
memset(req, 0, sizeof(*req)); |
|
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) { |
|
if (target.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", |
|
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()); |
|
} |
|
} |
|
|
|
m_model->clear(); |
|
m_avatarCache.clear(); |
|
m_currentChannelId = 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); |
|
|
|
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); |
|
} |
|
|
|
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); |
|
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); |
|
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_CHAT, "MessageList::saveChannelState ch=%s bottom=%d read=%lld pos=%lld", |
|
qPrintable(m_currentChannelId), (int)atBottom, (long long)lastReadMsgId, (long long)lastPosMsgId); |
|
|
|
QByteArray cid = m_currentChannelId.toUtf8(); |
|
auto postSaveKey = [&cid](const char* suffix, qint64 val) { |
|
QByteArray key = "ch_" + cid + suffix; |
|
QByteArray valStr = QByteArray::number(val); |
|
size_t keyLen = (size_t)key.size(), valLen = (size_t)valStr.size(); |
|
size_t total = keyLen + 1 + valLen + 1; |
|
if (total > 256) return; |
|
struct save_ui_state_arg* arg = (struct save_ui_state_arg*)u_malloc(sizeof(struct save_ui_state_arg)); |
|
if (!arg) return; |
|
arg->inst = gui_bridge_get_inst(); |
|
memcpy(arg->data, key.constData(), keyLen + 1); |
|
memcpy(arg->data + keyLen + 1, valStr.constData(), valLen + 1); |
|
gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg); |
|
}; |
|
|
|
if (lastReadMsgId > 0) |
|
postSaveKey("_last_read", lastReadMsgId); |
|
postSaveKey("_last_pos", lastPosMsgId); |
|
} |
|
|
|
QString MessageList::authorName(quint64 nodeId) const { |
|
auto it = m_localNicks.find(nodeId); |
|
if (it != m_localNicks.end() && !it->isEmpty()) return *it; |
|
return m_db->getDisplayName(nodeId); |
|
} |
|
|
|
void MessageList::reloadLocalNicks() { |
|
if (m_currentChannelId.isEmpty() || !m_db) return; |
|
m_localNicks = m_db->getLocalNicknames(m_currentChannelId); |
|
for (int r = 0; r < m_model->rowCount(); r++) { |
|
auto* item = m_model->item(r); |
|
if (!item) continue; |
|
QVariant v = item->data(MsgAuthorNodeIdRole); |
|
if (!v.isValid()) continue; |
|
quint64 nid = v.value<quint64>(); |
|
item->setData(authorName(nid), MsgAuthorRole); |
|
} |
|
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_CHAT, |
|
"MessageList::updateReadPosition ch=%s oldRead=%lld newRead=%lld pos=%lld bottom=%d lastVisRow=%d/%d", |
|
qPrintable(m_currentChannelId), (long long)m_lastReadMsgId, |
|
(long long)newReadMsgId, (long long)lastPosMsgId, (int)atBottom, |
|
lastFullyVisibleRow, m_model->rowCount()); |
|
|
|
QByteArray cid = m_currentChannelId.toUtf8(); |
|
auto postSaveKey = [&cid](const char* suffix, qint64 val) { |
|
QByteArray key = "ch_" + cid + suffix; |
|
QByteArray valStr = QByteArray::number(val); |
|
size_t keyLen = (size_t)key.size(), valLen = (size_t)valStr.size(); |
|
size_t total = keyLen + 1 + valLen + 1; |
|
if (total > 256) return; |
|
struct save_ui_state_arg* arg = (struct save_ui_state_arg*)u_malloc(sizeof(struct save_ui_state_arg)); |
|
if (!arg) return; |
|
arg->inst = gui_bridge_get_inst(); |
|
memcpy(arg->data, key.constData(), keyLen + 1); |
|
memcpy(arg->data + keyLen + 1, valStr.constData(), valLen + 1); |
|
gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg); |
|
}; |
|
|
|
if (newReadMsgId > m_lastReadMsgId) { |
|
m_lastReadMsgId = newReadMsgId; |
|
postSaveKey("_last_read", m_lastReadMsgId); |
|
emit readPositionChanged(m_currentChannelId, m_lastReadMsgId); |
|
} |
|
|
|
postSaveKey("_last_pos", lastPosMsgId); |
|
} |
|
|
|
void MessageList::cleanupAnimations() { |
|
size_t n = s_icons.size(); |
|
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "MessageList: cleanupAnimations clearing %zu icons", n); |
|
s_icons.clear(); |
|
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "MessageList: cleanupAnimations done"); |
|
} |
|
|
|
void MessageList::updateMessageProgress(const QString& chId, int64_t msgId, |
|
int blocksDone, int numBlocks) { |
|
if (chId != m_currentChannelId) return; |
|
for (int r = 0; r < m_model->rowCount(); r++) { |
|
QStandardItem* item = m_model->item(r); |
|
if (!item) continue; |
|
if (item->data(MsgIdRole).toLongLong() == msgId) { |
|
item->setData(blocksDone, MsgMediaBlocksDoneRole); |
|
item->setData(numBlocks, MsgMediaNumBlocksRole); |
|
item->setData(1, MsgFileDownloadStateRole); |
|
m_view->viewport()->update(); |
|
return; |
|
} |
|
} |
|
} |
|
|
|
void MessageList::onAttachmentDownloaded(const QString& chId, int64_t msgId) { |
|
if (chId != m_currentChannelId || !m_db || !m_db->isOpen()) return; |
|
|
|
MessageRow m = m_db->getMessageById(chId, msgId); |
|
if (m.id == 0) return; |
|
|
|
int row = -1; |
|
bool wasPending = false; |
|
for (int r = 0; r < m_model->rowCount(); r++) { |
|
QStandardItem* it = m_model->item(r); |
|
if (it && it->data(MsgIdRole).toLongLong() == msgId) { |
|
row = r; |
|
wasPending = it->data(MsgVoiceDownloadPendingRole).toBool(); |
|
break; |
|
} |
|
} |
|
if (row < 0) return; |
|
|
|
QStandardItem* item = m_model->item(row); |
|
setVoiceMessageRoles(item, m.data, m.contentType, |
|
m_db->dbPath() + "/media", chId, m.localAttrs); |
|
item->setData(0, MsgMediaBlocksDoneRole); |
|
item->setData(false, MsgVoiceDownloadPendingRole); |
|
|
|
if (m.contentType == "audio/opus" && wasPending) { |
|
QString fp = item->data(MsgVoiceFileRole).toString(); |
|
if (!fp.isEmpty() && QFile::exists(fp)) { |
|
QTimer::singleShot(500, this, [this, fp]() { |
|
MessageDelegate::stopAllAutoPlay(); |
|
VoicePlayer::playOpusFile(fp, this, [fp](float actualDur, uint64_t playbackId) { MessageDelegate::startAutoPlay(fp, actualDur, playbackId); }); |
|
}); |
|
} |
|
} |
|
|
|
if (m.contentType == "video/mp4") prepareVideoThumbnail(item->data(MsgVoiceFileRole).toString()); |
|
} |
|
|
|
/* Общие превью для каналов/PM; не более двух decoder workers, без запуска из paint. */ |
|
void MessageList::prepareVideoThumbnail(const QString& path) { |
|
if (path.isEmpty() || m_thumbnailJobs.contains(path) || !QFile::exists(path) || QFile::exists(path + ".jpg")) return; |
|
m_thumbnailPool.setMaxThreadCount(2); |
|
m_thumbnailJobs.insert(path); |
|
QPointer<MessageList> self(this); |
|
QObject* dispatcher = QCoreApplication::instance(); |
|
m_thumbnailPool.start([self, dispatcher, path]() { |
|
QString output = path + ".jpg"; |
|
int rc = video_extract_thumbnail(path.toUtf8().constData(), output.toUtf8().constData(), 320); |
|
if (rc) DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "thumbnail failed path=%s error=%d", qPrintable(path), rc); |
|
else DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "thumbnail ready path=%s", qPrintable(output)); |
|
QMetaObject::invokeMethod(dispatcher, [self, path]() { |
|
if (!self) return; |
|
self->m_thumbnailJobs.remove(path); |
|
self->m_view->viewport()->update(); |
|
}, Qt::QueuedConnection); |
|
}); |
|
} |
|
|
|
void MessageList::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()); |
|
if (!isVideo && !isImage && !isDmFile) 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(QCoreApplication::translate("ChatGui", "Save to file")); |
|
saveAct->setEnabled(downloaded); |
|
QAction* dlAct = nullptr; |
|
if (!downloaded && !isDm) dlAct = menu.addAction(QCoreApplication::translate("ChatGui", "Download")); |
|
|
|
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, 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); |
|
auto time = QDateTime::fromMSecsSinceEpoch((qint64)ts).toString("HH:mm"); |
|
if (!file && !status.isEmpty()) time += QStringLiteral(" · ") + status; |
|
|
|
auto *item = new QStandardItem(); |
|
setMsg(item, name, text, time, avatar, false, |
|
false, QString(), QString(), QVariantList{}); |
|
item->setData((qint64)seq, MsgIdRole); |
|
item->setData(QVariant::fromValue(author), MsgAuthorNodeIdRole); |
|
item->setData(dir == 1 ? 1 : 0, MsgIsOutgoingRole); |
|
item->setData(m_currentChannelId, MsgChannelIdRole); |
|
QString type = message.value("content_type").toString(QStringLiteral("text/plain")); |
|
item->setData(type, MsgContentTypeRole); |
|
if (invite) item->setData(inviteLink, MsgInviteLinkRole); |
|
if (file) { |
|
QString path = message.value("media_path").toString(); |
|
item->setData(path, MsgVoiceFileRole); |
|
item->setData(text, MsgFileDisplayNameRole); |
|
item->setData((qint64)message.value("media_size").toDouble(), MsgFileSizeRole); |
|
item->setData(path.isEmpty() ? (state == "expired" ? 0 : 1) : 2, MsgFileDownloadStateRole); |
|
item->setData(status, MsgDeliveryStateRole); |
|
int duration = message.value("duration_ms").toInt(); |
|
if (type == "audio/opus") { |
|
item->setData(QString::number(duration / 1000.0, 'f', 1) + "s", MsgVoiceDurationRole); |
|
item->setData((qint64)message.value("media_size").toDouble(), MsgVoiceFileSizeRole); |
|
QVariantList waveform; |
|
const QString levels = QStringLiteral("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"); |
|
for (QChar level : message.value("waveform").toString()) waveform.append(qMax(0, levels.indexOf(level)) / 63.0); |
|
item->setData(waveform, MsgVoiceWaveformRole); |
|
} else if (type == "video/mp4") { |
|
item->setData(duration, MsgVideoDurationMsRole); |
|
item->setData(message.value("width").toInt(), MsgVideoWidthRole); |
|
item->setData(message.value("height").toInt(), MsgVideoHeightRole); |
|
prepareVideoThumbnail(path); |
|
} |
|
} |
|
m_model->appendRow(item); |
|
} |
|
|
|
if (atBottom) m_view->scrollToBottom(); |
|
else scrollbar->setValue(previousScroll); |
|
} |
|
|
|
/* ── рация ── */ |
|
|
|
void MessageList::onRadioTalk(quint64 groupId, quint64 srcNodeId, bool on) { |
|
if (m_currentChannelId.isEmpty()) return; |
|
if (groupId != m_currentChannelId.toULongLong()) return; |
|
if (!m_talkingLabel) return; |
|
if (on) { |
|
QString name = authorName(srcNodeId); |
|
if (name.isEmpty()) name = QStringLiteral("0x%1").arg(srcNodeId, 0, 16); |
|
m_talkingLabel->setText(QCoreApplication::translate("ChatGui", "🎙 %1 is speaking").arg(name)); |
|
m_talkingLabel->show(); |
|
} else { |
|
m_talkingLabel->hide(); |
|
} |
|
} |
|
|
|
void MessageList::onRadioToggled(bool on) { |
|
if (m_currentChannelId.isEmpty() || isDmChannel(m_currentChannelId)) return; |
|
|
|
quint64 groupId = m_currentChannelId.toULongLong(); |
|
if (groupId == 0) return; |
|
|
|
struct radio_set_active_arg* a = (struct radio_set_active_arg*)u_malloc(sizeof(*a)); |
|
if (!a) { |
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioPanel: active request allocation failed group=%016llx", (unsigned long long)groupId); |
|
QSignalBlocker blocker(m_radioBtn); |
|
m_radioBtn->setChecked(m_radioOn); |
|
return; |
|
} |
|
a->inst = gui_bridge_get_inst(); |
|
a->group_id = groupId; |
|
a->on = on ? 1 : 0; |
|
gui_bridge_post_uasync_fn(radio_set_active_trampoline, a); |
|
|
|
if (on) { |
|
if (RadioAudioEngine::instance()->start(groupId) != 0) { |
|
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioPanel: radio start failed group=%016llx", (unsigned long long)groupId); |
|
auto* undo = static_cast<radio_set_active_arg*>(u_calloc(1, sizeof(radio_set_active_arg))); |
|
if (undo) { |
|
undo->inst = gui_bridge_get_inst(); undo->group_id = groupId; |
|
gui_bridge_post_uasync_fn(radio_set_active_trampoline, undo); |
|
} else DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioPanel: cannot cancel failed radio subscription"); |
|
QSignalBlocker blocker(m_radioBtn); |
|
m_radioBtn->setChecked(false); |
|
return; |
|
} |
|
} else { |
|
RadioAudioEngine::instance()->stop(); |
|
m_talkingLabel->hide(); |
|
} |
|
m_radioOn = on; |
|
updateRadioUi(); |
|
} |
|
|
|
void MessageList::updateRadioUi() { |
|
bool show = m_radioOn && !isDmChannel(m_currentChannelId); |
|
m_radioPanel->setVisible(show); |
|
}
|
|
|