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.
618 lines
24 KiB
618 lines
24 KiB
#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 "../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 <QDebug> |
|
#include <QFileInfo> |
|
#include <QFile> |
|
#include <vector> |
|
#include <memory> |
|
|
|
static std::vector<std::unique_ptr<LottieIcon>> s_icons; |
|
|
|
extern "C" { |
|
#include "../transport/gui_bridge.h" |
|
#include "chat/chat_core.h" |
|
#include "../../lib/u_async.h" |
|
#include "../../lib/mem.h" |
|
#include "../../lib/debug_config.h" |
|
} |
|
|
|
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) { |
|
QString ct = contentType; |
|
if (ct.isEmpty()) ct = contentTypeFromJson(data); |
|
if (ct.isEmpty()) ct = "text/plain"; |
|
item->setData(ct, MsgContentTypeRole); |
|
|
|
if (ct == "audio/opus") { |
|
QString raw = QString::fromUtf8(data); |
|
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); |
|
} |
|
} |
|
|
|
/* file path from local_attrs */ |
|
QJsonObject la = QJsonDocument::fromJson(localAttrs).object(); |
|
QString fp = la.value("fp").toString(); |
|
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); |
|
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "setVoiceMessageRoles: voice dur=%dms wf=%d exists=%d", |
|
durMs, wf.size(), QFile::exists(fp)); |
|
} |
|
} |
|
if (ct == "application/octet-stream") { |
|
QString fname = QString::fromUtf8(data); |
|
item->setData(fname, MsgFileDisplayNameRole); |
|
} |
|
} |
|
|
|
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); |
|
|
|
m_view->setModel(m_model); |
|
m_view->setItemDelegate(new MessageDelegate(this)); |
|
m_view->setEditTriggers(QAbstractItemView::NoEditTriggers); |
|
m_view->setSelectionMode(QAbstractItemView::NoSelection); |
|
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); |
|
|
|
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; |
|
} |
|
|
|
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)); |
|
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& dt, const QString& basename, |
|
const QString& suffix, const QString& ext, |
|
int numBlocks, qint64 blockSize, |
|
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)); |
|
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_dt, sizeof(req->media_dt), "%s", dt.toUtf8().constData()); |
|
snprintf(req->media_basename, sizeof(req->media_basename), "%s", basename.toUtf8().constData()); |
|
snprintf(req->media_suffix, sizeof(req->media_suffix), "%s", suffix.toUtf8().constData()); |
|
snprintf(req->media_ext, sizeof(req->media_ext), "%s", ext.toUtf8().constData()); |
|
req->media_num_blocks = (uint32_t)numBlocks; |
|
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& dt, const QString& basename, |
|
const QString& suffix, const QString& ext, |
|
int numBlocks, qint64 blockSize, |
|
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)); |
|
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_dt, sizeof(req->media_dt), "%s", dt.toUtf8().constData()); |
|
snprintf(req->media_basename, sizeof(req->media_basename), "%s", basename.toUtf8().constData()); |
|
snprintf(req->media_suffix, sizeof(req->media_suffix), "%s", suffix.toUtf8().constData()); |
|
snprintf(req->media_ext, sizeof(req->media_ext), "%s", ext.toUtf8().constData()); |
|
req->media_num_blocks = (uint32_t)numBlocks; |
|
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(); |
|
} 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(); |
|
if (!m_db || !m_db->isOpen()) return; |
|
|
|
m_localNicks = m_db->getLocalNicknames(channelId); |
|
|
|
auto msgs = m_db->getMessages(channelId, 100); |
|
|
|
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() || !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() > 100) |
|
m_model->removeRow(0); |
|
} |
|
|
|
if (wasAtBottom) m_view->scrollToBottom(); |
|
m_readDebounce->start(); |
|
} |
|
|
|
void MessageList::addMessage(const QString& channelId, quint64 authorNodeId, |
|
const QByteArray& content, qint64 timestamp) { |
|
if (channelId != m_currentChannelId) return; |
|
|
|
AccountRow acc = m_db ? m_db->getAccount(authorNodeId) : AccountRow(); |
|
QString author = m_db ? authorName(authorNodeId) : QString("node_%1").arg(authorNodeId); |
|
QString letter = acc.avatarLetter.isEmpty() ? author.mid(0, 1) : acc.avatarLetter; |
|
QColor color(acc.avatarColor); |
|
auto avatar = makeAvatar(color, letter); |
|
auto time = QDateTime::fromMSecsSinceEpoch(timestamp).toString("HH:mm"); |
|
|
|
/* dedup */ |
|
for (int r = 0; r < m_model->rowCount(); r++) { |
|
auto* it = m_model->item(r); |
|
if (it->data(MsgTimeRole).toString() == time |
|
&& it->data(MsgAuthorRole).toString() == author) return; |
|
} |
|
|
|
auto *item = new QStandardItem(); |
|
setMsg(item, author, QString::fromUtf8(content), time, avatar, false, |
|
false, QString(), QString(), QVariantList{}); |
|
bool isSelf = m_db && (authorNodeId == m_db->myNodeId()); |
|
item->setData(QVariant::fromValue(authorNodeId), MsgAuthorNodeIdRole); |
|
item->setData(isSelf ? 1 : 0, MsgIsOutgoingRole); |
|
setVoiceMessageRoles(item, content, QString(), |
|
m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId, QByteArray()); |
|
m_model->appendRow(item); |
|
|
|
if (m_model->rowCount() > 50) |
|
m_model->removeRow(0); |
|
|
|
m_view->scrollToBottom(); |
|
} |
|
|
|
void MessageList::saveChannelState() { |
|
if (m_currentChannelId.isEmpty() || m_model->rowCount() == 0) return; |
|
|
|
int vpHeight = m_view->viewport()->height(); |
|
QModelIndex topIdx = m_view->indexAt(QPoint(0, 0)); |
|
QScrollBar* sb = m_view->verticalScrollBar(); |
|
bool atBottom = sb && (sb->value() >= sb->maximum()); |
|
|
|
qint64 lastPosMsgId = 0; |
|
if (!atBottom && topIdx.isValid()) |
|
lastPosMsgId = topIdx.data(MsgIdRole).toLongLong(); |
|
|
|
qint64 lastReadMsgId = m_lastReadMsgId; |
|
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MessageList::saveChannelState ch=%s read=%lld pos=%lld", |
|
qPrintable(m_currentChannelId), (long long)lastReadMsgId, (long long)lastPosMsgId); |
|
if (lastReadMsgId == 0 && lastPosMsgId == 0) return; |
|
|
|
QByteArray cid = m_currentChannelId.toUtf8(); |
|
auto saveKey = [&cid](const char* suffix, qint64 val) { |
|
QByteArray key = "ch_" + cid + suffix; |
|
QByteArray valStr = QByteArray::number(val); |
|
chat_core_save_ui_state(key.constData(), valStr.constData()); |
|
}; |
|
|
|
if (lastReadMsgId > 0) saveKey("_last_read", lastReadMsgId); |
|
if (lastPosMsgId > 0) saveKey("_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 lastVisRow=%d/%d", |
|
qPrintable(m_currentChannelId), (long long)m_lastReadMsgId, |
|
(long long)newReadMsgId, (long long)lastPosMsgId, |
|
lastFullyVisibleRow, m_model->rowCount()); |
|
|
|
QByteArray cid = m_currentChannelId.toUtf8(); |
|
|
|
if (newReadMsgId > m_lastReadMsgId) { |
|
m_lastReadMsgId = newReadMsgId; |
|
QByteArray key = "ch_" + cid + "_last_read"; |
|
QByteArray valStr = QByteArray::number(m_lastReadMsgId); |
|
chat_core_save_ui_state(key.constData(), valStr.constData()); |
|
emit readPositionChanged(m_currentChannelId, m_lastReadMsgId); |
|
} |
|
|
|
if (lastPosMsgId > 0) { |
|
QByteArray posKey = "ch_" + cid + "_last_pos"; |
|
QByteArray posVal = QByteArray::number(lastPosMsgId); |
|
chat_core_save_ui_state(posKey.constData(), posVal.constData()); |
|
} |
|
} |
|
|
|
void MessageList::cleanupAnimations() { |
|
size_t n = s_icons.size(); |
|
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "MessageList: cleanupAnimations clearing %zu icons", n); |
|
s_icons.clear(); |
|
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "MessageList: cleanupAnimations done"); |
|
}
|
|
|