#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 "voiceplayback.h" #include "../db/db_manager.h" #include "../../lib/debug_config.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include static std::vector> 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" struct save_ui_state_arg { char data[256]; }; void chat_core_save_ui_state_trampoline(void* arg); } 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 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 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 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); } } 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(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& mediaSrc, const QString& mediaDest, 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_src, sizeof(req->media_src), "%s", mediaSrc.toUtf8().constData()); snprintf(req->media_dest, sizeof(req->media_dest), "%s", mediaDest.toUtf8().constData()); req->media_copy = 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::sendFileAttachment, this, [this](const QString& mediaSrc, const QString& mediaDest, 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_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(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, 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); 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() > 200) 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; 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_DEBUG, "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; 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(); 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 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; 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_INFO(DEBUG_CATEGORY_GENERAL, "MessageList: cleanupAnimations clearing %zu icons", n); s_icons.clear(); DEBUG_INFO(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) { QString ct; QString filePath; int row = -1; for (int r = 0; r < m_model->rowCount(); r++) { QStandardItem* item = m_model->item(r); if (!item) continue; if (item->data(MsgIdRole).toLongLong() == msgId) { row = r; item->setData(2, MsgFileDownloadStateRole); item->setData(0, MsgMediaBlocksDoneRole); ct = item->data(MsgContentTypeRole).toString(); filePath = item->data(MsgVoiceFileRole).toString(); break; } } if (row >= 0 && chId == m_currentChannelId) { 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) { wasPending = it->data(MsgVoiceDownloadPendingRole).toBool(); if (wasPending) it->setData(false, MsgVoiceDownloadPendingRole); break; } } refresh(); /* reload to get updated local_attrs with file path */ /* auto-play only if user clicked play to initiate download */ if (ct == "audio/opus" && wasPending) { QTimer::singleShot(500, this, [this, msgId]() { for (int r = 0; r < m_model->rowCount(); r++) { QStandardItem* it = m_model->item(r); if (it && it->data(MsgIdRole).toLongLong() == msgId) { QString fp = it->data(MsgVoiceFileRole).toString(); if (!fp.isEmpty() && QFile::exists(fp)) VoicePlayer::playOpusFile(fp); break; } } }); } } }