#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 #include #include #include #include #include #include #include #include #include #include static std::vector> 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 &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(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"); }