321 lines
15 KiB
C++
321 lines
15 KiB
C++
#include <QCoreApplication>
|
|
#include "memberlistmodel.h"
|
|
#include "debug_ui.h"
|
|
#include "flagpainter.h"
|
|
#include "avatars/avatarmanager.h"
|
|
#include "../transport/gui_bridge.h"
|
|
#include <QPainter>
|
|
#include <QPainterPath>
|
|
#include <QFont>
|
|
#include <QColor>
|
|
#include <QIcon>
|
|
#include <algorithm>
|
|
#include <utility>
|
|
#include <cstring>
|
|
|
|
extern "C" {
|
|
#include "../../../src/chat/chat_core.h"
|
|
#include "../../../src/chat/chat_member.h"
|
|
#include "../../../src/routing_layer/topo_node.h"
|
|
#include "../../../lib/mem.h"
|
|
}
|
|
|
|
static void applyPhoto(MemberListItem& item, const QString& channel) {
|
|
if ((item.snapshot.flags & CHAT_MEMBER_FLAG_SUPERNODE) || item.snapshot.storage) return;
|
|
auto* manager = AvatarManager::instance(); if (!manager) return;
|
|
const QImage image = manager->image(channel, item.snapshot.node_id); if (image.isNull()) return;
|
|
const int size = MemberListModel::kAvatarSize;
|
|
QPixmap pixmap(size, size); pixmap.fill(Qt::transparent);
|
|
QPainter painter(&pixmap); painter.setRenderHint(QPainter::Antialiasing);
|
|
painter.setRenderHint(QPainter::SmoothPixmapTransform);
|
|
QPainterPath clip; clip.addEllipse(QRectF(0, 0, size, size)); painter.setClipPath(clip);
|
|
painter.drawImage(QRect(0, 0, size, size), image); painter.setClipping(false);
|
|
const QColor statusColor = item.snapshot.online ? QColor("#4CAF50") : QColor("#9E9E9E");
|
|
painter.setPen(QPen(statusColor, 1)); painter.setBrush(statusColor);
|
|
painter.drawEllipse(QRect(size - 9, size - 9, 8, 8)); painter.end(); item.avatar = pixmap;
|
|
}
|
|
|
|
MemberListModel::MemberListModel(QObject* parent)
|
|
: QAbstractListModel(parent)
|
|
, m_rttTimer(new QTimer(this))
|
|
{
|
|
if (auto* manager = AvatarManager::instance()) connect(manager, &AvatarManager::changed, this,
|
|
[this](const QString& channel, quint64 owner) {
|
|
if (channel != m_channelId) return;
|
|
for (int row = 0; row < m_members.size(); ++row) if (m_members[row].snapshot.node_id == owner) {
|
|
applyPhoto(m_members[row], channel); emit dataChanged(index(row), index(row), {Qt::DecorationRole});
|
|
}
|
|
});
|
|
m_rttTimer->setInterval(2000);
|
|
connect(m_rttTimer, &QTimer::timeout, this, &MemberListModel::refreshRtt);
|
|
m_rttTimer->start();
|
|
}
|
|
|
|
int MemberListModel::rowCount(const QModelIndex& parent) const {
|
|
return parent.isValid() ? 0 : int(m_members.size());
|
|
}
|
|
|
|
QVariant MemberListModel::data(const QModelIndex& index, int role) const {
|
|
if (!index.isValid() || index.model() != this || index.column() != 0 || index.row() < 0 || index.row() >= m_members.size())
|
|
return {};
|
|
|
|
const auto& item = m_members.at(index.row());
|
|
if (role == Qt::DisplayRole) return item.name;
|
|
if (role == Qt::DecorationRole) return QVariant::fromValue(QIcon(item.avatar));
|
|
if (role == Qt::UserRole + 1) return routeText(item.snapshot, false);
|
|
if (role == Qt::ToolTipRole)
|
|
return QCoreApplication::translate("ChatGui", item.snapshot.online ? "online" : "offline") + " | 0x" +
|
|
QString("%1").arg(item.snapshot.node_id, 16, 16, QChar('0')) +
|
|
((item.snapshot.flags & CHAT_MEMBER_FLAG_RADIO) ? QCoreApplication::translate("ChatGui", " · Radio enabled") : QString());
|
|
if (role == Qt::UserRole + 2) return item.snapshot.is_self;
|
|
if (role == Qt::UserRole + 3) return item.snapshot.flags;
|
|
if (role == Qt::UserRole + 4) return bool(item.snapshot.storage);
|
|
if (role == RadioSpeakingRole) return item.snapshot.is_self ? m_selfTransmitting : m_radioTalkers.contains(item.snapshot.node_id);
|
|
if (role == OnlineRole) return item.snapshot.online;
|
|
return {};
|
|
}
|
|
|
|
QString MemberListModel::routeText(const chat_member_display& peer, bool detailed) const {
|
|
if (!peer.online) return QCoreApplication::translate("ChatGui", "offline");
|
|
if (peer.is_self) return QStringLiteral("SELF");
|
|
QString route;
|
|
if (peer.conn_up & NCONN_DIRECT) {
|
|
route = QStringLiteral("DIR");
|
|
if (detailed) {
|
|
uint8_t localNat = 255;
|
|
for (const auto& item : m_members) if (item.snapshot.is_self) { localNat = item.snapshot.nat_type; break; }
|
|
auto nat = [](uint8_t type) {
|
|
static const char* names[] = {"NET", "NONE", "EIM", "STRICT", "NAT?", "CONTACT", "EIM?"};
|
|
return QString::fromLatin1(type < sizeof(names) / sizeof(names[0]) ? names[type] : "?");
|
|
};
|
|
route += QStringLiteral("(%1-%2)").arg(nat(localNat), nat(peer.nat_type));
|
|
}
|
|
} else if (peer.conn_up & NCONN_INDIRECT) route = QStringLiteral("IND");
|
|
else route = detailed ? QStringLiteral("BGP(%1 hop)").arg(peer.intermediaries) : QStringLiteral("B%1").arg(peer.intermediaries);
|
|
if (peer.rtt != UINT16_MAX) {
|
|
QString rtt = QString::number(peer.rtt / 10.0, 'f', peer.rtt % 10 ? 1 : 0);
|
|
route += (detailed ? QStringLiteral(" RTT=%1ms") : QStringLiteral(", RTT=%1ms")).arg(rtt);
|
|
}
|
|
return route;
|
|
}
|
|
|
|
void MemberListModel::setChannel(const QString& channelId, bool isOwner) {
|
|
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 channel rejected: bytes=%lld embedded_nul=%d limit=63",
|
|
(long long)channel.size(), channel.contains('\0')); return;
|
|
}
|
|
const bool changed = m_channelId != channelId;
|
|
if (changed || (m_isOwner && !isOwner)) {
|
|
beginResetModel();
|
|
if (changed) {
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: channel %s -> %s cleared talkers=%d self_tx=%d",
|
|
qPrintable(m_channelId), qPrintable(channelId), int(m_radioTalkers.size()), m_selfTransmitting);
|
|
m_channelId = channelId;
|
|
m_radioTalkers.clear(); m_radioActive = false; m_selfTransmitting = false;
|
|
m_members.clear();
|
|
} else {
|
|
m_members.erase(std::remove_if(m_members.begin(), m_members.end(), [](const MemberListItem& item) {
|
|
return item.snapshot.flags & CHAT_MEMBER_FLAG_DELETED;
|
|
}), m_members.end());
|
|
}
|
|
m_isOwner = isOwner;
|
|
endResetModel();
|
|
emit snapshotChanged(0);
|
|
} else m_isOwner = isOwner;
|
|
refresh();
|
|
}
|
|
|
|
void MemberListModel::refresh() {
|
|
if (!m_channelId.isEmpty() && gui_bridge_is_uasync_ready()) {
|
|
struct chat_member_list_req* req = (struct chat_member_list_req*)u_calloc(1, sizeof(*req));
|
|
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member list request allocation failed"); return; }
|
|
req->inst = gui_bridge_get_inst();
|
|
strncpy(req->ch_id, m_channelId.toUtf8().constData(), sizeof(req->ch_id) - 1);
|
|
if (gui_bridge_post_uasync_owned_fn(chat_core_request_member_list_trampoline, req) < 0) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member list request rejected ch=%s", qPrintable(m_channelId)); u_free(req);
|
|
}
|
|
}
|
|
}
|
|
|
|
void MemberListModel::refreshRtt() {
|
|
if (m_channelId.isEmpty() || !gui_bridge_is_uasync_ready()) return;
|
|
struct chat_member_list_req* req = (struct chat_member_list_req*)u_calloc(1, sizeof(*req));
|
|
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member RTT request allocation failed"); return; }
|
|
req->inst = gui_bridge_get_inst();
|
|
strncpy(req->ch_id, m_channelId.toUtf8().constData(), sizeof(req->ch_id) - 1);
|
|
if (gui_bridge_post_uasync_owned_fn(chat_core_request_member_rtt_trampoline, req) < 0) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "member RTT request rejected ch=%s", qPrintable(m_channelId)); u_free(req);
|
|
}
|
|
}
|
|
|
|
quint64 MemberListModel::nodeIdAt(int row) const {
|
|
return row >= 0 && row < m_members.size() ? m_members.at(row).snapshot.node_id : 0;
|
|
}
|
|
|
|
bool MemberListModel::isOnlineAt(int row) const {
|
|
return row >= 0 && row < m_members.size() ? m_members.at(row).snapshot.online : false;
|
|
}
|
|
|
|
std::optional<chat_member_display> MemberListModel::member(quint64 nodeId) const {
|
|
for (const auto& item : m_members) if (item.snapshot.node_id == nodeId) return item.snapshot;
|
|
return std::nullopt;
|
|
}
|
|
|
|
static bool deserialize_member(const uint8_t* src, MemberListItem& item) {
|
|
if (chat_core_deserialize_member(src, &item.snapshot) < 0) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member snapshot decode failed: missing data"); return false;
|
|
}
|
|
item.name = QString::fromUtf8(item.snapshot.name);
|
|
|
|
QColor c = item.snapshot.online ? QColor("#4CAF50") : QColor("#9E9E9E");
|
|
const int size = MemberListModel::kAvatarSize;
|
|
QPixmap pm(size, size); pm.fill(Qt::transparent);
|
|
QPainter p(&pm);
|
|
p.setRenderHint(QPainter::Antialiasing);
|
|
const bool supernode = item.snapshot.flags & CHAT_MEMBER_FLAG_SUPERNODE;
|
|
const bool storage = item.snapshot.storage;
|
|
if (supernode || storage) {
|
|
if (supernode) FlagPainter::drawServer(&p, 0, 0, storage ? size * 4 / 5 : size);
|
|
if (storage) {
|
|
const int storageSize = supernode ? size * 2 / 3 : size;
|
|
FlagPainter::drawStorage(&p, size - storageSize, size - storageSize, storageSize);
|
|
}
|
|
p.setPen(QPen(c, 1)); p.setBrush(c);
|
|
p.drawEllipse(QRect(size - 9, size - 9, 8, 8));
|
|
} else {
|
|
p.setBrush(c);
|
|
p.setPen(Qt::NoPen);
|
|
p.drawEllipse(0, 0, size, size);
|
|
p.setPen(Qt::white);
|
|
QFont f; f.setBold(true); f.setPixelSize(size / 2);
|
|
p.setFont(f);
|
|
p.drawText(QRect(0, 0, size, size), Qt::AlignCenter, item.name.left(1));
|
|
}
|
|
p.end();
|
|
item.avatar = pm;
|
|
return true;
|
|
}
|
|
|
|
void MemberListModel::onMemberList(const QString& chId, int count, const uint8_t* data, int dataLen) {
|
|
if (chId != m_channelId) return;
|
|
if (count < 0 || dataLen < 0 || count != dataLen / CHAT_MEMBER_DISPLAY_SIZE ||
|
|
dataLen % CHAT_MEMBER_DISPLAY_SIZE || (count && !data)) {
|
|
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member list: invalid count=%d bytes=%d", count, dataLen); return;
|
|
}
|
|
|
|
QVector<MemberListItem> members;
|
|
members.reserve(count);
|
|
for (int i = 0; i < count; i++) {
|
|
MemberListItem item;
|
|
if (!deserialize_member(data + i * CHAT_MEMBER_DISPLAY_SIZE, item)) return;
|
|
applyPhoto(item, chId);
|
|
if ((item.snapshot.flags & CHAT_MEMBER_FLAG_DELETED) && !m_isOwner) continue;
|
|
members.append(item);
|
|
}
|
|
beginResetModel();
|
|
m_members = std::move(members);
|
|
endResetModel();
|
|
const int roleAvatars = std::count_if(m_members.begin(), m_members.end(), [](const MemberListItem& item) {
|
|
return (item.snapshot.flags & CHAT_MEMBER_FLAG_SUPERNODE) || item.snapshot.storage;
|
|
});
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "member list applied ch=%s count=%d visible=%d owner=%d role_avatars=%d",
|
|
qPrintable(chId), count, int(m_members.size()), m_isOwner, roleAvatars);
|
|
emit snapshotChanged(0);
|
|
}
|
|
|
|
void MemberListModel::onMemberUpdated(const QString& chId, const uint8_t* data) {
|
|
if (chId != m_channelId) return;
|
|
|
|
MemberListItem item;
|
|
if (!deserialize_member(data, item)) return;
|
|
applyPhoto(item, chId);
|
|
|
|
/* find existing row by nodeId */
|
|
int row = -1;
|
|
for (int i = 0; i < m_members.size(); ++i) {
|
|
if (m_members[i].snapshot.node_id == item.snapshot.node_id) { row = i; break; }
|
|
}
|
|
|
|
if ((item.snapshot.flags & CHAT_MEMBER_FLAG_DELETED) && !m_isOwner) {
|
|
/* deleted member — remove from non-owner's list */
|
|
onMemberRemoved(chId, item.snapshot.node_id);
|
|
return;
|
|
}
|
|
|
|
if (row < 0 || ((m_members[row].snapshot.flags ^ item.snapshot.flags) & CHAT_MEMBER_FLAG_SUPERNODE)
|
|
|| m_members[row].snapshot.storage != item.snapshot.storage)
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT_SYNC, "member avatar updated ch=%s node=%016llx supernode=%d storage=%u",
|
|
qPrintable(chId), (unsigned long long)item.snapshot.node_id, bool(item.snapshot.flags & CHAT_MEMBER_FLAG_SUPERNODE),
|
|
item.snapshot.storage);
|
|
if (row >= 0) {
|
|
const auto& previous = m_members[row].snapshot;
|
|
if (previous.online != item.snapshot.online || previous.conn_up != item.snapshot.conn_up
|
|
|| previous.intermediaries != item.snapshot.intermediaries || previous.nat_type != item.snapshot.nat_type)
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_CONNECTION, "member route updated ch=%s node=%016llx online=%u up=%u intermediaries=%u nat=%u display=%s",
|
|
qPrintable(chId), (unsigned long long)item.snapshot.node_id, item.snapshot.online, item.snapshot.conn_up,
|
|
item.snapshot.intermediaries, item.snapshot.nat_type, qPrintable(routeText(item.snapshot, false)));
|
|
m_members[row] = item;
|
|
emit dataChanged(index(row), index(row));
|
|
} else {
|
|
/* new member — add at end */
|
|
row = int(m_members.size());
|
|
beginInsertRows(QModelIndex(), row, row);
|
|
m_members.append(item);
|
|
endInsertRows();
|
|
}
|
|
emit snapshotChanged(item.snapshot.node_id);
|
|
}
|
|
|
|
void MemberListModel::onMemberRemoved(const QString& chId, uint64_t nodeId) {
|
|
if (chId != m_channelId) return;
|
|
if (m_radioTalkers.remove(nodeId))
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: removed talker ch=%s node=%016llx",
|
|
qPrintable(chId), (unsigned long long)nodeId);
|
|
|
|
for (int row = 0; row < m_members.size(); ++row) {
|
|
if (m_members[row].snapshot.node_id == nodeId) {
|
|
beginRemoveRows(QModelIndex(), row, row);
|
|
m_members.removeAt(row);
|
|
endRemoveRows();
|
|
emit snapshotChanged(nodeId);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Состояние собственного TX берём из аудио-движка: оно включает PTT и VAD. */
|
|
void MemberListModel::setRadioState(quint64 groupId, bool active, bool transmitting) {
|
|
active = active && groupId != 0 && groupId == m_channelId.toULongLong();
|
|
transmitting = active && transmitting;
|
|
if (m_radioActive == active && m_selfTransmitting == transmitting) return;
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: state ch=%s group=%016llx active=%d self_tx=%d talkers=%d",
|
|
qPrintable(m_channelId), (unsigned long long)groupId, active, transmitting, int(m_radioTalkers.size()));
|
|
m_radioActive = active; m_selfTransmitting = transmitting;
|
|
if (!active) m_radioTalkers.clear();
|
|
if (!m_members.isEmpty()) emit dataChanged(index(0), index(m_members.size() - 1), {RadioSpeakingRole});
|
|
}
|
|
|
|
/* START может прийти до списка участников; STOP снимает только указанный источник. */
|
|
void MemberListModel::onRadioTalk(quint64 groupId, quint64 nodeId, bool on) {
|
|
if (!groupId || !nodeId) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio members: invalid talk group=%016llx node=%016llx on=%d",
|
|
(unsigned long long)groupId, (unsigned long long)nodeId, on);
|
|
return;
|
|
}
|
|
if (groupId != m_channelId.toULongLong() || (on && !m_radioActive)) {
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: ignored talk ch=%s group=%016llx node=%016llx on=%d active=%d",
|
|
qPrintable(m_channelId), (unsigned long long)groupId, (unsigned long long)nodeId, on, m_radioActive);
|
|
return;
|
|
}
|
|
if (m_radioTalkers.contains(nodeId) == on) return;
|
|
if (on) m_radioTalkers.insert(nodeId);
|
|
else m_radioTalkers.remove(nodeId);
|
|
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: talk ch=%s node=%016llx on=%d talkers=%d",
|
|
qPrintable(m_channelId), (unsigned long long)nodeId, on, int(m_radioTalkers.size()));
|
|
for (int row = 0; row < m_members.size(); ++row) if (m_members[row].snapshot.node_id == nodeId) {
|
|
emit dataChanged(index(row), index(row), {RadioSpeakingRole});
|
|
return;
|
|
}
|
|
}
|