- Add lib/libopus/ (opus v1.6.1 embedded source: CELT 18 .c, SILK 77 .c, SILK float 28 .c, OPUS core 14 .c) - Add lib/opus_codec.h/.c (wrapper: encoder, decoder, raw PCM int16) - Add tests/test_opus_codec.c (7 tests: silence, sine, compression, PLC, null, defaults, multi-frame) - chatgui: AudioRecorder (miniaudio capture, 48kHz mono) - chatgui: VoiceEncoder (PCM→Opus file with custom container) - chatgui: VoicePlayer (Opus→PCM playback via SoundManager) - chatgui: PTT button in InputBar, voice message send/receive in MessageList - chatgui: AudioDeviceSettingsPage (device selection, persisted in ui_state) - chatgui: MessageDelegate voice message rendering (play button + duration) - Update autotools: configure.ac, Makefile.am, src/Makefile.am, tests/Makefile.am - Update CMake: chatgui CMakeLists.txt, libutun CMakeLists.txt (link libopus_internal.a)
507 lines
19 KiB
C++
507 lines
19 KiB
C++
#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 <QFileInfo>
|
|
#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, 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)
|
|
{
|
|
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 &filePath, int durationMs) {
|
|
if (m_currentChannelId.isEmpty()) return;
|
|
|
|
QFileInfo fi(filePath);
|
|
QString fileName = fi.fileName();
|
|
QString jsonData = QString("{\"n\":%1,\"ch\":\"%2\",\"ct\":\"audio/opus\",\"d\":\"%3\",\"dur\":%4}")
|
|
.arg(m_db ? m_db->myNodeId() : 0)
|
|
.arg(m_currentChannelId)
|
|
.arg(fileName)
|
|
.arg(durationMs / 1000.0, 0, 'f', 1);
|
|
|
|
QByteArray textData = jsonData.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");
|
|
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;
|
|
|
|
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);
|
|
item->setData(m.contentType, MsgContentTypeRole);
|
|
if (m.contentType == "audio/opus") {
|
|
/* JSON data contains {"dur":X.X} — parse duration */
|
|
QByteArray d = m.data;
|
|
int durIdx = d.indexOf("\"dur\":");
|
|
if (durIdx >= 0) {
|
|
int durEnd = d.indexOf(',', durIdx);
|
|
if (durEnd < 0) durEnd = d.indexOf('}', durIdx);
|
|
QByteArray durStr = d.mid(durIdx + 6, durEnd - durIdx - 6);
|
|
item->setData(QString::fromUtf8(durStr) + "s", MsgVoiceDurationRole);
|
|
}
|
|
}
|
|
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);
|
|
item->setData(m.contentType, MsgContentTypeRole);
|
|
if (m.contentType == "audio/opus") {
|
|
QByteArray d = m.data;
|
|
int durIdx = d.indexOf("\"dur\":");
|
|
if (durIdx >= 0) {
|
|
int durEnd = d.indexOf(',', durIdx);
|
|
if (durEnd < 0) durEnd = d.indexOf('}', durIdx);
|
|
QByteArray durStr = d.mid(durIdx + 6, durEnd - durIdx - 6);
|
|
item->setData(QString::fromUtf8(durStr) + "s", MsgVoiceDurationRole);
|
|
}
|
|
}
|
|
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);
|
|
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");
|
|
}
|