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.
435 lines
16 KiB
435 lines
16 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 "../db/db_manager.h" |
|
#include "../../lib/debug_config.h" |
|
#include <QVBoxLayout> |
|
#include <QPainter> |
|
#include <QPixmap> |
|
#include <QDateTime> |
|
#include <QFile> |
|
#include <QScrollBar> |
|
#include <QApplication> |
|
#include <QDebug> |
|
#include <cstdio> |
|
#include <vector> |
|
#include <memory> |
|
|
|
static std::vector<std::unique_ptr<LottieIcon>> s_icons; |
|
|
|
extern "C" { |
|
#include "../transport/gui_bridge.h" |
|
#include "../transport/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 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, QWidget *parent) |
|
: QWidget(parent) |
|
, m_view(new ChatView(this)) |
|
, m_model(new QStandardItemModel(this)) |
|
, m_inputBar(nullptr) |
|
, m_emojiPanel(nullptr) |
|
, m_db(db) |
|
{ |
|
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); |
|
fprintf(stderr, "[DIAG register] path=%s fileOpen=1 rawSize=%d iconPtr=%p iconValid=%d\n", |
|
qPrintable(e.animPath), (int)raw.size(), |
|
(void*)icon.get(), (int)icon->isValid()); |
|
if (icon->isValid()) { |
|
AnimTimer::instance()->registerIcon(e.animPath, icon.get()); |
|
s_icons.push_back(std::move(icon)); |
|
} |
|
} else { |
|
fprintf(stderr, "[DIAG register] path=%s fileOpen=0\n", |
|
qPrintable(e.animPath)); |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
m_emojiPanel = new EmojiPanel(this); |
|
layout->addWidget(m_emojiPanel); |
|
|
|
m_inputBar = new InputBar(m_emojiPanel, 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(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_readDebounce->stop(); |
|
if (!m_db || !m_db->isOpen()) return; |
|
|
|
auto msgs = m_db->getMessages(channelId, 100); |
|
|
|
for (const auto& m : msgs) { |
|
AccountRow acc = m_db->getAccount(m.authorNodeId); |
|
QString author = m_db->getDisplayName(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(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole); |
|
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; |
|
|
|
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 = m_db->getDisplayName(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(m.isOutgoing ? 1 : 0, MsgIsOutgoingRole); |
|
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 ? m_db->getDisplayName(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(isSelf ? 1 : 0, MsgIsOutgoingRole); |
|
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); |
|
} |
|
|
|
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"); |
|
}
|
|
|