#include #include "channellist.h" #include "channeldelegate.h" #include "avatars/groupavatarmanager.h" #include "../db/db_manager.h" #include #include #include #include #include #include #include #include #include #include #include #include #include extern "C" { #include "../../lib/debug_config.h" #include "../../lib/mem.h" #include "../transport/gui_bridge.h" #include "dm/dm_core.h" } /* Секундное среднее CPU: фиксированный градиент, заполнение по проценту. */ class CpuLoadIndicator : public QWidget { public: explicit CpuLoadIndicator(QWidget* parent) : QWidget(parent) { setObjectName(QStringLiteral("cpuLoadIndicator")); setFixedSize(60, 24); setAccessibleName(QStringLiteral("CPU")); } void setValue(int percent) { percent = qBound(0, percent, 100); if (m_percent == percent) return; m_percent = percent; update(); } protected: void paintEvent(QPaintEvent*) override { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); QPainterPath path; path.addRoundedRect(QRectF(rect()), 5, 5); painter.setClipPath(path); painter.fillRect(rect(), QColor("#E4E9E6")); QLinearGradient gradient(0, 0, width(), 0); gradient.setColorAt(0, QColor("#66BB6A")); gradient.setColorAt(0.5, QColor("#FFCA28")); gradient.setColorAt(1, QColor("#EF5350")); painter.fillRect(QRectF(0, 0, width() * m_percent / 100.0, height()), gradient); painter.setPen(QColor("#172019")); QFont f = font(); f.setBold(true); painter.setFont(f); painter.drawText(rect(), Qt::AlignCenter, QString::number(m_percent) + '%'); } private: int m_percent = 0; }; /* Пустое место и заголовки секций не сбрасывают выбор канала. */ class ChannelListView : public QListView { public: using QListView::QListView; protected: void mousePressEvent(QMouseEvent* e) override { QModelIndex idx = indexAt(e->pos()); if (!idx.isValid()) return; if (idx.data(ChannelIsSectionRole).toBool()) return; QListView::mousePressEvent(e); } QItemSelectionModel::SelectionFlags selectionCommand(const QModelIndex& idx, const QEvent* e) const override { if (idx.isValid() && idx.data(ChannelIsSectionRole).toBool()) return QItemSelectionModel::NoUpdate; if (idx.isValid()) return QItemSelectionModel::ClearAndSelect; return QItemSelectionModel::NoUpdate; } void selectionChanged(const QItemSelection& sel, const QItemSelection& desel) override { QListView::selectionChanged(sel, desel); if (selectionModel() && !selectionModel()->hasSelection() && currentIndex().isValid() && currentIndex().row() < model()->rowCount()) selectionModel()->select(currentIndex(), QItemSelectionModel::ClearAndSelect); } }; static QIcon makeChannelIcon(const QColor &color, const QString &letter) { QPixmap pix(32, 32); pix.fill(Qt::transparent); QPainter p(&pix); p.setRenderHint(QPainter::Antialiasing); p.setBrush(color); p.setPen(Qt::NoPen); p.drawRoundedRect(0, 0, 32, 32, 8, 8); p.setPen(Qt::white); QFont f = p.font(); f.setBold(true); f.setPointSize(14); p.setFont(f); p.drawText(QRect(0, 0, 32, 32), Qt::AlignCenter, letter); p.end(); return QIcon(pix); } ChannelList::ChannelList(DbManager* db, QWidget *parent) : QWidget(parent) , m_listView(new ChannelListView(this)) , m_model(new QStandardItemModel(this)) , m_db(db) { if (auto* avatars = GroupAvatarManager::instance()) connect(avatars, &GroupAvatarManager::changed, this, [this, avatars](const QString& channel) { const QImage image = avatars->currentImage(channel); if (image.isNull()) return; QPixmap pixmap(64, 64); pixmap.fill(Qt::transparent); QPainter painter(&pixmap); painter.setRenderHint(QPainter::Antialiasing); QPainterPath path; path.addEllipse(QRectF(0, 0, 64, 64)); painter.setClipPath(path); painter.drawImage(QRect(0, 0, 64, 64), image); painter.end(); for (int row = 0; row < m_model->rowCount(); row++) if (m_model->item(row)->data(ChannelChannelIdRole).toString() == channel) m_model->item(row)->setData(QIcon(pixmap), ChannelIconRole); }); m_dmPresenceTimer = new QTimer(this); m_dmPresenceTimer->setSingleShot(true); m_dmPresenceTimer->setInterval(50); connect(m_dmPresenceTimer, &QTimer::timeout, this, [this]() { auto* inst = gui_bridge_get_inst(); if (!inst || !m_db) { clearDmPresence(); return; } for (const auto& dm : m_db->getDmConversations()) { auto* req = (struct dm_messages_req*)u_calloc(1, sizeof(struct dm_messages_req)); if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "PM presence: allocation failed"); return; } req->inst = inst; snprintf(req->conv_id, sizeof(req->conv_id), "%s", dm.convId.toUtf8().constData()); gui_bridge_post_uasync_fn(dm_call_route_trampoline, req); } }); auto *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); auto *header = new QLabel(QCoreApplication::translate("ChatGui", "Channels"), this); header->setAlignment(Qt::AlignCenter); header->setStyleSheet("font-weight: bold; padding: 8px; background: palette(window);"); auto* headerRow = new QHBoxLayout; headerRow->setContentsMargins(0, 0, 8, 0); headerRow->addWidget(header, 1); m_cpuLoad = new CpuLoadIndicator(this); headerRow->addWidget(m_cpuLoad); layout->addLayout(headerRow); m_listView->setModel(m_model); m_listView->setItemDelegate(new ChannelDelegate(this)); m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers); m_listView->setSelectionMode(QAbstractItemView::SingleSelection); m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_listView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); m_listView->setMouseTracking(true); layout->addWidget(m_listView); auto *footer = new QWidget(this); m_footer = footer; m_footer->setObjectName("channelFooter"); auto *fl = new QHBoxLayout(footer); fl->setContentsMargins(6, 4, 6, 4); fl->setSpacing(6); auto *gearBtn = new QPushButton(QString::fromUtf8("\xE2\x9A\x99"), footer); gearBtn->setFixedSize(28, 28); gearBtn->setToolTip(QCoreApplication::translate("ChatGui", "Settings")); gearBtn->setStyleSheet( "QPushButton { border: none; font-size: 18px; background: transparent; color: #666; }" "QPushButton:hover { color: #3390EC; background: rgba(51,144,236,20); border-radius: 14px; }"); fl->addWidget(gearBtn); connect(gearBtn, &QPushButton::clicked, this, [this] { emit settingsRequested(); }); auto *monitorBtn = new QPushButton(QString::fromUtf8("\xF0\x9F\x94\x8D"), footer); /* 🔍 */ monitorBtn->setFixedSize(28, 28); monitorBtn->setToolTip(QCoreApplication::translate("ChatGui", "Connection Monitor")); monitorBtn->setStyleSheet( "QPushButton { border: none; font-size: 16px; background: transparent; color: #666; }" "QPushButton:hover { color: #3390EC; background: rgba(51,144,236,20); border-radius: 14px; }"); fl->addWidget(monitorBtn); connect(monitorBtn, &QPushButton::clicked, this, [this] { emit connMonitorRequested(); }); fl->addStretch(); auto* versionLabel = new QLabel(QStringLiteral("v%1").arg(QCoreApplication::applicationVersion()), footer); versionLabel->setStyleSheet("font-size: 10px; color: palette(mid);"); fl->addWidget(versionLabel); auto *createBtn = new QPushButton(QCoreApplication::translate("ChatGui", "GRP"), footer); createBtn->setFixedSize(36, 28); createBtn->setToolTip(QCoreApplication::translate("ChatGui", "Create group")); createBtn->setStyleSheet( "QPushButton { border: none; border-radius: 6px; font-size: 11px; font-weight: bold;" " color: white; background: #27AE60; }" "QPushButton:hover { background: #219A52; }"); connect(createBtn, &QPushButton::clicked, this, [this] { emit createGroupRequested(); }); fl->addWidget(createBtn); auto *plusBtn = new QPushButton("+", footer); plusBtn->setFixedSize(28, 28); plusBtn->setToolTip(QCoreApplication::translate("ChatGui", "Join")); plusBtn->setStyleSheet( "QPushButton { border: none; border-radius: 14px; font-size: 18px; font-weight: bold;" " color: white; background: #3390EC; }" "QPushButton:hover { background: #2B7EC8; }"); connect(plusBtn, &QPushButton::clicked, this, [this] { emit joinRequested(); }); fl->addWidget(plusBtn); layout->addWidget(footer); auto targets = footer->findChildren(); targets.append(footer); for (auto* target : targets) { target->setAcceptDrops(true); target->installEventFilter(this); } m_dropHint = new QLabel(footer); m_dropHint->setObjectName("ownFilesDropHint"); m_dropHint->setAttribute(Qt::WA_TransparentForMouseEvents); m_dropHint->setAlignment(Qt::AlignCenter); m_dropHint->setStyleSheet("QLabel { border: 2px dashed palette(highlight); background: palette(window); color: palette(text); }"); m_dropHint->hide(); connect(m_listView->selectionModel(), &QItemSelectionModel::currentChanged, this, [this](const QModelIndex ¤t, const QModelIndex &) { if (current.isValid()) { QString cid = current.data(ChannelChannelIdRole).toString(); emit channelSelected(cid); } }); m_listView->setContextMenuPolicy(Qt::CustomContextMenu); connect(m_listView, &QWidget::customContextMenuRequested, this, [this](const QPoint& point) { auto index = m_listView->indexAt(point); QString channel = index.data(ChannelChannelIdRole).toString(); if (!index.isValid() || channel.isEmpty() || channel.startsWith(QStringLiteral("dm:"))) return; QMenu menu(this); auto* files = menu.addAction(QCoreApplication::translate("ChatGui", "Group files")); if (menu.exec(m_listView->viewport()->mapToGlobal(point)) == files) emit filesRequested(channel); }); } bool ChannelList::eventFilter(QObject* target, QEvent* event) { if (event->type() == QEvent::DragLeave) { m_dropHint->hide(); return true; } if (event->type() != QEvent::DragEnter && event->type() != QEvent::DragMove && event->type() != QEvent::Drop) return QWidget::eventFilter(target, event); auto* drop = static_cast(event); QString channel = m_listView->currentIndex().data(ChannelChannelIdRole).toString(); QString reason; if (!channel.toULongLong()) reason = QCoreApplication::translate("ChatGui", "Select a group to upload files"); else if (!m_db || !m_db->myNodeId() || !gui_bridge_is_uasync_ready()) reason = QCoreApplication::translate("ChatGui", "Upload unavailable"); else if (!drop->mimeData()->hasUrls() || drop->mimeData()->urls().isEmpty() || !(drop->possibleActions() & Qt::CopyAction)) reason = QCoreApplication::translate("ChatGui", "Drop local files to copy"); QStringList paths; if (reason.isEmpty()) for (const auto& url : drop->mimeData()->urls()) { if (!url.isLocalFile() || url.toLocalFile().isEmpty()) { reason = QCoreApplication::translate("ChatGui", "Only local files can be uploaded"); break; } paths.append(url.toLocalFile()); } if (event->type() == QEvent::Drop) m_dropHint->hide(); else { m_dropHint->setGeometry(m_footer->rect()); m_dropHint->setText(reason.isEmpty() ? QCoreApplication::translate("ChatGui", "Copy to my files") : reason); m_dropHint->show(); m_dropHint->raise(); } if (!reason.isEmpty()) { if (event->type() != QEvent::DragMove) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file drop: rejected channel=%s reason=%s", qPrintable(channel), qPrintable(reason)); drop->ignore(); // После отклонённого DragEnter Qt не гарантирует DragLeave. m_dropHint->hide(); return true; } drop->setDropAction(Qt::CopyAction); drop->accept(); if (event->type() == QEvent::Drop) { DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file drop: accepted channel=%s files=%d", qPrintable(channel), int(paths.size())); QTimer::singleShot(0, this, [this, channel, paths] { emit ownFilesDropped(channel, paths); }); } return true; } void ChannelList::refreshDmPresence() { if (!m_dmPresenceTimer->isActive()) m_dmPresenceTimer->start(); } void ChannelList::clearDmPresence() { m_dmPresenceTimer->stop(); m_dmOnline.clear(); for (int i = 0; i < m_model->rowCount(); i++) { auto* item = m_model->item(i); if (item->data(ChannelChannelIdRole).toString().startsWith(QStringLiteral("dm:"))) item->setData(false, ChannelDmOnlineRole); } } void ChannelList::onDmCallRoute(const uint8_t* data, int len) { if (!data || len < 17 || len != 17 + data[0]) { DEBUG_WARN(DEBUG_CATEGORY_CALL, "PM presence snapshot: invalid size=%d", len); return; } QString conv = QString::fromUtf8((const char*)data + 1, data[0]); quint64 peer = 0, group = 0; memcpy(&peer, data + 1 + data[0], 8); memcpy(&group, data + 9 + data[0], 8); bool online = peer && group; if (m_dmOnline.value(conv) != online) DEBUG_INFO(DEBUG_CATEGORY_CALL, "PM presence: conv=%s online=%d", conv.toUtf8().constData(), online); m_dmOnline.insert(conv, online); for (int i = 0; i < m_model->rowCount(); i++) { auto* item = m_model->item(i); if (item->data(ChannelChannelIdRole).toString() == QStringLiteral("dm:") + conv) item->setData(online, ChannelDmOnlineRole); } } void ChannelList::setCpuLoad(int percent) { m_cpuLoad->setValue(percent); } void ChannelList::loadChannels() { refreshDmPresence(); QString selectedCid; if (m_listView->currentIndex().isValid()) selectedCid = m_listView->currentIndex().data(ChannelChannelIdRole).toString(); auto* sel = m_listView->selectionModel(); sel->blockSignals(true); m_model->clear(); if (m_db && m_db->isOpen()) { int sortMode = m_db->getUiStateInt("channel_sort", 0); auto channels = m_db->getChannels(sortMode == 1 ? ChannelSortMode::Recent : ChannelSortMode::Alphabetical); static const QColor colors[] = { QColor(74,144,226), QColor(126,211,33), QColor(245,166,35), QColor(208,2,27), QColor(144,19,254), QColor(0,184,148) }; int ci = 0; for (const auto& ch : channels) { auto letter = ch.name.mid(0, 1).toUpper(); if (letter.isEmpty()) letter = "#"; auto *item = new QStandardItem(); item->setData(ch.name, ChannelNameRole); item->setData(ch.channelId, ChannelChannelIdRole); item->setData(m_db->getChannelLastMessage(ch.channelId), ChannelLastMessageRole); item->setData(ch.unreadCount, ChannelUnreadCountRole); item->setData(ch.lastMsgAt, ChannelLastMsgAtRole); item->setData(makeChannelIcon(colors[ci % 6], letter), ChannelIconRole); if (auto* avatars = GroupAvatarManager::instance()) { const QImage image = avatars->image(ch.channelId, ch.avatarVersion, ch.avatarHash); if (!image.isNull()) { QPixmap pixmap(64, 64); pixmap.fill(Qt::transparent); QPainter painter(&pixmap); painter.setRenderHint(QPainter::Antialiasing); QPainterPath clip; clip.addEllipse(QRectF(0, 0, 64, 64)); painter.setClipPath(clip); painter.drawImage(QRect(0, 0, 64, 64), image); painter.end(); item->setData(QIcon(pixmap), ChannelIconRole); } } m_model->appendRow(item); DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "ChannelList::loadChannels row=%d cid='%s' name='%s' unread=%d lastMsgAt=%lld", m_model->rowCount()-1, qPrintable(ch.channelId), qPrintable(ch.name), ch.unreadCount, (long long)ch.lastMsgAt); ci++; } /* ── секция «Личные» (DM-беседы) ── */ auto dms = m_db->getDmConversations(); if (!dms.isEmpty()) { auto *section = new QStandardItem(); section->setData(QCoreApplication::translate("ChatGui", "Direct messages"), ChannelNameRole); section->setData(true, ChannelIsSectionRole); section->setFlags(Qt::NoItemFlags); m_model->appendRow(section); static const QColor dmColor(0x8E, 0x44, 0xAD); for (const auto& dm : dms) { QString name = dm.peerName; if (name.isEmpty()) name = QString("0x%1").arg(dm.peerNodeId, 4, 16, QChar('0')).toUpper(); auto letter = name.mid(0, 1).toUpper(); if (letter.isEmpty()) letter = "@"; auto *item = new QStandardItem(); item->setData(name, ChannelNameRole); item->setData(QStringLiteral("dm:") + dm.convId, ChannelChannelIdRole); item->setData(m_dmOnline.value(dm.convId), ChannelDmOnlineRole); item->setData(QString(), ChannelLastMessageRole); item->setData(0, ChannelUnreadCountRole); item->setData(dm.lastTs, ChannelLastMsgAtRole); item->setData(makeChannelIcon(dmColor, letter), ChannelIconRole); m_model->appendRow(item); DEBUG_DEBUG(DEBUG_CATEGORY_DM, "ChannelList::loadChannels dm row=%d conv='%s' peer='%s'", m_model->rowCount()-1, qPrintable(dm.convId), qPrintable(name)); } } } sel->blockSignals(false); if (!selectedCid.isEmpty()) { selectChannel(selectedCid); } else if (m_model->rowCount() > 0) { QModelIndex idx = m_model->index(0, 0); sel->select(idx, QItemSelectionModel::ClearAndSelect); sel->setCurrentIndex(idx, QItemSelectionModel::NoUpdate); } } void ChannelList::selectChannel(const QString& channelId) { for (int row = 0; row < m_model->rowCount(); row++) { if (m_model->item(row)->data(ChannelChannelIdRole).toString() == channelId) { QModelIndex idx = m_model->index(row, 0); m_listView->selectionModel()->select(idx, QItemSelectionModel::ClearAndSelect); m_listView->selectionModel()->setCurrentIndex(idx, QItemSelectionModel::NoUpdate); return; } } } void ChannelList::setChannelPeersOnline(const QString& channelId, int count) { int found = 0; for (int row = 0; row < m_model->rowCount(); row++) { QStandardItem* item = m_model->item(row); if (item) { QString itemCid = item->data(ChannelChannelIdRole).toString(); int match = (itemCid == channelId) ? 1 : 0; if (match) { item->setData(count, ChannelPeersOnlineRole); item->setData(m_autoConnectActive ? 1 : 0, ChannelAutoConnectActiveRole); found = 1; break; } } } if (!found) DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "ChannelList: channel=%s not found in model", qPrintable(channelId)); } void ChannelList::setAutoConnectActive(bool active) { m_autoConnectActive = active; for (int row = 0; row < m_model->rowCount(); row++) { QStandardItem* item = m_model->item(row); if (item) item->setData(active ? 1 : 0, ChannelAutoConnectActiveRole); } } void ChannelList::setChannelUnreadCount(const QString& channelId, int count) { for (int row = 0; row < m_model->rowCount(); row++) { QStandardItem* item = m_model->item(row); if (item && item->data(ChannelChannelIdRole).toString() == channelId) { int old = item->data(ChannelUnreadCountRole).toInt(); DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "ChannelList::setChannelUnreadCount ch=%s %d->%d", qPrintable(channelId), old, count); item->setData(count, ChannelUnreadCountRole); emit m_model->dataChanged(m_model->index(row, 0), m_model->index(row, 0)); return; } } DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "ChannelList::setChannelUnreadCount ch=%s not found", qPrintable(channelId)); } int ChannelList::getChannelUnreadCount(const QString& channelId) const { for (int row = 0; row < m_model->rowCount(); row++) { QStandardItem* item = m_model->item(row); if (item && item->data(ChannelChannelIdRole).toString() == channelId) return item->data(ChannelUnreadCountRole).toInt(); } return 0; }