|
|
|
|
@ -32,6 +32,7 @@
|
|
|
|
|
#include <QImageReader> |
|
|
|
|
#include <QMenu> |
|
|
|
|
#include <QFileDialog> |
|
|
|
|
#include <QToolTip> |
|
|
|
|
#include <vector> |
|
|
|
|
#include <memory> |
|
|
|
|
#include <thread> |
|
|
|
|
@ -48,6 +49,7 @@ extern "C" {
|
|
|
|
|
#include "radio/radio.h" |
|
|
|
|
#include "../../lib/u_async.h" |
|
|
|
|
#include "../../lib/mem.h" |
|
|
|
|
#include "../../lib/audio_compressor.h" |
|
|
|
|
#include "../../lib/debug_config.h" |
|
|
|
|
void chat_core_save_ui_state_trampoline(void* arg); |
|
|
|
|
} |
|
|
|
|
@ -453,6 +455,12 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
|
|
|
|
}); |
|
|
|
|
connect(msgDelegate, &MessageDelegate::reactionRequested, this, &MessageList::requestReaction); |
|
|
|
|
connect(msgDelegate, &MessageDelegate::pollRequested, this, &MessageList::showPoll); |
|
|
|
|
connect(msgDelegate, &MessageDelegate::replyRequested, this, &MessageList::jumpToMessage); |
|
|
|
|
connect(m_view, &ChatView::latestRequested, this, [this]() { |
|
|
|
|
if (m_historyAnchor.isEmpty()) return; |
|
|
|
|
m_historyAnchor.clear(); loadChannel(m_currentChannelId); |
|
|
|
|
QTimer::singleShot(0, m_view, &QListView::scrollToBottom); |
|
|
|
|
}); |
|
|
|
|
msgDelegate->setDownloadCallback([](const QModelIndex& index) { |
|
|
|
|
QString target = index.data(MsgChannelIdRole).toString(); |
|
|
|
|
qint64 id = index.data(MsgIdRole).toLongLong(); |
|
|
|
|
@ -501,9 +509,13 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
|
|
|
|
layout->addWidget(m_inputBar); |
|
|
|
|
m_inputBar->installFileDropTarget(m_view->viewport()); |
|
|
|
|
|
|
|
|
|
connect(m_inputBar, &InputBar::sendMessage, this, [this](const QString &text) { |
|
|
|
|
connect(m_inputBar, &InputBar::sendMessage, this, [this](const QString &text, const QByteArray& reply, quint64 request) { |
|
|
|
|
if (!gui_bridge_get_inst() || !gui_bridge_is_uasync_ready()) { |
|
|
|
|
GUI_ERROR("send: core unavailable"); m_inputBar->submissionResult(request, false); return; |
|
|
|
|
} |
|
|
|
|
if (m_currentChannelId.isEmpty()) { |
|
|
|
|
qWarning("MessageList: no channel selected, message not sent"); |
|
|
|
|
m_inputBar->submissionResult(request, false); |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -527,7 +539,10 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
|
|
|
|
|
|
|
|
|
struct chat_msg_submit* req = (struct chat_msg_submit*) |
|
|
|
|
u_malloc(sizeof(struct chat_msg_submit) + textData.size()); |
|
|
|
|
if (!req) { GUI_ERROR("send: text request allocation failed"); m_inputBar->submissionResult(request, false); return; } |
|
|
|
|
memset(req, 0, sizeof(*req)); |
|
|
|
|
if (reply.size() == 32) memcpy(req->reply_to, reply.constData(), 32); |
|
|
|
|
req->request_id = request; |
|
|
|
|
req->inst = gui_bridge_get_inst(); |
|
|
|
|
snprintf(req->channel_id, sizeof(req->channel_id), "%s", |
|
|
|
|
m_currentChannelId.toUtf8().constData()); |
|
|
|
|
@ -540,6 +555,11 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
connect(m_inputBar, &InputBar::submitAttachment, this, [this](const QString& target, struct attachment_send_req* req) { |
|
|
|
|
if (!gui_bridge_get_inst() || !gui_bridge_is_uasync_ready()) { |
|
|
|
|
GUI_ERROR("send: attachment core unavailable"); m_inputBar->submissionResult(req->request_id, false); |
|
|
|
|
if (req->pcm_release) req->pcm_release(req->pcm_owner); else u_free((void*)req->pcm); |
|
|
|
|
audio_compressor_destroy(req->compressor); u_free(req); return; |
|
|
|
|
} |
|
|
|
|
req->inst = gui_bridge_get_inst(); req->is_dm = isDmChannel(target); |
|
|
|
|
QByteArray id = (req->is_dm ? dmConvId(target) : target).toUtf8(); |
|
|
|
|
snprintf(req->target, sizeof(req->target), "%s", id.constData()); |
|
|
|
|
@ -547,8 +567,10 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
connect(m_inputBar, &InputBar::sendFileAttachment, this, [this](const QString& target, const QString& mediaSrc, const QString& mediaDest, |
|
|
|
|
const QString& displayName) { |
|
|
|
|
if (target.isEmpty()) return; |
|
|
|
|
const QString& displayName, const QByteArray& reply, quint64 request) { |
|
|
|
|
if (target.isEmpty() || !gui_bridge_get_inst() || !gui_bridge_is_uasync_ready()) { |
|
|
|
|
GUI_ERROR("send: file target/core unavailable"); m_inputBar->submissionResult(request, false); return; |
|
|
|
|
} |
|
|
|
|
if (isDmChannel(target)) { |
|
|
|
|
auto* req = (struct dm_file_req*)u_calloc(1, sizeof(struct dm_file_req)); |
|
|
|
|
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "PM file request allocation failed"); return; } |
|
|
|
|
@ -570,7 +592,10 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
|
|
|
|
|
|
|
|
|
struct chat_msg_submit* req = (struct chat_msg_submit*) |
|
|
|
|
u_malloc(sizeof(struct chat_msg_submit) + textData.size()); |
|
|
|
|
if (!req) { GUI_ERROR("send: file request allocation failed"); m_inputBar->submissionResult(request, false); return; } |
|
|
|
|
memset(req, 0, sizeof(*req)); |
|
|
|
|
if (reply.size() == 32) memcpy(req->reply_to, reply.constData(), 32); |
|
|
|
|
req->request_id = request; |
|
|
|
|
req->inst = gui_bridge_get_inst(); |
|
|
|
|
snprintf(req->channel_id, sizeof(req->channel_id), "%s", |
|
|
|
|
target.toUtf8().constData()); |
|
|
|
|
@ -586,8 +611,10 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
connect(m_inputBar, &InputBar::sendImageAttachment, this, [this](const QString& target, const QString& mediaSrc, const QString& mediaDest, |
|
|
|
|
const QString& displayName) { |
|
|
|
|
if (target.isEmpty()) return; |
|
|
|
|
const QString& displayName, const QByteArray& reply, quint64 request) { |
|
|
|
|
if (target.isEmpty() || !gui_bridge_get_inst() || !gui_bridge_is_uasync_ready()) { |
|
|
|
|
GUI_ERROR("send: image target/core unavailable"); m_inputBar->submissionResult(request, false); return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
QByteArray textData = displayName.toUtf8(); |
|
|
|
|
auto ts = QDateTime::currentMSecsSinceEpoch(); |
|
|
|
|
@ -610,7 +637,10 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
|
|
|
|
|
|
|
|
|
struct chat_msg_submit* req = (struct chat_msg_submit*) |
|
|
|
|
u_malloc(sizeof(struct chat_msg_submit) + textData.size()); |
|
|
|
|
if (!req) { GUI_ERROR("send: image request allocation failed"); m_inputBar->submissionResult(request, false); return; } |
|
|
|
|
memset(req, 0, sizeof(*req)); |
|
|
|
|
if (reply.size() == 32) memcpy(req->reply_to, reply.constData(), 32); |
|
|
|
|
req->request_id = request; |
|
|
|
|
req->inst = gui_bridge_get_inst(); |
|
|
|
|
snprintf(req->channel_id, sizeof(req->channel_id), "%s", |
|
|
|
|
target.toUtf8().constData()); |
|
|
|
|
@ -646,6 +676,8 @@ void MessageList::loadChannel(const QString& channelId) {
|
|
|
|
|
QWidget* prevFocus = QApplication::focusWidget(); |
|
|
|
|
|
|
|
|
|
bool sameChannel = (m_currentChannelId == channelId); |
|
|
|
|
if (!sameChannel) m_historyAnchor.clear(); |
|
|
|
|
m_view->setHistoricalWindow(!m_historyAnchor.isEmpty()); |
|
|
|
|
|
|
|
|
|
/* Save state of current channel before switching */ |
|
|
|
|
if (!m_currentChannelId.isEmpty()) { |
|
|
|
|
@ -718,7 +750,7 @@ void MessageList::loadChannel(const QString& channelId) {
|
|
|
|
|
|
|
|
|
|
m_localNicks = m_db->getLocalNicknames(channelId); |
|
|
|
|
|
|
|
|
|
auto msgs = m_db->getMessages(channelId, 200); |
|
|
|
|
auto msgs = m_db->getMessages(channelId, 200, m_historyAnchor); |
|
|
|
|
|
|
|
|
|
for (const auto& m : msgs) { |
|
|
|
|
AccountRow acc = m_db->getAccount(m.authorNodeId); |
|
|
|
|
@ -748,6 +780,7 @@ void MessageList::loadChannel(const QString& channelId) {
|
|
|
|
|
m_db ? m_db->dbPath() + "/media" : QString(), channelId, m.localAttrs); |
|
|
|
|
m_model->appendRow(item); |
|
|
|
|
setupFeedback(item, m, channelId); |
|
|
|
|
updateReply(item, m.replyTo); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (sameChannel) { |
|
|
|
|
@ -789,6 +822,8 @@ void MessageList::refresh() {
|
|
|
|
|
|
|
|
|
|
if (!m_db || !m_db->isOpen()) return; |
|
|
|
|
|
|
|
|
|
refreshReplies(); |
|
|
|
|
if (!m_historyAnchor.isEmpty()) return; |
|
|
|
|
QSet<QString> present; |
|
|
|
|
for (int row = 0; row < m_model->rowCount(); row++) present.insert(m_model->item(row)->data(MsgMessageKeyRole).toString()); |
|
|
|
|
auto newMsgs = m_db->getMessages(m_currentChannelId, 200); |
|
|
|
|
@ -823,6 +858,7 @@ void MessageList::refresh() {
|
|
|
|
|
m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId, m.localAttrs); |
|
|
|
|
m_model->appendRow(item); |
|
|
|
|
setupFeedback(item, m, m_currentChannelId); |
|
|
|
|
updateReply(item, m.replyTo); |
|
|
|
|
while (m_model->rowCount() > 200) |
|
|
|
|
m_model->removeRow(0); |
|
|
|
|
} |
|
|
|
|
@ -1047,6 +1083,71 @@ void MessageList::prepareVideoThumbnail(const QString& path) {
|
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* Цитаты разрешаются независимо от окна истории; поздняя синхронизация обновляет уже видимые ответы. */ |
|
|
|
|
void MessageList::updateReply(QStandardItem* item, const QByteArray& original) { |
|
|
|
|
item->setData(QString::fromLatin1(original.toHex()), MsgReplyToRole); |
|
|
|
|
item->setData(!original.isEmpty(), MsgHasQuoteRole); |
|
|
|
|
if (original.isEmpty() || !m_db) return; |
|
|
|
|
const auto message = m_db->getMessageByKey(m_currentChannelId, original); |
|
|
|
|
const QString author = message.messageId.isEmpty() ? QCoreApplication::translate("ChatGui", "Reply") : authorName(message.authorNodeId); |
|
|
|
|
const QString preview = message.messageId.isEmpty() |
|
|
|
|
? QCoreApplication::translate("ChatGui", "Original message is not available yet") : DbManager::messagePreview(message); |
|
|
|
|
if (item->data(MsgQuoteAuthorRole).toString() == author && item->data(MsgQuoteTextRole).toString() == preview) return; |
|
|
|
|
item->setData(author, MsgQuoteAuthorRole); item->setData(preview, MsgQuoteTextRole); |
|
|
|
|
GUI_DEBUG("reply: preview ch=%s original=%s resolved=%d", qPrintable(m_currentChannelId), original.toHex().constData(), |
|
|
|
|
!message.messageId.isEmpty()); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
void MessageList::refreshReplies() { |
|
|
|
|
for (int row = 0; row < m_model->rowCount(); ++row) { |
|
|
|
|
auto* item = m_model->item(row); |
|
|
|
|
const QByteArray key = QByteArray::fromHex(item->data(MsgReplyToRole).toString().toLatin1()); |
|
|
|
|
if (!key.isEmpty()) updateReply(item, key); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* Перезагружаем только ограниченное окно вокруг цели; обычный refresh сохраняет открытый участок истории. */ |
|
|
|
|
void MessageList::jumpToMessage(const QString& messageId) { |
|
|
|
|
if (!m_db || isDmChannel(m_currentChannelId)) return; |
|
|
|
|
const QByteArray key = QByteArray::fromHex(messageId.toLatin1()); |
|
|
|
|
if (key.size() != 32) { GUI_ERROR("reply: invalid navigation key=%s", qPrintable(messageId)); return; } |
|
|
|
|
bool present = false; |
|
|
|
|
for (int row = 0; row < m_model->rowCount(); ++row) |
|
|
|
|
if (m_model->item(row)->data(MsgMessageKeyRole).toString() == messageId) { present = true; break; } |
|
|
|
|
if (!present) { |
|
|
|
|
if (m_db->getMessageByKey(m_currentChannelId, key).messageId.isEmpty()) { |
|
|
|
|
GUI_WARN("reply: navigation original unavailable ch=%s id=%s", qPrintable(m_currentChannelId), qPrintable(messageId)); |
|
|
|
|
QToolTip::showText(QCursor::pos(), QCoreApplication::translate("ChatGui", "Original message is not available yet"), m_view); |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
m_historyAnchor = key; |
|
|
|
|
loadChannel(m_currentChannelId); |
|
|
|
|
} |
|
|
|
|
GUI_DEBUG("reply: navigate ch=%s id=%s loaded_window=%d", qPrintable(m_currentChannelId), qPrintable(messageId), !present); |
|
|
|
|
const QString channel = m_currentChannelId; |
|
|
|
|
QTimer::singleShot(0, this, [this, channel, messageId]() { |
|
|
|
|
if (channel != m_currentChannelId) return; |
|
|
|
|
for (int row = 0; row < m_model->rowCount(); ++row) { |
|
|
|
|
const QModelIndex index = m_model->index(row, 0); |
|
|
|
|
if (index.data(MsgMessageKeyRole).toString() != messageId) continue; |
|
|
|
|
m_view->scrollTo(index, QAbstractItemView::PositionAtCenter); |
|
|
|
|
m_model->setData(index, true, MsgHighlightRole); |
|
|
|
|
QTimer::singleShot(1500, this, [this, index = QPersistentModelIndex(index)]() { |
|
|
|
|
if (index.isValid()) m_model->setData(index, false, MsgHighlightRole); |
|
|
|
|
}); |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
GUI_WARN("reply: navigation target disappeared ch=%s id=%s", qPrintable(channel), qPrintable(messageId)); |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
void MessageList::onMessageSubmitted(const uint8_t* data, int len) { |
|
|
|
|
if (!data || len != 9 || data[8] > 1) { GUI_ERROR("reply: invalid submission result bytes=%d", len); return; } |
|
|
|
|
quint64 request = 0; |
|
|
|
|
for (unsigned i = 0; i < 8; ++i) request |= quint64(data[i]) << (8 * i); |
|
|
|
|
m_inputBar->submissionResult(request, data[8] != 0); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
void MessageList::onMessageContextMenu(const QPoint& pos) { |
|
|
|
|
QModelIndex idx = m_view->indexAt(pos); |
|
|
|
|
if (!idx.isValid()) return; |
|
|
|
|
@ -1064,6 +1165,13 @@ void MessageList::onMessageContextMenu(const QPoint& pos) {
|
|
|
|
|
QMenu menu(m_view); |
|
|
|
|
QString messageId = idx.data(MsgMessageKeyRole).toString(); |
|
|
|
|
if (!isDm && messageId.size() == 64) { |
|
|
|
|
const QString channel = idx.data(MsgChannelIdRole).toString(); |
|
|
|
|
connect(menu.addAction(QCoreApplication::translate("ChatGui", "Reply")), &QAction::triggered, this, [this, channel, messageId]() { |
|
|
|
|
if (channel != m_currentChannelId || !m_db) return; |
|
|
|
|
const auto original = m_db->getMessageByKey(channel, QByteArray::fromHex(messageId.toLatin1())); |
|
|
|
|
if (original.messageId.isEmpty()) { GUI_WARN("reply: selected original disappeared ch=%s", qPrintable(channel)); return; } |
|
|
|
|
m_inputBar->setReply(original.messageId, authorName(original.authorNodeId), DbManager::messagePreview(original)); |
|
|
|
|
}); |
|
|
|
|
if (ct == "poll") connect(menu.addAction(QCoreApplication::translate("ChatGui", "Vote / results")), &QAction::triggered, |
|
|
|
|
this, [this, messageId]() { showPoll(messageId); }); |
|
|
|
|
auto* reactions = menu.addMenu(QCoreApplication::translate("ChatGui", "React")); |
|
|
|
|
|