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.
 
 
 
 
 
 

452 lines
21 KiB

#include <QCoreApplication>
#include "channellist.h"
#include "channeldelegate.h"
#include "avatars/groupavatarmanager.h"
#include "../db/db_manager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QPainter>
#include <QPainterPath>
#include <QPixmap>
#include <QMouseEvent>
#include <QMenu>
#include <QStyle>
#include <QDropEvent>
#include <QMimeData>
#include <QUrl>
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<QWidget*>(); 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 &current, 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<QDropEvent*>(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;
}