#include "accountlist.h" #include "accountdelegate.h" #include "memberlistmodel.h" #include "memberpropsdialog.h" #include "renamedialog.h" #include "../db/db_manager.h" #include "../transport/gui_bridge.h" #include #include #include #include #include #include #include #include #include "../../../lib/platform_compat.h" extern "C" { #include "../lib/socket_compat.h" #include "../lib/debug_config.h" #include "chat/chat_core.h" #include "../../lib/mem.h" } static AccountList* g_detailTarget = nullptr; static void memberDetailCb(const uint8_t* data, int len) { if (!g_detailTarget) return; if (len < 13) return; uint64_t nodeId; memcpy(&nodeId, data, 8); g_detailTarget->onMemberDetailData(nodeId, data, len); } QString AccountList::addrStr(const void* addr, int family) { if (family == 4) { const uint8_t* a = (const uint8_t*)addr; return QString("%1.%2.%3.%4").arg(a[0]).arg(a[1]).arg(a[2]).arg(a[3]); } if (family == 6) { const uint16_t* a = (const uint16_t*)addr; return QString("%1:%2:%3:%4:%5:%6:%7:%8") .arg(ntohs(a[0]), 0, 16).arg(ntohs(a[1]), 0, 16) .arg(ntohs(a[2]), 0, 16).arg(ntohs(a[3]), 0, 16) .arg(ntohs(a[4]), 0, 16).arg(ntohs(a[5]), 0, 16) .arg(ntohs(a[6]), 0, 16).arg(ntohs(a[7]), 0, 16); } return "?"; } QString AccountList::relativeTimeStr(qint64 timestamp) { if (timestamp <= 0) return ""; qint64 diff = QDateTime::currentSecsSinceEpoch() - timestamp; if (diff < 0) diff = 0; if (diff < 60) return QString("%1s").arg(diff); if (diff < 3600) return QString("%1m").arg(diff / 60); if (diff < 86400) return QString("%1h").arg(diff / 3600); return QString("%1d").arg(diff / 86400); } AccountList::AccountList(DbManager* db, QWidget *parent) : QWidget(parent) , m_listView(new QListView(this)) , m_model(new MemberListModel(this)) , m_db(db) , m_detailText(nullptr) { auto *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); auto *header = new QLabel("Members", this); header->setAlignment(Qt::AlignCenter); header->setStyleSheet("font-weight: bold; padding: 8px; background: palette(window);"); layout->addWidget(header); m_listView->setModel(m_model); m_listView->setItemDelegate(new AccountDelegate(m_listView)); m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers); m_listView->setSelectionMode(QAbstractItemView::SingleSelection); m_listView->setIconSize(QSize(24, 24)); m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_listView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); m_listView->setContextMenuPolicy(Qt::CustomContextMenu); m_detailText = new QTextEdit(this); m_detailText->setReadOnly(true); m_detailText->setFont(QFont("monospace", 10)); m_detailText->setStyleSheet( "QTextEdit { border-top: 1px solid palette(mid); border-bottom: none;" " border-left: none; border-right: none; padding: 6px; background: palette(base); }"); m_detailText->setPlaceholderText("Select a member"); auto *memberSplitter = new QSplitter(Qt::Vertical, this); memberSplitter->addWidget(m_listView); memberSplitter->addWidget(m_detailText); memberSplitter->setStretchFactor(0, 3); memberSplitter->setStretchFactor(1, 2); memberSplitter->setSizes({360, 240}); layout->addWidget(memberSplitter, 1); connect(m_listView->selectionModel(), &QItemSelectionModel::selectionChanged, m_listView->viewport(), qOverload<>(&QWidget::update)); connect(m_listView, &QListView::clicked, this, &AccountList::onMemberClicked); connect(m_listView, &QListView::doubleClicked, this, &AccountList::onMemberDoubleClicked); connect(m_listView, &QWidget::customContextMenuRequested, this, &AccountList::onMemberContextMenu); m_detailTimer = new QTimer(this); m_detailTimer->setInterval(5000); connect(m_detailTimer, &QTimer::timeout, this, &AccountList::onDetailTimerTick); m_linkTimer = new QTimer(this); m_linkTimer->setInterval(1000); connect(m_linkTimer, &QTimer::timeout, this, [this]{ if (m_detailNodeId != 0) requestMemberDetail(); }); g_detailTarget = this; gui_bridge_set_member_detail_cb(memberDetailCb); } AccountList::~AccountList() { if (g_detailTarget == this) g_detailTarget = nullptr; } void AccountList::setChannel(const QString& channelId) { m_channelId = channelId; m_model->setChannel(channelId); m_detailTimer->stop(); m_linkTimer->stop(); m_detailNodeId = 0; m_detailText->clear(); } void AccountList::refresh() { m_model->refresh(); } void AccountList::onMemberClicked(const QModelIndex& index) { if (!index.isValid()) return; quint64 nodeId = m_model->nodeIdAt(index.row()); if (nodeId) showMemberDetail(nodeId); } void AccountList::showMemberDetail(quint64 nodeId) { if (!m_db || !m_db->m_db) return; m_detailTimer->stop(); m_linkTimer->stop(); m_detailNodeId = nodeId; m_detailName.clear(); m_detailOnline = false; m_detailLastSeen = 0; m_detailCreated = 0; m_detailAddrs.clear(); m_snapConnPresent = false; m_snapConnUp = false; m_snapConnInit = false; m_snapClient = false; m_snapServer = false; m_snapOwnTcpActive = 0; m_snapLinks.clear(); m_snapSocks.clear(); sqlite3* d = m_db->m_db; sqlite3_stmt* st = nullptr; if (sqlite3_prepare_v2(d, "SELECT name, online, last_seen_at, created_at FROM nodes WHERE node_id=?", -1, &st, nullptr) == SQLITE_OK) { sqlite3_bind_int64(st, 1, (sqlite3_int64)nodeId); if (sqlite3_step(st) == SQLITE_ROW) { m_detailName = DbManager::colText(st, 0); m_detailOnline = sqlite3_column_int(st, 1) != 0; m_detailLastSeen = sqlite3_column_int64(st, 2); m_detailCreated = sqlite3_column_int64(st, 3); } sqlite3_finalize(st); } if (m_detailName.isEmpty()) m_detailName = QString("0x%1").arg(nodeId, 8, 16, QChar('0')).right(4).toUpper(); /* prefer local nickname */ QString tbl = m_db->peersTableName(m_channelId); sqlite3_stmt* nickSt = nullptr; QByteArray nickSql = QString( "SELECT local_nick FROM \"%1\" WHERE node_id=?" ).arg(tbl).toUtf8(); if (sqlite3_prepare_v2(d, nickSql.constData(), -1, &nickSt, nullptr) == SQLITE_OK) { sqlite3_bind_int64(nickSt, 1, (sqlite3_int64)nodeId); if (sqlite3_step(nickSt) == SQLITE_ROW) { QString n = DbManager::colText(nickSt, 0); if (!n.isEmpty()) m_detailName = n; } sqlite3_finalize(nickSt); } sqlite3_stmt* na = nullptr; sqlite3_prepare_v2(d, "SELECT family, protocol, address, port, socket_id FROM node_addresses WHERE node_id=?", -1, &na, nullptr); if (na) { sqlite3_bind_int64(na, 1, (sqlite3_int64)nodeId); while (sqlite3_step(na) == SQLITE_ROW) { DetailAddress a; a.family = sqlite3_column_int(na, 0); a.protocol = sqlite3_column_int(na, 1); const void* adr = sqlite3_column_blob(na, 2); int sz = sqlite3_column_bytes(na, 2); if (adr && sz > 0) a.addr = QByteArray((const char*)adr, sz); a.port = sqlite3_column_int(na, 3); a.socket_id = sqlite3_column_int(na, 4); if (!a.addr.isEmpty()) m_detailAddrs.append(a); } sqlite3_finalize(na); } std::sort(m_detailAddrs.begin(), m_detailAddrs.end(), [](const DetailAddress& a, const DetailAddress& b) { if (a.ifname != b.ifname) return a.ifname < b.ifname; if (a.addr != b.addr) return a.addr < b.addr; if (a.protocol != b.protocol) return a.protocol < b.protocol; return a.port < b.port; }); m_detailTimer->start(); m_linkTimer->start(); updateDetailDisplay(); requestMemberDetail(); } void AccountList::requestMemberDetail() { if (m_detailNodeId == 0) return; void* arg = u_malloc(sizeof(uint64_t)); if (!arg) { DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "AccountList: req alloc failed"); return; } uint64_t nid = m_detailNodeId; memcpy(arg, &nid, sizeof(nid)); gui_bridge_post_uasync_fn(chat_core_collect_member_detail_trampoline, arg); } void AccountList::onMemberDetailData(uint64_t nodeId, const uint8_t* data, int len) { if (nodeId != m_detailNodeId) return; /* stale response from earlier selection */ if (len < 13) return; const uint8_t* p = data; /* skip node_id(8) */ p += 8; uint8_t flags = *p++; m_snapConnPresent = (flags & 1) != 0; m_snapConnUp = (flags & 2) != 0; m_snapConnInit = (flags & 4) != 0; m_snapClient = (flags & 8) != 0; m_snapServer = (flags & 16) != 0; uint16_t ownTcpActive; memcpy(&ownTcpActive, p, 2); p += 2; m_snapOwnTcpActive = ownTcpActive; uint8_t linkCount = *p++; m_snapLinks.clear(); for (int i = 0; i < linkCount; i++) { if (p + MEMBER_DETAIL_LINK_SIZE > data + len) break; MemberLinkSnap lk; lk.isTcp = p[0] != 0; lk.linkState = p[1]; lk.linkStatus = p[2]; lk.tcpReady = p[3] != 0; lk.family = p[4]; int addrLen = p[5]; lk.addr = QByteArray((const char*)p + 6, qMin(addrLen, 16)); memcpy(&lk.port, p + 22, 2); p += MEMBER_DETAIL_LINK_SIZE; m_snapLinks.append(lk); } if (p >= data + len) { updateDetailDisplay(); return; } uint8_t sockCount = *p++; m_snapSocks.clear(); for (int i = 0; i < sockCount; i++) { if (p + MEMBER_DETAIL_SOCK_SIZE > data + len) break; MemberSockSnap s; memcpy(&s.sockId, p, 4); memcpy(&s.port, p + 4, 2); memcpy(&s.linkCount, p + 6, 4); s.ifname = QString::fromUtf8((const char*)p + 10).trimmed(); p += MEMBER_DETAIL_SOCK_SIZE; m_snapSocks.append(s); } /* resolve interface names from snapshot sockets (by socket_id) */ for (auto& a : m_detailAddrs) { for (const auto& s : m_snapSocks) { if (s.sockId == a.socket_id) { a.ifname = s.ifname; break; } } } std::sort(m_detailAddrs.begin(), m_detailAddrs.end(), [](const DetailAddress& a, const DetailAddress& b) { if (a.ifname != b.ifname) return a.ifname < b.ifname; if (a.addr != b.addr) return a.addr < b.addr; if (a.protocol != b.protocol) return a.protocol < b.protocol; return a.port < b.port; }); updateDetailDisplay(); } void AccountList::updateDetailDisplay() { QStringList lines; lines.append(QString("ID:0x%1").arg(m_detailNodeId, 16, 16, QChar('0'))); QString dotColor = m_detailOnline ? "#4CAF50" : "#9E9E9E"; QString scLabel; if (m_snapClient) scLabel = " C"; else if (m_snapServer) scLabel = " S"; lines.append(QString("● %2%3") .arg(dotColor, m_detailName.toHtmlEscaped(), scLabel)); if (m_detailLastSeen > 0) lines.append(QString("⌛ %1").arg(relativeTimeStr(m_detailLastSeen))); if (m_detailCreated > 0) lines.append(QString("📅 %1").arg(relativeTimeStr(m_detailCreated))); bool isSelf = m_db ? (m_detailNodeId == m_db->myNodeId()) : false; for (const auto& a : m_detailAddrs) { QString proto; switch (a.protocol) { case 1: proto = "UDP"; break; case 2: proto = "TCP"; break; default: proto = "UDP/TCP"; break; } QString linkColor = "#9E9E9E"; if (m_snapConnPresent) { for (const auto& lk : m_snapLinks) { if (lk.family == a.family && lk.port == a.port && lk.addr == a.addr) { linkColor = (lk.linkState == 3 && lk.linkStatus == 1) ? "#4CAF50" : "#E53935"; break; } } if (linkColor == "#9E9E9E" && a.protocol == 2) { for (const auto& lk : m_snapLinks) { if (lk.isTcp && lk.tcpReady) { linkColor = "#4CAF50"; break; } } } } QString prefix; if (isSelf) { int linkCount = 0; if (a.protocol == 1) { for (const auto& s : m_snapSocks) { if (s.port == a.port) { linkCount = s.linkCount; break; } } } else if (a.protocol == 2) { linkCount = m_snapOwnTcpActive; } if (linkCount > 0) prefix = QString("[%1] ").arg(linkCount); } if (!a.ifname.isEmpty()) { lines.append(QString("%1● %3 %4") .arg(prefix, linkColor, proto, a.ifname)); lines.append(QString("  %1:%2") .arg(addrStr(a.addr.constData(), a.family)).arg(a.port)); } else { lines.append(QString("%1● %3:%4:%5") .arg(prefix, linkColor, proto, addrStr(a.addr.constData(), a.family)).arg(a.port)); } } /* Show snapshot links that are not in DB (non-self) */ if (m_snapConnPresent && m_snapLinks.size() > 0) { for (int li = 0; li < m_snapLinks.size(); li++) { const auto& lk = m_snapLinks[li]; bool matched = false; for (const auto& a : m_detailAddrs) { if (lk.family == a.family && lk.port == a.port && lk.addr == a.addr) { matched = true; break; } } if (matched) continue; QString proto = lk.isTcp ? "TCP" : "UDP"; QString lc = (lk.linkState == 3 && lk.linkStatus == 1) ? "#4CAF50" : "#E53935"; lines.append(QString("● %2:%3:%4 [link]") .arg(lc, proto, addrStr(lk.addr.constData(), lk.family)).arg(lk.port)); } } m_detailText->setHtml(lines.join("
")); } void AccountList::onDetailTimerTick() { if (m_detailNodeId != 0) requestMemberDetail(); } void AccountList::onMemberDoubleClicked(const QModelIndex& index) { if (!index.isValid()) return; quint64 nodeId = m_model->nodeIdAt(index.row()); if (!nodeId) return; QString name = m_model->data(index, Qt::DisplayRole).toString(); uint8_t flags = (uint8_t)(m_model->data(index, Qt::UserRole + 3).toInt() & 0xFF); MemberPropsDialog dlg(m_db, m_channelId, nodeId, name, flags, this); if (dlg.exec() == QDialog::Accepted) { m_model->refresh(); emit memberRenamed(); } } void AccountList::onMemberContextMenu(const QPoint& pos) { QModelIndex index = m_listView->indexAt(pos); if (!index.isValid()) return; quint64 nodeId = m_model->nodeIdAt(index.row()); if (!nodeId) return; QString name = m_model->data(index, Qt::DisplayRole).toString(); QMenu menu(m_listView); QAction* renameAction = menu.addAction("Rename"); if (nodeId == m_db->myNodeId()) renameAction->setEnabled(false); QAction* editAction = menu.addAction("Edit"); QAction* chosen = menu.exec(m_listView->viewport()->mapToGlobal(pos)); if (chosen == renameAction) { RenameDialog dlg(m_db, m_channelId, nodeId, name, this); if (dlg.exec() == QDialog::Accepted) { m_model->refresh(); emit memberRenamed(); } return; } if (chosen != editAction) return; uint8_t flags = (uint8_t)(m_model->data(index, Qt::UserRole + 3).toInt() & 0xFF); MemberPropsDialog dlg(m_db, m_channelId, nodeId, name, flags, this); if (dlg.exec() == QDialog::Accepted) { m_model->refresh(); emit memberRenamed(); } } void AccountList::onMemberList(const QString& chId, int count, const uint8_t* data, int dataLen) { m_model->onMemberList(chId, count, data, dataLen); } void AccountList::onMemberUpdated(const QString& chId, const uint8_t* data) { m_model->onMemberUpdated(chId, data); } void AccountList::onMemberRemoved(const QString& chId, uint64_t nodeId) { m_model->onMemberRemoved(chId, nodeId); }