595 lines
30 KiB
C++
595 lines
30 KiB
C++
#include <QCoreApplication>
|
|
#include "accountlist.h"
|
|
#include "addressprofiletext.h"
|
|
#include "accountdelegate.h"
|
|
#include "memberlistmodel.h"
|
|
#include "avatars/avatargallery.h"
|
|
#include "memberpropsdialog.h"
|
|
#include "renamedialog.h"
|
|
#include "../db/db_manager.h"
|
|
#include "../transport/gui_bridge.h"
|
|
#include <QVBoxLayout>
|
|
#include <QSplitter>
|
|
#include <QLabel>
|
|
#include <QDateTime>
|
|
#include <QFont>
|
|
#include <QSet>
|
|
#include <QMessageBox>
|
|
#include <QShowEvent>
|
|
#include <QHideEvent>
|
|
#include <QHostAddress>
|
|
#include <QScopeGuard>
|
|
#include <QMenu>
|
|
#include <algorithm>
|
|
#include <cstring>
|
|
#include "../../../lib/platform_compat.h"
|
|
|
|
extern "C" {
|
|
#include "../lib/socket_compat.h"
|
|
#include "../lib/debug_config.h"
|
|
#include "chat/chat_core.h"
|
|
#include "routing_layer/topo_node.h"
|
|
#include "member_files/protocol.h"
|
|
#include "../../lib/mem.h"
|
|
}
|
|
|
|
static QList<QPointer<AccountList>> g_detailTargets; // только GUI; каждый ответ коррелируется своей панелью
|
|
static quint64 g_nextDetailRequest = 0; // только GUI-поток, не сбрасывается при пересоздании панели
|
|
|
|
static void memberDetailCb(const uint8_t* data, int len) {
|
|
const auto targets = g_detailTargets;
|
|
for (const auto& target : targets) if (target) target->onMemberDetailData(data, len);
|
|
}
|
|
|
|
QString AccountList::addrStr(const QByteArray& addr, int family) {
|
|
if (!family) return "?";
|
|
const int required = family == 4 ? 4 : family == 6 ? 16 : 0;
|
|
if (!required || addr.size() < required) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member detail address invalid family=%d bytes=%lld required=%d",
|
|
family, (long long)addr.size(), required); return "?";
|
|
}
|
|
if (family == 4) {
|
|
const auto* a = reinterpret_cast<const uint8_t*>(addr.constData());
|
|
return QString("%1.%2.%3.%4").arg(a[0]).arg(a[1]).arg(a[2]).arg(a[3]);
|
|
}
|
|
Q_IPV6ADDR ip{}; memcpy(ip.c, addr.constData(), sizeof(ip.c));
|
|
return '[' + QHostAddress(ip).toString() + ']';
|
|
}
|
|
|
|
QString AccountList::relativeTimeStr(qint64 timestamp) {
|
|
if (timestamp <= 0) return "";
|
|
qint64 diff = QDateTime::currentSecsSinceEpoch() - timestamp;
|
|
if (diff < 0) diff = 0;
|
|
if (diff < 60) return QCoreApplication::translate("ChatGui", "%1s").arg(diff);
|
|
if (diff < 3600) return QCoreApplication::translate("ChatGui", "%1m").arg(diff / 60);
|
|
if (diff < 86400) return QCoreApplication::translate("ChatGui", "%1h").arg(diff / 3600);
|
|
return QCoreApplication::translate("ChatGui", "%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_header(nullptr)
|
|
, m_detailPanel(nullptr)
|
|
{
|
|
auto *layout = new QVBoxLayout(this);
|
|
layout->setContentsMargins(0, 0, 0, 0);
|
|
layout->setSpacing(0);
|
|
|
|
m_header = new QLabel(QCoreApplication::translate("ChatGui", "Members"), this);
|
|
m_header->setAlignment(Qt::AlignCenter);
|
|
m_header->setStyleSheet("font-weight: bold; padding: 8px; background: palette(window);");
|
|
layout->addWidget(m_header);
|
|
|
|
m_listView->setModel(m_model);
|
|
auto* delegate = new AccountDelegate(m_listView);
|
|
m_listView->setItemDelegate(delegate);
|
|
connect(delegate, &AccountDelegate::radioMuteRequested, this, [this](const QModelIndex& index) {
|
|
if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), m_model->nodeIdAt(index.row())) < 0)
|
|
QMessageBox::warning(this, QCoreApplication::translate("ChatGui", "Error"),
|
|
QCoreApplication::translate("ChatGui", "Cannot change radio listening setting"));
|
|
});
|
|
connect(delegate, &AccountDelegate::avatarRequested, this, [this](const QModelIndex& index) {
|
|
showAvatarGallery(m_channelId, m_model->nodeIdAt(index.row()));
|
|
});
|
|
connect(delegate, &AccountDelegate::radioCallRequested, this, [this](const QModelIndex& index) {
|
|
const quint64 peer = m_model->nodeIdAt(index.row());
|
|
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(index.row())) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "member radio call ignored: peer=%016llx online=%d",
|
|
(unsigned long long)peer, m_model->isOnlineAt(index.row())); return;
|
|
}
|
|
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "member radio call clicked ch=%s peer=%016llx",
|
|
qPrintable(m_channelId), (unsigned long long)peer);
|
|
emit radioCallRequested(m_channelId, peer);
|
|
});
|
|
connect(delegate, &AccountDelegate::callRequested, this, [this](const QModelIndex& index) {
|
|
const quint64 peer = m_model->nodeIdAt(index.row());
|
|
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(index.row())) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_CALL, "member call ignored: peer=%016llx online=%d",
|
|
(unsigned long long)peer, m_model->isOnlineAt(index.row())); return;
|
|
}
|
|
DEBUG_INFO(DEBUG_CATEGORY_CALL, "member call clicked ch=%s peer=%016llx",
|
|
qPrintable(m_channelId), (unsigned long long)peer);
|
|
emit callRequested(m_channelId, peer, index.data(Qt::DisplayRole).toString());
|
|
});
|
|
m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
|
m_listView->setSelectionMode(QAbstractItemView::SingleSelection);
|
|
m_listView->setMouseTracking(true);
|
|
m_listView->setIconSize(QSize(MemberListModel::kAvatarSize, MemberListModel::kAvatarSize));
|
|
m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
m_listView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
|
|
m_listView->setContextMenuPolicy(Qt::CustomContextMenu);
|
|
m_detailPanel = new QWidget(this);
|
|
auto* detailLayout = new QVBoxLayout(m_detailPanel);
|
|
detailLayout->setContentsMargins(0, 0, 0, 0);
|
|
detailLayout->setSpacing(0);
|
|
|
|
m_detailHeader = new QLabel(QCoreApplication::translate("ChatGui", "Select a member"), m_detailPanel);
|
|
m_detailHeader->setObjectName("memberDetailHeader");
|
|
m_detailHeader->setWordWrap(true);
|
|
m_detailHeader->setStyleSheet("font-weight: bold; padding: 4px 8px; background: palette(window);");
|
|
detailLayout->addWidget(m_detailHeader);
|
|
|
|
m_detailText = new QTextEdit(m_detailPanel);
|
|
m_detailText->setObjectName("memberDetailText");
|
|
m_detailText->setReadOnly(true);
|
|
m_detailText->setFont(QFont("monospace", 9));
|
|
m_detailText->setStyleSheet("QTextEdit { border: none; padding: 4px; background: palette(base); }");
|
|
detailLayout->addWidget(m_detailText, 1);
|
|
|
|
auto *memberSplitter = new QSplitter(Qt::Vertical, this);
|
|
memberSplitter->addWidget(m_listView);
|
|
memberSplitter->addWidget(m_detailPanel);
|
|
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_detailClock.start();
|
|
m_detailTimer = new QTimer(this);
|
|
m_detailTimer->setInterval(1000);
|
|
connect(m_detailTimer, &QTimer::timeout, this, &AccountList::onDetailTimerTick);
|
|
connect(m_model, &MemberListModel::snapshotChanged, this, &AccountList::onSnapshotChanged);
|
|
|
|
if (g_detailTargets.isEmpty()) {
|
|
gui_bridge_set_radio_mute_result_cb([](const uint8_t* data, int len) {
|
|
if (!data || len != 17) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio: invalid GUI mute result bytes=%d", len); return; }
|
|
if (data[16]) return;
|
|
const auto targets = g_detailTargets;
|
|
for (auto it = targets.crbegin(); it != targets.crend(); ++it) {
|
|
if (!*it || !(*it)->isVisible() || (*it)->m_channelId.toULongLong() != mf_get64(data)) continue;
|
|
QMessageBox::warning(*it, QCoreApplication::translate("ChatGui", "Error"),
|
|
QCoreApplication::translate("ChatGui", "Cannot change radio listening setting"));
|
|
return;
|
|
}
|
|
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio mute failed with no visible member panel group=%016llx node=%016llx",
|
|
(unsigned long long)mf_get64(data), (unsigned long long)mf_get64(data + 8));
|
|
});
|
|
gui_bridge_set_member_detail_cb(memberDetailCb);
|
|
}
|
|
g_detailTargets.append(this);
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_CONNECTION, "member detail panel registered count=%lld", (long long)g_detailTargets.size());
|
|
|
|
}
|
|
|
|
AccountList::~AccountList() {
|
|
clearMemberDetail();
|
|
g_detailTargets.erase(std::remove_if(g_detailTargets.begin(), g_detailTargets.end(), [this](const auto& target) {
|
|
return !target || target.data() == this;
|
|
}), g_detailTargets.end());
|
|
if (g_detailTargets.isEmpty()) { gui_bridge_set_radio_mute_result_cb(nullptr); gui_bridge_set_member_detail_cb(nullptr); }
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_CONNECTION, "member detail panel removed remaining=%lld", (long long)g_detailTargets.size());
|
|
}
|
|
|
|
void AccountList::setChannel(const QString& channelId) {
|
|
const QByteArray channel = channelId.toUtf8();
|
|
if (channel.size() >= int(sizeof(chat_member_list_req::ch_id)) || channel.contains('\0')) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member panel channel rejected bytes=%lld embedded_nul=%d",
|
|
(long long)channel.size(), channel.contains('\0')); return;
|
|
}
|
|
if (m_channelId != channelId) clearMemberDetail();
|
|
m_channelId = channelId;
|
|
|
|
bool isOwner = false;
|
|
if (m_db && m_db->isOpen()) {
|
|
const auto chs = m_db->getChannels();
|
|
for (const auto& ch : chs) {
|
|
if (ch.channelId == channelId) { isOwner = (ch.ed25519_privkey.size() == 32); break; }
|
|
}
|
|
}
|
|
|
|
m_model->setChannel(channelId, isOwner);
|
|
|
|
m_header->setText(isOwner ? QCoreApplication::translate("ChatGui", "Members 🔑") : QCoreApplication::translate("ChatGui", "Members"));
|
|
m_header->setToolTip(isOwner ? QCoreApplication::translate("ChatGui", "Channel owner (has private key)") : QString());
|
|
}
|
|
|
|
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::clearMemberDetail() {
|
|
if (m_detailNodeId || m_detailRequest) DEBUG_DEBUG(DEBUG_CATEGORY_CONNECTION,
|
|
"member detail cleared channel=%s node=%016llx request=%llu", qPrintable(m_channelId),
|
|
(unsigned long long)m_detailNodeId, (unsigned long long)m_detailRequest);
|
|
m_detailTimer->stop(); m_detailNodeId = 0; m_detailRequest = 0; m_detailDirty = false;
|
|
m_detailHops.clear(); m_snapLinks.clear(); m_snapSocks.clear(); m_detailAddrs.clear();
|
|
m_detailLastSeen = 0; m_detailCreated = 0;
|
|
m_detailHeader->setText(QCoreApplication::translate("ChatGui", "Select a member")); m_detailText->clear();
|
|
}
|
|
|
|
void AccountList::showEvent(QShowEvent* event) {
|
|
QWidget::showEvent(event);
|
|
if (m_detailNodeId) { m_detailTimer->start(); requestMemberDetail(); }
|
|
}
|
|
|
|
void AccountList::hideEvent(QHideEvent* event) {
|
|
m_detailTimer->stop(); m_detailRequest = 0; m_detailHops.clear();
|
|
QWidget::hideEvent(event);
|
|
}
|
|
|
|
void AccountList::onSnapshotChanged(quint64 nodeId) {
|
|
if (!m_detailNodeId) return;
|
|
if (nodeId && nodeId != m_detailNodeId) {
|
|
const auto changed = m_model->member(nodeId);
|
|
if (changed && changed->is_self) updateDetailDisplay();
|
|
return;
|
|
}
|
|
if (!m_model->member(m_detailNodeId)) { clearMemberDetail(); return; }
|
|
m_detailDirty = true; m_detailHops.clear();
|
|
for (int row = 0; row < m_model->rowCount(); row++) if (m_model->nodeIdAt(row) == m_detailNodeId) {
|
|
m_listView->setCurrentIndex(m_model->index(row)); break;
|
|
}
|
|
updateDetailDisplay();
|
|
requestMemberDetail();
|
|
}
|
|
|
|
void AccountList::showMemberDetail(quint64 nodeId) {
|
|
if (!m_model->member(nodeId)) return;
|
|
clearMemberDetail(); m_detailNodeId = nodeId;
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_CONNECTION, "member detail selected channel=%s node=%016llx", qPrintable(m_channelId),
|
|
(unsigned long long)nodeId);
|
|
readDetailMetadata(); updateDetailDisplay();
|
|
if (isVisible()) { m_detailTimer->start(); requestMemberDetail(); }
|
|
}
|
|
|
|
/* Только статические сведения БД; имя и live-состояние принадлежат модели участников. */
|
|
void AccountList::readDetailMetadata() {
|
|
m_detailAddrs.clear(); m_detailLastSeen = 0; m_detailCreated = 0;
|
|
if (!m_db || !m_db->m_db || !m_detailNodeId) return;
|
|
quint64 nodeId = m_detailNodeId;
|
|
sqlite3* d = m_db->m_db;
|
|
sqlite3_stmt* st = nullptr;
|
|
if (sqlite3_prepare_v2(d, "SELECT last_seen_at,created_at FROM nodes WHERE node_id=?", -1, &st, nullptr) == SQLITE_OK) {
|
|
int rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)nodeId);
|
|
if (rc == SQLITE_OK) rc = sqlite3_step(st);
|
|
if (rc == SQLITE_ROW) { m_detailLastSeen = sqlite3_column_int64(st, 0); m_detailCreated = sqlite3_column_int64(st, 1); }
|
|
else if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member detail dates read: %s", sqlite3_errmsg(d));
|
|
} else DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member detail dates prepare: %s", sqlite3_errmsg(d));
|
|
sqlite3_finalize(st);
|
|
|
|
sqlite3_stmt* na = nullptr;
|
|
if (sqlite3_prepare_v2(d,
|
|
"SELECT family, protocol, address, port, socket_id, addr_type, nat_mapping, nat_inbound, nat_source"
|
|
" FROM node_addresses WHERE node_id=?", -1, &na, nullptr) != SQLITE_OK) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member detail addresses prepare: %s", sqlite3_errmsg(d)); return;
|
|
}
|
|
if (na) {
|
|
int rc = sqlite3_bind_int64(na, 1, (sqlite3_int64)nodeId);
|
|
if (rc == SQLITE_OK) rc = sqlite3_step(na);
|
|
for (; rc == SQLITE_ROW; rc = sqlite3_step(na)) {
|
|
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 || !((a.family == 4 && sz >= 4) || (a.family == 6 && sz >= 16))) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_CONNECTION, "member detail invalid stored address node=%016llx family=%d bytes=%d",
|
|
(unsigned long long)nodeId, a.family, sz); continue;
|
|
}
|
|
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);
|
|
a.addr_type = sqlite3_column_int(na, 5);
|
|
a.nat_mapping = sqlite3_column_int(na, 6);
|
|
a.nat_inbound = sqlite3_column_int(na, 7);
|
|
a.nat_source = sqlite3_column_int(na, 8);
|
|
if (!a.addr.isEmpty()) m_detailAddrs.append(a);
|
|
}
|
|
if (rc != SQLITE_DONE) DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member detail addresses read: %s", sqlite3_errmsg(d));
|
|
sqlite3_finalize(na);
|
|
}
|
|
|
|
std::sort(m_detailAddrs.begin(), m_detailAddrs.end(),
|
|
[](const DetailAddress& a, const DetailAddress& b) {
|
|
if (a.socket_id != b.socket_id) return a.socket_id < b.socket_id;
|
|
if (a.addr_type != b.addr_type) return a.addr_type < b.addr_type;
|
|
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;
|
|
});
|
|
|
|
}
|
|
|
|
void AccountList::requestMemberDetail() {
|
|
if (!m_detailNodeId || !isVisible()) return;
|
|
if (!gui_bridge_is_uasync_ready()) {
|
|
if (m_detailRequest) DEBUG_DEBUG(DEBUG_CATEGORY_CONNECTION, "member detail request abandoned: core unavailable request=%llu",
|
|
(unsigned long long)m_detailRequest);
|
|
m_detailRequest = 0; return;
|
|
}
|
|
const qint64 now = m_detailClock.elapsed();
|
|
if (m_detailRequest && now - m_detailRequestedAt < 5000) return;
|
|
if (m_detailRequest) DEBUG_WARN(DEBUG_CATEGORY_CONNECTION, "member detail timeout channel=%s node=%016llx request=%llu",
|
|
qPrintable(m_channelId), (unsigned long long)m_detailNodeId, (unsigned long long)m_detailRequest);
|
|
auto* req = (chat_member_detail_req*)u_calloc(1, sizeof(chat_member_detail_req));
|
|
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member detail request allocation failed"); m_detailRequest = 0; return; }
|
|
req->inst = gui_bridge_get_inst(); req->group_id = m_channelId.toULongLong(); req->node_id = m_detailNodeId;
|
|
if (++g_nextDetailRequest == 0) ++g_nextDetailRequest;
|
|
req->request_id = g_nextDetailRequest;
|
|
m_detailRequest = req->request_id; m_detailRequestedAt = now; m_detailDirty = false;
|
|
DEBUG_TRACE(DEBUG_CATEGORY_CONNECTION, "member detail requested channel=%s node=%016llx request=%llu", qPrintable(m_channelId),
|
|
(unsigned long long)m_detailNodeId, (unsigned long long)m_detailRequest);
|
|
if (gui_bridge_post_uasync_owned_fn(chat_core_collect_member_detail_trampoline, req) < 0) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_CONNECTION, "member detail request rejected channel=%s node=%016llx request=%llu",
|
|
qPrintable(m_channelId), (unsigned long long)m_detailNodeId, (unsigned long long)m_detailRequest);
|
|
u_free(req); m_detailRequest = 0;
|
|
}
|
|
}
|
|
|
|
void AccountList::onMemberDetailData(const uint8_t* data, int len) {
|
|
chat_member_detail_view view{};
|
|
if (len < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member detail negative packet length=%d", len); return; }
|
|
if (chat_core_parse_member_detail(data, (size_t)len, &view) < 0) return;
|
|
if (!m_detailRequest || view.request_id != m_detailRequest || view.group_id != m_channelId.toULongLong() ||
|
|
view.node_id != m_detailNodeId) {
|
|
DEBUG_TRACE(DEBUG_CATEGORY_CONNECTION, "member detail ignored stale request=%llu group=%016llx node=%016llx",
|
|
(unsigned long long)view.request_id, (unsigned long long)view.group_id, (unsigned long long)view.node_id); return;
|
|
}
|
|
m_detailRequest = 0;
|
|
if (view.result != CHAT_MEMBER_DETAIL_OK) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_CONNECTION, "member detail failed channel=%s node=%016llx request=%llu result=%u",
|
|
qPrintable(m_channelId), (unsigned long long)m_detailNodeId, (unsigned long long)view.request_id, view.result);
|
|
m_detailHops.clear(); m_snapLinks.clear(); m_snapSocks.clear(); updateDetailDisplay(); return;
|
|
}
|
|
if (m_detailDirty) { requestMemberDetail(); return; }
|
|
QVector<MemberLinkSnap> links;
|
|
for (int i = 0; i < view.link_count; i++) {
|
|
const uint8_t* p = view.links + i * CHAT_MEMBER_DETAIL_LINK_SIZE;
|
|
MemberLinkSnap lk{};
|
|
lk.direction = p[0]; lk.linkState = p[1]; lk.linkStatus = p[2]; lk.hasIp = p[3] != 0;
|
|
lk.localFamily = p[4]; lk.localAddr = QByteArray((const char*)p + 5, 16);
|
|
lk.localPort = (uint16_t)p[21] | ((uint16_t)p[22] << 8);
|
|
lk.family = p[23]; lk.addr = QByteArray((const char*)p + 24, 16);
|
|
lk.port = (uint16_t)p[40] | ((uint16_t)p[41] << 8);
|
|
memcpy(lk.sockName, p + 42, 20); lk.protocol = QString::fromUtf8((const char*)p + 62); links.append(lk);
|
|
}
|
|
QVector<MemberSockSnap> socks;
|
|
for (int i = 0; i < view.sock_count; i++) {
|
|
const uint8_t* p = view.socks + i * CHAT_MEMBER_DETAIL_SOCK_SIZE;
|
|
MemberSockSnap s{};
|
|
uint32_t id = 0; for (int j = 0; j < 4; j++) id |= (uint32_t)p[j] << (8 * j);
|
|
s.sockId = id; s.port = (uint16_t)p[4] | ((uint16_t)p[5] << 8);
|
|
s.linksUp = (uint16_t)p[6] | ((uint16_t)p[7] << 8); s.linksDown = (uint16_t)p[8] | ((uint16_t)p[9] << 8);
|
|
s.ifname = QString::fromUtf8((const char*)p + 10); socks.append(s);
|
|
}
|
|
QVector<quint64> hops;
|
|
for (int i = 0; i < view.hop_count; i++) hops.append(mf_get64(view.hops + i * 8));
|
|
m_snapLinks = links; m_snapSocks = socks; m_detailHops = hops;
|
|
readDetailMetadata();
|
|
/* 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; }
|
|
}
|
|
}
|
|
updateDetailDisplay();
|
|
}
|
|
|
|
void AccountList::updateDetailDisplay() {
|
|
const auto member = m_model->member(m_detailNodeId);
|
|
if (!member) return;
|
|
const bool isSelf = member->is_self != 0;
|
|
QString nodeHex = QString("0x%1").arg(m_detailNodeId, 16, 16, QChar('0'));
|
|
|
|
/* ── Header ── */
|
|
QString dotColor = member->online ? "#4CAF50" : "#9E9E9E";
|
|
QStringList hdr;
|
|
hdr.append(QString("<span style='color:%1;'>●</span> %2 %3")
|
|
.arg(dotColor, QString::fromUtf8(member->name).toHtmlEscaped(), nodeHex));
|
|
if (m_detailLastSeen > 0)
|
|
hdr.append(QCoreApplication::translate("ChatGui", "seen %1").arg(relativeTimeStr(m_detailLastSeen)));
|
|
if (m_detailCreated > 0)
|
|
hdr.append(QCoreApplication::translate("ChatGui", "created %1").arg(relativeTimeStr(m_detailCreated)));
|
|
m_detailHeader->setText(hdr.join(" "));
|
|
|
|
/* ── Body ── */
|
|
QStringList lines;
|
|
|
|
auto protoName = [](int protocol) -> QString {
|
|
if (protocol & 0x04) return (protocol & 0x02) ? "TCP+REALITY" : "REALITY";
|
|
switch (protocol) { case 1: return "UDP"; case 2: return "TCP"; default: return "?"; }
|
|
};
|
|
|
|
if (!m_detailAddrs.isEmpty()) {
|
|
lines.append(QCoreApplication::translate("ChatGui", "── Addresses ──"));
|
|
for (const auto& a : m_detailAddrs) {
|
|
lines.append(QString(" [sock%1] %2 %3 %4:%5 %6")
|
|
.arg(a.socket_id)
|
|
.arg(protoName(a.protocol))
|
|
.arg(a.ifname.toHtmlEscaped())
|
|
.arg(addrStr(a.addr, a.family))
|
|
.arg(a.port)
|
|
.arg(addressProfileText(a.nat_mapping, a.nat_inbound, a.nat_source)));
|
|
}
|
|
}
|
|
|
|
if (isSelf) {
|
|
lines.append(QCoreApplication::translate("ChatGui", "── Listen ──"));
|
|
for (const auto& s : m_snapSocks) {
|
|
const DetailAddress* a = nullptr;
|
|
for (const auto& da : m_detailAddrs) {
|
|
if (da.socket_id == s.sockId) { a = &da; break; }
|
|
}
|
|
QString proto = a ? protoName(a->protocol) : "?";
|
|
QString ip = a ? addrStr(a->addr, a->family) : "?";
|
|
lines.append(QString(" [%1/%2] %3:%4 %5")
|
|
.arg(s.linksUp).arg(s.linksDown)
|
|
.arg(ip).arg(s.port)
|
|
.arg(proto));
|
|
}
|
|
} else {
|
|
if (!m_snapLinks.isEmpty()) {
|
|
lines.append(QCoreApplication::translate("ChatGui", "── Connections ──"));
|
|
for (const auto& lk : m_snapLinks) {
|
|
bool up = (lk.linkState == 3 && lk.linkStatus == 1);
|
|
QString c = up ? "#4CAF50" : "#E53935";
|
|
QString arrow = lk.direction == 2 ? "◀" : lk.direction == 1 ? "▶" : "";
|
|
QString description = lk.protocol.toHtmlEscaped();
|
|
if (lk.hasIp) description += QString(" %1:%2 %3 %4:%5")
|
|
.arg(addrStr(lk.localAddr, lk.localFamily)).arg(lk.localPort)
|
|
.arg(arrow).arg(addrStr(lk.addr, lk.family)).arg(lk.port);
|
|
else if (!arrow.isEmpty()) description += " " + arrow;
|
|
lines.append(QString("<span style='color:%1;'>●</span> %2").arg(c, description));
|
|
if (lk.sockName[0])
|
|
lines.append(QString(" %1").arg(QString::fromUtf8(lk.sockName).toHtmlEscaped()));
|
|
}
|
|
}
|
|
lines.prepend(m_model->routeText(*member, true));
|
|
if (!m_detailHops.isEmpty() && member->online) {
|
|
QStringList hops;
|
|
for (auto it = m_detailHops.crbegin(); it != m_detailHops.crend(); ++it) {
|
|
QString label = QString("%1").arg(*it, 16, 16, QChar('0'));
|
|
const auto hop = m_model->member(*it);
|
|
if (hop) label += ' ' + QString::fromUtf8(hop->name).toHtmlEscaped();
|
|
hops.append(label);
|
|
}
|
|
lines.append(QString("BGP: ") + hops.join(" → "));
|
|
}
|
|
}
|
|
|
|
if (lines.isEmpty())
|
|
m_detailText->clear();
|
|
else
|
|
m_detailText->setHtml(QString("<pre style='margin:0;'>") + lines.join("\n") + "</pre>");
|
|
}
|
|
|
|
void AccountList::onDetailTimerTick() {
|
|
if (m_detailNodeId != 0) requestMemberDetail();
|
|
}
|
|
|
|
/* Диалог может пережить закрытие панели/БД: QObject-владение и QPointer исключают повторное удаление. */
|
|
void AccountList::editMember(quint64 nodeId, bool properties) {
|
|
const auto member = m_model->member(nodeId);
|
|
if (!member || !m_db || !m_db->isOpen()) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "member edit unavailable channel=%s node=%016llx database=%d",
|
|
qPrintable(m_channelId), (unsigned long long)nodeId, m_db && m_db->isOpen());
|
|
return;
|
|
}
|
|
const QString channel = m_channelId, name = QString::fromUtf8(member->name);
|
|
QPointer<AccountList> alive(this);
|
|
QPointer<QDialog> dialog;
|
|
if (properties) dialog = new MemberPropsDialog(m_db, channel, nodeId, name, member->flags, this);
|
|
else dialog = new RenameDialog(m_db, channel, nodeId, name, this);
|
|
const auto release = qScopeGuard([dialog] { if (dialog) delete dialog.data(); });
|
|
connect(m_db.data(), &QObject::destroyed, dialog.data(), &QDialog::reject);
|
|
const int result = dialog->exec();
|
|
if (!alive || result != QDialog::Accepted || channel != m_channelId) return;
|
|
m_model->refresh();
|
|
emit memberRenamed();
|
|
}
|
|
|
|
void AccountList::onMemberDoubleClicked(const QModelIndex& index) {
|
|
if (index.isValid()) editMember(m_model->nodeIdAt(index.row()), true);
|
|
}
|
|
|
|
/* Вложенный event loop меню допускает смену канала, снимка и уничтожение панели. */
|
|
void AccountList::onMemberContextMenu(const QPoint& pos) {
|
|
const QModelIndex index = m_listView->indexAt(pos);
|
|
if (!index.isValid()) return;
|
|
const quint64 nodeId = m_model->nodeIdAt(index.row());
|
|
const auto member = m_model->member(nodeId);
|
|
if (!nodeId || !member) return;
|
|
const QString channel = m_channelId;
|
|
const bool self = member->is_self != 0, editable = m_db && m_db->isOpen();
|
|
|
|
QPointer<AccountList> alive(this);
|
|
QMenu menu;
|
|
connect(this, &QObject::destroyed, &menu, &QMenu::close);
|
|
QAction* pmAction = menu.addAction(QCoreApplication::translate("ChatGui", "Send message"));
|
|
pmAction->setEnabled(!self);
|
|
QAction* inviteAction = menu.addAction(QCoreApplication::translate("ChatGui", "Invite to group"));
|
|
inviteAction->setEnabled(!self);
|
|
QAction* callAction = menu.addAction(QCoreApplication::translate("ChatGui", "Call peer"));
|
|
callAction->setEnabled(!self && member->online);
|
|
QAction* radioCallAction = menu.addAction(QCoreApplication::translate("ChatGui", "Call by radio"));
|
|
radioCallAction->setEnabled(!self && member->online);
|
|
QAction* connectAction = menu.addAction(QCoreApplication::translate("ChatGui", "Connect"));
|
|
connectAction->setEnabled(!self);
|
|
QAction* renameAction = menu.addAction(QCoreApplication::translate("ChatGui", "Rename"));
|
|
renameAction->setEnabled(!self && editable);
|
|
QAction* editAction = menu.addAction(QCoreApplication::translate("ChatGui", "Edit"));
|
|
editAction->setEnabled(editable);
|
|
QAction* filesAction = menu.addAction(QCoreApplication::translate("ChatGui", "Member files"));
|
|
QAction* chosen = menu.exec(m_listView->viewport()->mapToGlobal(pos));
|
|
if (!alive || !chosen) return;
|
|
const auto current = m_model->member(nodeId);
|
|
if (channel != m_channelId || !current) {
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_CONNECTION, "member menu cancelled after snapshot/channel change node=%016llx",
|
|
(unsigned long long)nodeId);
|
|
return;
|
|
}
|
|
if (current->is_self && chosen != editAction && chosen != filesAction) {
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_CONNECTION, "member menu action cancelled: target became self node=%016llx", (unsigned long long)nodeId);
|
|
return;
|
|
}
|
|
if ((chosen == callAction || chosen == radioCallAction) && !current->online) {
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_CONNECTION, "member menu call cancelled: peer went offline node=%016llx", (unsigned long long)nodeId);
|
|
return;
|
|
}
|
|
const QString name = QString::fromUtf8(current->name);
|
|
if (chosen == inviteAction) { emit inviteRequested(channel, nodeId, name); return; }
|
|
if (chosen == radioCallAction) { emit radioCallRequested(channel, nodeId); return; }
|
|
if (chosen == filesAction) { emit filesRequested(channel, nodeId); return; }
|
|
if (chosen == pmAction) { emit pmRequested(channel, nodeId, name); return; }
|
|
if (chosen == callAction) { emit callRequested(channel, nodeId, name); return; }
|
|
if (chosen == connectAction) {
|
|
auto* request = static_cast<chat_connect_node_req*>(u_calloc(1, sizeof(chat_connect_node_req)));
|
|
if (!request) { DEBUG_ERROR(DEBUG_CATEGORY_CONNECTION, "member connect request allocation failed"); return; }
|
|
request->inst = gui_bridge_get_inst(); request->node_id = nodeId;
|
|
strncpy(request->ch_id, channel.toUtf8().constData(), sizeof(request->ch_id) - 1);
|
|
if (gui_bridge_post_uasync_owned_fn(chat_core_connect_node_trampoline, request) < 0) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_CONNECTION, "member connect request rejected channel=%s node=%016llx",
|
|
qPrintable(channel), (unsigned long long)nodeId);
|
|
u_free(request);
|
|
}
|
|
return;
|
|
}
|
|
if (chosen == renameAction || chosen == editAction) editMember(nodeId, chosen == editAction);
|
|
}
|
|
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);
|
|
}
|