Browse Source

fix(chat): node online status race, WELCOME peer_needed off-by-one, JOIN missing proto byte

- node_put/node_update_verified: set online=1 on INSERT (nodes created when conn is UP)
- channel_peers_all: fix peer_needed calculation (atl byte was missing)
- cs_send_channel_join: add proto byte to address serialization matching all parsers
topo_upd
evgeny 2 months ago
parent
commit
f476188566
  1. 20
      src/chat/chat_sync.c
  2. 17
      src/routing_layer/conn_mgr_core.c
  3. 16
      src/routing_layer/topo_group_connect.c
  4. 10
      src/routing_layer/topo_node_sqlite.c
  5. 1
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  6. 1
      tools/chatgui/CMakeLists.txt
  7. 27
      tools/chatgui/db/db_manager.cpp
  8. 1
      tools/chatgui/db/db_manager.h
  9. 26
      tools/chatgui/src/channeldelegate.cpp
  10. 2
      tools/chatgui/src/channeldelegate.h
  11. 23
      tools/chatgui/src/channellist.cpp
  12. 1
      tools/chatgui/src/channellist.h
  13. 99
      tools/chatgui/src/channelsettingsdialog.cpp
  14. 22
      tools/chatgui/src/channelsettingsdialog.h
  15. 148
      tools/chatgui/src/mainwindow.cpp
  16. 19
      tools/chatgui/src/mainwindow.h
  17. 19
      tools/chatgui/src/messagedelegate.cpp
  18. 3
      tools/chatgui/src/messagedelegate.h

20
src/chat/chat_sync.c

@ -1128,9 +1128,10 @@ static void cs_handle_channel_info_resp(struct chat_sync* cs, uint64_t peer,
else { sock = sock->next; continue; }
if (sa->ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
if (joff + 8 > sizeof(jbuf)) break;
if (joff + 9 > sizeof(jbuf)) break;
jbuf[joff++] = 4;
jbuf[joff++] = sock->sock_id;
jbuf[joff++] = 1;
memcpy(jbuf + joff, &sin->sin_addr, 4); joff += 4;
uint16_t p = ntohs(sin->sin_port);
jbuf[joff++] = (uint8_t)((p >> 8) & 0xFF);
@ -1138,9 +1139,10 @@ static void cs_handle_channel_info_resp(struct chat_sync* cs, uint64_t peer,
addr_cnt++;
} else if (sa->ss_family == AF_INET6) {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
if (joff + 20 > sizeof(jbuf)) break;
if (joff + 21 > sizeof(jbuf)) break;
jbuf[joff++] = 6;
jbuf[joff++] = sock->sock_id;
jbuf[joff++] = 1;
memcpy(jbuf + joff, &sin6->sin6_addr, 16); joff += 16;
uint16_t p = ntohs(sin6->sin6_port);
jbuf[joff++] = (uint8_t)((p >> 8) & 0xFF);
@ -1217,9 +1219,10 @@ static void cs_handle_channel_join(struct chat_sync* cs, uint64_t peer,
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) {
sqlite3_bind_int64(stmt, 1, (sqlite3_int64)node_id);
sqlite3_bind_int(stmt, 2, fm);
sqlite3_bind_blob(stmt, 3, ip, ip_len, SQLITE_STATIC);
sqlite3_bind_int(stmt, 4, (int)port);
sqlite3_bind_int(stmt, 5, (int)sid);
sqlite3_bind_int(stmt, 3, (int)proto);
sqlite3_bind_blob(stmt, 4, ip, ip_len, SQLITE_STATIC);
sqlite3_bind_int(stmt, 5, (int)port);
sqlite3_bind_int(stmt, 6, (int)sid);
sqlite3_step(stmt); sqlite3_finalize(stmt);
}
if (fm == 4)
@ -1391,9 +1394,10 @@ static void cs_handle_welcome(struct chat_sync* cs, uint64_t peer,
if (sqlite3_prepare_v2(db, sql, -1, &stmt2, NULL) == SQLITE_OK) {
sqlite3_bind_int64(stmt2, 1, (sqlite3_int64)node_id);
sqlite3_bind_int(stmt2, 2, fm);
sqlite3_bind_blob(stmt2, 3, ip, ip_len, SQLITE_STATIC);
sqlite3_bind_int(stmt2, 4, (int)port);
sqlite3_bind_int(stmt2, 5, (int)sid);
sqlite3_bind_int(stmt2, 3, (int)proto);
sqlite3_bind_blob(stmt2, 4, ip, ip_len, SQLITE_STATIC);
sqlite3_bind_int(stmt2, 5, (int)port);
sqlite3_bind_int(stmt2, 6, (int)sid);
sqlite3_step(stmt2); sqlite3_finalize(stmt2);
}
if (fm == 4)

17
src/routing_layer/conn_mgr_core.c

@ -791,7 +791,22 @@ void cm_handle_invite_info_resp(struct CONN_MGR* mgr, struct ETCP_CONN* conn, co
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "invite: membership CONFIRMED by 0x%016llx name=\"%.*s\" — joined to channel, handle registered",
(unsigned long long)inv->node_id, resp->node_name_len, resp->node_name_len ? (const char*)resp->node_name : "");
if (inv->cb) inv->cb(inv->handle, inv->node_id, inv->mgr->group->group_id, CONN_EVENT_JOIN, inv->cb_arg);
if (inv->ncd_handle) { node_conn_direct_close(inv->ncd_handle); inv->ncd_handle = NULL; }
if (inv->ncd_handle) {
struct CONN_MGR_ENTRY* old_e = cm_find_entry(inv->mgr, inv->node_id);
if (old_e) {
if (old_e->ncd_handle) { node_conn_direct_force_close(old_e->ncd_handle); old_e->ncd_handle = NULL; }
cm_entry_cleanup(old_e);
}
struct CONN_MGR_ENTRY* cm_entry = cm_ensure_entry(inv->mgr, inv->node_id);
if (cm_entry) {
node_conn_direct_transfer(inv->ncd_handle, cm_ncd_callback, cm_entry);
cm_entry->ncd_handle = inv->ncd_handle;
cm_entry->state = CONN_MGR_STATE_CONNECTED;
cm_entry->conn_type = CONN_TYPE_DIRECT;
cm_update_nodeinfo(cm_entry);
}
inv->ncd_handle = NULL;
}
cm_invite_cleanup(inv);
}

16
src/routing_layer/topo_group_connect.c

@ -292,16 +292,16 @@ static void tgc_phase2_try_next(struct TOPO_GROUP_CONNECT* gc) {
if (gc->tried_super == 0) {
topo_node_sqlite_get_supernode_peers(db, gc->group->channel_id, &gc->candidate_ids, &gc->candidate_count);
gc->tried_super = 1; gc->cursor = 0;
DEBUG_DEBUG(DEBUG_CATEGORY_BGP, "%s: Phase2 supernode round — loaded %d for ch=%s",
DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase2 supernode round — loaded %d for ch=%s",
TGC_ID, gc->candidate_count, gc->group->channel_id);
} else if (gc->tried_super == 1) {
topo_node_sqlite_get_public_peers(db, gc->group->channel_id, &gc->candidate_ids, &gc->candidate_count,
gc->group->instance->node_id);
gc->tried_super = 2; gc->cursor = 0;
DEBUG_DEBUG(DEBUG_CATEGORY_BGP, "%s: Phase2 public round — loaded %d for ch=%s",
DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase2 public round — loaded %d for ch=%s",
TGC_ID, gc->candidate_count, gc->group->channel_id);
} else {
DEBUG_DEBUG(DEBUG_CATEGORY_BGP, "%s: Phase2 exhausted — no connections, ch=%s", TGC_ID, gc->group->channel_id);
DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase2 exhausted — no connections, ch=%s", TGC_ID, gc->group->channel_id);
gc->phase = TGC_PHASE_THREE; gc->candidate_count = 0;
tgc_phase3_try_next(gc);
return;
@ -309,9 +309,9 @@ static void tgc_phase2_try_next(struct TOPO_GROUP_CONNECT* gc) {
}
if (gc->cursor < gc->candidate_count) {
uint64_t nid = gc->candidate_ids[gc->cursor++];
DEBUG_DEBUG(DEBUG_CATEGORY_BGP, "%s: Phase2 trying 0x%016llx (%d/%d) super_round=%d", TGC_ID,
DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase2 trying 0x%016llx (%d/%d) super_round=%d", TGC_ID,
(unsigned long long)nid, gc->cursor, gc->candidate_count, gc->tried_super);
conn_mgr_open_invite(gc->group->instance, gc->group->group_id, NULL, nid, tgc_callback, gc, NULL);
if (conn_mgr_open_invite(gc->group->instance, gc->group->group_id, NULL, nid, tgc_callback, gc, NULL) < 0) continue;
return;
}
if (gc->candidate_ids) { u_free(gc->candidate_ids); gc->candidate_ids = NULL; }
@ -330,14 +330,14 @@ static void tgc_phase3_try_next(struct TOPO_GROUP_CONNECT* gc) {
topo_node_sqlite_get_local_peers(db, gc->group->channel_id, &gc->candidate_ids, &gc->candidate_count,
gc->group->instance->node_id);
gc->cursor = 0;
DEBUG_DEBUG(DEBUG_CATEGORY_BGP, "%s: Phase3 loaded %d local peers for ch=%s",
DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase3 loaded %d local peers for ch=%s",
TGC_ID, gc->candidate_count, gc->group->channel_id);
}
while (gc->cursor < gc->candidate_count) {
uint64_t nid = gc->candidate_ids[gc->cursor++];
DEBUG_DEBUG(DEBUG_CATEGORY_BGP, "%s: Phase3 trying 0x%016llx (%d/%d)", TGC_ID,
DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase3 trying 0x%016llx (%d/%d)", TGC_ID,
(unsigned long long)nid, gc->cursor, gc->candidate_count);
conn_mgr_open_invite(gc->group->instance, gc->group->group_id, NULL, nid, tgc_callback, gc, NULL);
if (conn_mgr_open_invite(gc->group->instance, gc->group->group_id, NULL, nid, tgc_callback, gc, NULL) < 0) continue;
return;
}
DEBUG_INFO(DEBUG_CATEGORY_BGP, "%s: Phase3 exhausted — no connections, ch=%s", TGC_ID, gc->group->channel_id);

10
src/routing_layer/topo_node_sqlite.c

@ -116,8 +116,8 @@ int topo_node_sqlite_node_put(sqlite3* db, struct TOPO_GROUPS* groups, struct TO
sqlite3_stmt* stmt = NULL;
if (sqlite3_prepare_v2(db,
"INSERT INTO nodes(node_id, name, x25519_pubkey, ed25519_pubkey, last_seen_at)"
" VALUES(?,?,?,?,?)"
"INSERT INTO nodes(node_id, name, x25519_pubkey, ed25519_pubkey, last_seen_at, online)"
" VALUES(?,?,?,?,?,1)"
" ON CONFLICT(node_id) DO UPDATE SET"
" name=CASE WHEN excluded.name != '' THEN excluded.name ELSE nodes.name END, x25519_pubkey=excluded.x25519_pubkey,"
" ed25519_pubkey=excluded.ed25519_pubkey, last_seen_at=excluded.last_seen_at,\n"
@ -273,8 +273,8 @@ int topo_node_sqlite_node_update_verified(sqlite3* db, uint64_t node_id,
if (!db || !x25519 || !ed25519) return -1;
sqlite3_stmt* stmt = NULL;
if (sqlite3_prepare_v2(db,
"INSERT INTO nodes(node_id, name, x25519_pubkey, ed25519_pubkey, created_at, update_ts)"
" VALUES(?,?,?,?,?,?)"
"INSERT INTO nodes(node_id, name, x25519_pubkey, ed25519_pubkey, created_at, update_ts, online)"
" VALUES(?,?,?,?,?,?,1)"
" ON CONFLICT(node_id) DO UPDATE SET"
" name=CASE WHEN excluded.name IS NOT NULL AND excluded.name != ''"
" THEN excluded.name ELSE nodes.name END,"
@ -409,7 +409,7 @@ int topo_node_sqlite_channel_peers_all(sqlite3* db, const char* channel_id,
sqlite3_finalize(ast);
uint8_t flags = (join_sig && join_ts) ? PEERS_FLAG_HAS_JOIN : 0;
size_t peer_needed = 8 + 32 + 32 + 1 + (flags & PEERS_FLAG_HAS_JOIN ? 64+8 : 0) + 64 + 8 + 1 + (size_t)nl + 1 + (size_t)atl + 64 + 1;
size_t peer_needed = 8 + 32 + 32 + 1 + (flags & PEERS_FLAG_HAS_JOIN ? 64+8 : 0) + 64 + 8 + 1 + (size_t)nl + 1 + 1 + (size_t)atl + 64;
size_t addr_sz = off - addr_start;
if (addr_cnt > 0) {
if (off + peer_needed > buf_sz) { sqlite3_finalize(stmt); return -2; }

1
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt

@ -291,6 +291,7 @@ class ConfigProvider(private val context: Context) {
if (privKey.length == 64) appendLine("my_private_key=$privKey")
appendLine("db_path=$dbDir")
appendLine("db_sync_enabled=1")
appendLine("chatserver_enabled=1")
appendLine()
for (srv in servers) {
val family = if (srv.bindIp.contains(":")) "v6" else "v4"

1
tools/chatgui/CMakeLists.txt

@ -89,6 +89,7 @@ add_executable(chatgui
src/channellist.cpp
src/creategroupdialog.cpp
src/channeldelegate.cpp
src/channelsettingsdialog.cpp
src/chatview.cpp
src/messagedelegate.cpp
src/messagelist.cpp

27
tools/chatgui/db/db_manager.cpp

@ -229,6 +229,33 @@ QList<MessageRow> DbManager::getMessagesSince(const QString& chId, qint64 sinceI
return list;
}
MessageRow DbManager::getMessageById(const QString& chId, qint64 msgId) const {
MessageRow m;
QString sql = QStringLiteral(
"SELECT id, node_id, data, timestamp, chain_hash, author_signature, local_attrs"
" FROM \"%1\" WHERE id = ?").arg(msgTableName(chId));
sqlite3_stmt* stmt = prepareOrNull(sql.toUtf8().constData());
if (!stmt) return m;
sqlite3_bind_int64(stmt, 1, msgId);
if (sqlite3_step(stmt) == SQLITE_ROW) {
m.id = sqlite3_column_int64(stmt, 0);
m.authorNodeId = (quint64)sqlite3_column_int64(stmt, 1);
QByteArray jdata = colBlob(stmt, 2);
m.contentType = QString::fromUtf8(parseJsonField(jdata, "ct"));
if (m.contentType.isEmpty()) m.contentType = "text/plain";
m.data = parseJsonField(jdata, "d");
m.timestamp = sqlite3_column_int64(stmt, 3);
m.datahash = 0;
m.chainHash = colBlob(stmt, 4);
m.signature = colBlob(stmt, 5);
m.localAttrs = QByteArray((const char*)sqlite3_column_text(stmt, 6));
m.isOutgoing = (m.authorNodeId == m_myNodeId);
m.isRead = true;
}
sqlite3_finalize(stmt);
return m;
}
/* ── peers (UI) ── */
int DbManager::getChannelMemberCount(const QString& chId) const {

1
tools/chatgui/db/db_manager.h

@ -98,6 +98,7 @@ public:
/* ── Сообщения (UI) ── */
QList<MessageRow> getMessages(const QString& chId, int limit = 50) const;
QList<MessageRow> getMessagesSince(const QString& chId, qint64 sinceId) const;
MessageRow getMessageById(const QString& chId, qint64 msgId) const;
/* ── Участники канала (UI) ── */
int getChannelMemberCount(const QString& chId) const;

26
tools/chatgui/src/channeldelegate.cpp

@ -19,6 +19,13 @@ QRect ChannelDelegate::inviteByLinkButtonRect(const QRect &itemRect) {
InviteBtnSize, InviteBtnSize);
}
QRect ChannelDelegate::settingsButtonRect(const QRect &itemRect) {
const int margin = 6;
return QRect(itemRect.right() - margin - InviteBtnSize * 3 - InviteBtnGap * 2,
itemRect.bottom() - margin - InviteBtnSize,
InviteBtnSize, InviteBtnSize);
}
void ChannelDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const {
painter->save();
@ -199,6 +206,25 @@ void ChannelDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
painter->drawLine(cx + dx + 1, cy, cx + dx + 1, cy - 3 + 1);
painter->drawLine(cx + dx + 1, cy, cx + dx + 1, cy + 3 - 1);
/* ── settings gear (left of invite-by-link button) ── */
QRect gearRect = settingsButtonRect(option.rect);
bool gearHovered = index.data(ChannelSettingsBtnHoveredRole).toInt() != 0;
QColor gearColor = gearHovered ? QColor("#3390EC") : QColor("#888");
painter->setPen(QPen(gearColor, 2));
painter->setBrush(Qt::NoBrush);
int gr = 5;
painter->drawEllipse(gearRect.center(), gr, gr);
painter->drawEllipse(gearRect.center(), gr / 2, gr / 2);
int gcx = gearRect.center().x(), gcy = gearRect.center().y();
painter->drawLine(gcx, gcy - gr, gcx, gcy - gr + 1);
painter->drawLine(gcx, gcy + gr, gcx, gcy + gr - 1);
painter->drawLine(gcx - gr, gcy, gcx - gr + 1, gcy);
painter->drawLine(gcx + gr, gcy, gcx + gr - 1, gcy);
painter->drawLine(gcx - 3, gcy - 4, gcx - 3, gcy - 5);
painter->drawLine(gcx + 3, gcy + 4, gcx + 3, gcy + 5);
painter->drawLine(gcx - 4, gcy - 3, gcx - 5, gcy - 3);
painter->drawLine(gcx + 4, gcy + 3, gcx + 5, gcy + 3);
painter->restore();
}

2
tools/chatgui/src/channeldelegate.h

@ -11,6 +11,7 @@ enum ChannelDataRole {
ChannelAutoConnectActiveRole = Qt::UserRole + 4, /* bool: 1 if auto-connect in progress */
ChannelInviteBtnHoveredRole = Qt::UserRole + 5, /* bool: 1 when mouse hovers invite button */
ChannelInviteByLinkBtnHoveredRole = Qt::UserRole + 8, /* bool: hover for invite-by-link button */
ChannelSettingsBtnHoveredRole = Qt::UserRole + 9, /* bool: hover for per-channel settings gear */
ChannelUnreadCountRole = Qt::UserRole + 6, /* int: unread message count, 0 if none */
ChannelLastMsgAtRole = Qt::UserRole + 7 /* qint64: last message timestamp for sorting */
};
@ -29,4 +30,5 @@ public:
static constexpr int InviteBtnGap = 2;
static QRect inviteButtonRect(const QRect &itemRect);
static QRect inviteByLinkButtonRect(const QRect &itemRect);
static QRect settingsButtonRect(const QRect &itemRect);
};

23
tools/chatgui/src/channellist.cpp

@ -22,11 +22,17 @@ public:
signals:
void inviteClicked(const QString& channelId);
void inviteByLinkClicked(const QString& channelId);
void settingsClicked(const QString& channelId);
protected:
void mousePressEvent(QMouseEvent* e) override {
QModelIndex idx = indexAt(e->pos());
if (!idx.isValid()) return;
QRect gearRect = ChannelDelegate::settingsButtonRect(visualRect(idx));
if (gearRect.contains(e->pos())) {
emit settingsClicked(idx.data(ChannelChannelIdRole).toString());
return;
}
QRect invRect = ChannelDelegate::inviteByLinkButtonRect(visualRect(idx));
if (invRect.contains(e->pos())) {
emit inviteByLinkClicked(idx.data(ChannelChannelIdRole).toString());
@ -42,13 +48,18 @@ protected:
void mouseMoveEvent(QMouseEvent* e) override {
int prevInv = m_hoveredInviteRow;
int prevInvBy = m_hoveredInviteByLinkRow;
int prevSettings = m_hoveredSettingsRow;
m_hoveredInviteRow = -1;
m_hoveredInviteByLinkRow = -1;
m_hoveredSettingsRow = -1;
QModelIndex idx = indexAt(e->pos());
if (idx.isValid()) {
QRect gearRect = ChannelDelegate::settingsButtonRect(visualRect(idx));
QRect invRect = ChannelDelegate::inviteByLinkButtonRect(visualRect(idx));
QRect plusRect = ChannelDelegate::inviteButtonRect(visualRect(idx));
if (invRect.contains(e->pos()))
if (gearRect.contains(e->pos()))
m_hoveredSettingsRow = idx.row();
else if (invRect.contains(e->pos()))
m_hoveredInviteByLinkRow = idx.row();
else if (plusRect.contains(e->pos()))
m_hoveredInviteRow = idx.row();
@ -61,7 +72,12 @@ protected:
model()->setData(model()->index(prevInvBy, 0), 0, ChannelInviteByLinkBtnHoveredRole);
if (m_hoveredInviteByLinkRow >= 0)
model()->setData(model()->index(m_hoveredInviteByLinkRow, 0), 1, ChannelInviteByLinkBtnHoveredRole);
setCursor((m_hoveredInviteRow >= 0 || m_hoveredInviteByLinkRow >= 0) ? Qt::PointingHandCursor : Qt::ArrowCursor);
if (m_hoveredSettingsRow != prevSettings)
model()->setData(model()->index(prevSettings, 0), 0, ChannelSettingsBtnHoveredRole);
if (m_hoveredSettingsRow >= 0)
model()->setData(model()->index(m_hoveredSettingsRow, 0), 1, ChannelSettingsBtnHoveredRole);
setCursor((m_hoveredInviteRow >= 0 || m_hoveredInviteByLinkRow >= 0 || m_hoveredSettingsRow >= 0)
? Qt::PointingHandCursor : Qt::ArrowCursor);
QListView::mouseMoveEvent(e);
}
QItemSelectionModel::SelectionFlags selectionCommand(const QModelIndex& idx, const QEvent* e) const override {
@ -79,6 +95,7 @@ protected:
private:
int m_hoveredInviteRow = -1;
int m_hoveredInviteByLinkRow = -1;
int m_hoveredSettingsRow = -1;
};
static QIcon makeChannelIcon(const QColor &color, const QString &letter) {
@ -173,6 +190,8 @@ ChannelList::ChannelList(DbManager* db, QWidget *parent)
this, &ChannelList::inviteRequested);
connect(static_cast<ChannelListView*>(m_listView), &ChannelListView::inviteByLinkClicked,
this, &ChannelList::inviteByLinkRequested);
connect(static_cast<ChannelListView*>(m_listView), &ChannelListView::settingsClicked,
this, &ChannelList::channelSettingsRequested);
}
void ChannelList::loadChannels() {

1
tools/chatgui/src/channellist.h

@ -23,6 +23,7 @@ signals:
void channelSelected(const QString& channelId);
void inviteRequested(const QString& channelId);
void inviteByLinkRequested(const QString& channelId);
void channelSettingsRequested(const QString& channelId);
void joinRequested();
void createGroupRequested();
void settingsRequested();

99
tools/chatgui/src/channelsettingsdialog.cpp

@ -0,0 +1,99 @@
#include "channelsettingsdialog.h"
#include "../db/db_manager.h"
#include "../transport/gui_bridge.h"
#include "../../lib/mem.h"
extern "C" {
#include "chat/chat_core.h"
}
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QPushButton>
#include <QLabel>
ChannelSettingsDialog::ChannelSettingsDialog(const QString& channelId, DbManager* db, QWidget* parent)
: QDialog(parent)
, m_channelId(channelId)
, m_db(db)
{
setWindowTitle(QStringLiteral("Channel Settings"));
setMinimumWidth(340);
setModal(true);
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(20, 16, 20, 16);
layout->setSpacing(12);
auto* titleLabel = new QLabel(QStringLiteral("Settings for channel"), this);
titleLabel->setStyleSheet("font-weight: bold; font-size: 14px;");
layout->addWidget(titleLabel);
auto* chIdLabel = new QLabel(channelId, this);
chIdLabel->setStyleSheet("color: gray; font-size: 11px;");
chIdLabel->setWordWrap(true);
layout->addWidget(chIdLabel);
layout->addSpacing(8);
m_autoPlayVoice = new QCheckBox(QStringLiteral("Auto-play voice messages on receive"), this);
m_autoPlayVoice->setToolTip(QStringLiteral("Voice messages will play automatically when received. Multiple messages queue up and play sequentially without interrupting current playback."));
layout->addWidget(m_autoPlayVoice);
m_pttMode = new QCheckBox(QStringLiteral("Push-To-Talk (PTT) mode"), this);
m_pttMode->setToolTip(QStringLiteral("Record voice messages with push-to-talk. (Coming soon)"));
m_pttMode->setEnabled(false);
layout->addWidget(m_pttMode);
layout->addStretch();
auto* btnLayout = new QHBoxLayout();
btnLayout->setSpacing(8);
btnLayout->addStretch();
auto* cancelBtn = new QPushButton(QStringLiteral("Cancel"), this);
cancelBtn->setFixedWidth(90);
cancelBtn->setStyleSheet(
"QPushButton { border: 1px solid palette(mid); border-radius: 4px;"
" padding: 6px 16px; font-size: 13px; background: palette(button); }"
"QPushButton:hover { background: palette(light); }");
connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject);
btnLayout->addWidget(cancelBtn);
auto* saveBtn = new QPushButton(QStringLiteral("Save"), this);
saveBtn->setFixedWidth(90);
saveBtn->setDefault(true);
saveBtn->setStyleSheet(
"QPushButton { border: none; border-radius: 4px;"
" padding: 6px 16px; font-size: 13px; font-weight: bold;"
" background: #3390EC; color: white; }"
"QPushButton:hover { background: #2B7ED4; }");
connect(saveBtn, &QPushButton::clicked, this, &ChannelSettingsDialog::onSave);
btnLayout->addWidget(saveBtn);
layout->addLayout(btnLayout);
if (m_db && m_db->isOpen()) {
QString autoPlayKey = QStringLiteral("ch_%1_auto_play_voice").arg(channelId);
m_autoPlayVoice->setChecked(m_db->getUiStateInt(autoPlayKey, 0) != 0);
QString pttKey = QStringLiteral("ch_%1_ptt_mode").arg(channelId);
m_pttMode->setChecked(m_db->getUiStateInt(pttKey, 0) != 0);
}
}
void ChannelSettingsDialog::onSave() {
auto saveKey = [&](const char* suffix, int val) {
QString key = QStringLiteral("ch_%1_%2").arg(m_channelId, QString::fromUtf8(suffix));
QByteArray keyBA = key.toUtf8();
QByteArray valBA = QByteArray::number(val);
size_t klen = (size_t)keyBA.size(), vlen = (size_t)valBA.size();
size_t total = klen + 1 + vlen + 1;
if (total <= 256) {
void* arg = u_malloc(total);
memcpy(arg, keyBA.constData(), klen + 1);
memcpy((char*)arg + klen + 1, valBA.constData(), vlen + 1);
gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg);
}
};
saveKey("auto_play_voice", m_autoPlayVoice->isChecked() ? 1 : 0);
saveKey("ptt_mode", m_pttMode->isChecked() ? 1 : 0);
accept();
}

22
tools/chatgui/src/channelsettingsdialog.h

@ -0,0 +1,22 @@
#pragma once
#include <QDialog>
#include <QCheckBox>
#include <QString>
class DbManager;
class ChannelSettingsDialog : public QDialog {
Q_OBJECT
public:
explicit ChannelSettingsDialog(const QString& channelId, DbManager* db, QWidget* parent = nullptr);
private slots:
void onSave();
private:
QString m_channelId;
DbManager* m_db;
QCheckBox* m_autoPlayVoice;
QCheckBox* m_pttMode;
};

148
tools/chatgui/src/mainwindow.cpp

@ -1,14 +1,17 @@
#include "mainwindow.h"
#include "channellist.h"
#include "messagelist.h"
#include "messagedelegate.h"
#include "accountlist.h"
#include "invitedialog.h"
#include "inviteby.h"
#include "joindialog.h"
#include "settingsdialog.h"
#include "channelsettingsdialog.h"
#include "creategroupdialog.h"
#include "invite_link.h"
#include "sound_manager.h"
#include "voiceplayback.h"
#include "audiorecorder.h"
#include "voicemessageencoder.h"
@ -31,11 +34,15 @@ extern "C" {
#include <QTextStream>
#include <QDir>
#include <cmath>
#include <QJsonDocument>
#include <QJsonObject>
extern "C" {
#include "chat/chat_core.h"
#include "../../lib/mem.h"
#include "../../lib/debug_config.h"
void chat_core_attachment_download_trampoline(void* arg);
void chat_core_save_ui_state_trampoline(void* arg);
}
static MainWindow* s_mainWindow = nullptr;
@ -79,6 +86,7 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath,
, m_cfgDebugFile(debugFile), m_cfgDebugLevel(debugLevel)
, m_cfgDebugCategories(debugCategories)
, m_recorder(nullptr)
, m_autoPlayTimer(new QTimer(this))
{
setWindowTitle("Chat");
resize(900, 600);
@ -92,6 +100,7 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath,
setupNode();
setupConnects();
}
connect(m_autoPlayTimer, &QTimer::timeout, this, &MainWindow::autoPlayTimerTick);
}
MainWindow::~MainWindow() {
@ -263,6 +272,7 @@ void MainWindow::setupBridgeCallbacks() {
QString chId = QString::fromUtf8(ch_id, ch_id_len);
s_mainWindow->m_channelList->loadChannels();
s_mainWindow->m_messageList->onAttachmentDownloaded(chId, msg_id);
s_mainWindow->onAttachmentDownloadedForAutoPlay(chId, msg_id);
});
gui_bridge_set_download_progress_cb([](const char* ch_id, int ch_id_len, int64_t msg_id, int blocks_done, int num_blocks) {
if (!s_mainWindow) return;
@ -287,6 +297,7 @@ void MainWindow::setupConnects() {
connect(m_channelList, &ChannelList::joinRequested, this, &MainWindow::showJoinDialog);
connect(m_channelList, &ChannelList::createGroupRequested, this, &MainWindow::onCreateGroupRequested);
connect(m_channelList, &ChannelList::settingsRequested, this, &MainWindow::showSettings);
connect(m_channelList, &ChannelList::channelSettingsRequested, this, &MainWindow::showChannelSettings);
connect(m_accountList, &AccountList::memberRenamed,
m_messageList, &MessageList::reloadLocalNicks);
connect(m_messageList, &MessageList::readPositionChanged,
@ -422,6 +433,10 @@ void MainWindow::onMessageReceived(const char* ch_id, int ch_id_len, uint64_t au
}
if (m_db && m_db->getUiStateInt("channel_sort", 0) == 1)
reloadChannels();
if (m_db && m_db->isOpen() && m_db->getUiStateInt(QStringLiteral("ch_%1_auto_play_voice").arg(cid), 0)) {
QTimer::singleShot(100, this, [this, cid]() { autoPlayCheckNewMessage(cid); });
}
}
void MainWindow::onMembersChanged(const char* ch_id) {
@ -479,3 +494,136 @@ void MainWindow::onCreateGroupRequested() {
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "MainWindow: create group request posted name=%s", qPrintable(name));
}
void MainWindow::showChannelSettings(const QString& channelId) {
ChannelSettingsDialog dlg(channelId, m_db, this);
dlg.exec();
}
QString MainWindow::autoPlayMediaDir() const {
if (m_db && m_db->isOpen())
return m_db->dbPath() + QStringLiteral("/media");
return QString();
}
void MainWindow::autoPlayCheckNewMessage(const QString& channelId) {
if (!m_db || !m_db->isOpen()) return;
auto msgs = m_db->getMessages(channelId, 1);
if (msgs.isEmpty()) return;
const MessageRow& msg = msgs.first();
if (msg.isOutgoing || msg.contentType != QStringLiteral("audio/opus"))
return;
AutoPlayItem item;
item.channelId = channelId;
item.msgId = msg.id;
item.downloadStarted = false;
item.durationSec = 0.0f;
QByteArray data = msg.data;
if (data.startsWith("wf=")) {
QString raw = QString::fromUtf8(data);
int durPos = raw.indexOf(";dur=", 3);
if (durPos > 0) {
int durEnd = raw.indexOf(';', durPos + 5);
if (durEnd > 0) {
int durMs = raw.mid(durPos + 5, durEnd - durPos - 5).toInt();
item.durationSec = durMs / 1000.0f;
}
}
}
QJsonObject la = QJsonDocument::fromJson(msg.localAttrs).object();
QString fp = la.value(QStringLiteral("fp")).toString();
if (!fp.isEmpty()) {
QString fullPath = autoPlayMediaDir() + QStringLiteral("/") + channelId + QStringLiteral("/") + fp;
if (QFile::exists(fullPath)) {
m_autoPlayQueue.enqueue(item);
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "auto-play: enqueue ch=%s msg=%lld file=%s",
qPrintable(channelId), (long long)msg.id, qPrintable(fullPath));
if (!m_autoPlayTimer->isActive())
m_autoPlayTimer->start(200);
if (!SoundManager::instance()->isCurrentPcmPlaying())
autoPlayTimerTick();
return;
}
}
struct attachment_dl_req* req = (struct attachment_dl_req*)
u_malloc(sizeof(struct attachment_dl_req));
if (!req) return;
memset(req, 0, sizeof(*req));
strncpy(req->channel_id, channelId.toUtf8().constData(), sizeof(req->channel_id) - 1);
req->msg_id = msg.id;
gui_bridge_post_uasync_fn(chat_core_attachment_download_trampoline, req);
item.downloadStarted = true;
m_autoPlayQueue.enqueue(item);
m_autoPlayDownloading.insert(msg.id);
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "auto-play: download started ch=%s msg=%lld",
qPrintable(channelId), (long long)msg.id);
if (!m_autoPlayTimer->isActive())
m_autoPlayTimer->start(200);
}
void MainWindow::autoPlayTimerTick() {
if (SoundManager::instance()->isCurrentPcmPlaying())
return;
while (!m_autoPlayQueue.isEmpty()) {
AutoPlayItem& item = m_autoPlayQueue.head();
MessageRow msg = m_db->getMessageById(item.channelId, item.msgId);
if (msg.id == 0) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "auto-play: msg %lld not found in DB, removing from queue",
(long long)item.msgId);
m_autoPlayQueue.dequeue();
continue;
}
QJsonObject la = QJsonDocument::fromJson(msg.localAttrs).object();
QString fp = la.value(QStringLiteral("fp")).toString();
if (!fp.isEmpty()) {
QString fullPath = autoPlayMediaDir() + QStringLiteral("/") + item.channelId
+ QStringLiteral("/") + fp;
if (QFile::exists(fullPath)) {
m_autoPlayQueue.dequeue();
m_autoPlayDownloading.remove(item.msgId);
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "auto-play: playing ch=%s msg=%lld file=%s dur=%.1fs",
qPrintable(item.channelId), (long long)item.msgId, qPrintable(fullPath),
(double)item.durationSec);
MessageDelegate::stopAllAutoPlay();
VoicePlayer::playOpusFile(fullPath);
MessageDelegate::startAutoPlay(fullPath, item.durationSec);
return;
}
}
if (!item.downloadStarted && !m_autoPlayDownloading.contains(item.msgId)) {
struct attachment_dl_req* req = (struct attachment_dl_req*)
u_malloc(sizeof(struct attachment_dl_req));
if (!req) return;
memset(req, 0, sizeof(*req));
strncpy(req->channel_id, item.channelId.toUtf8().constData(), sizeof(req->channel_id) - 1);
req->msg_id = item.msgId;
gui_bridge_post_uasync_fn(chat_core_attachment_download_trampoline, req);
item.downloadStarted = true;
m_autoPlayDownloading.insert(item.msgId);
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "auto-play: download started ch=%s msg=%lld (from queue)",
qPrintable(item.channelId), (long long)item.msgId);
}
return;
}
m_autoPlayTimer->stop();
}
void MainWindow::onAttachmentDownloadedForAutoPlay(const QString& chId, int64_t msgId) {
(void)chId;
if (m_autoPlayDownloading.remove((qint64)msgId) == 0 && m_autoPlayQueue.isEmpty())
return;
if (!m_autoPlayTimer->isActive())
m_autoPlayTimer->start(200);
if (!SoundManager::instance()->isCurrentPcmPlaying())
autoPlayTimerTick();
}

19
tools/chatgui/src/mainwindow.h

@ -3,6 +3,9 @@
#include <QMainWindow>
#include <QSystemTrayIcon>
#include <QMenu>
#include <QQueue>
#include <QSet>
#include <QTimer>
class ChannelList;
class MessageList;
@ -11,6 +14,13 @@ class DbManager;
class UtunNode;
class AudioRecorder;
struct AutoPlayItem {
QString channelId;
qint64 msgId = 0;
bool downloadStarted = false;
float durationSec = 0.0f;
};
class MainWindow : public QMainWindow {
Q_OBJECT
public:
@ -31,6 +41,8 @@ private slots:
void showJoinDialog();
void onCreateGroupRequested();
void showSettings();
void showChannelSettings(const QString& channelId);
void autoPlayTimerTick();
public:
void onMessageReceived(const char* ch_id, int ch_id_len, uint64_t author);
@ -38,6 +50,7 @@ public:
void onAutoConnectStatus(uint8_t status, uint16_t total, uint16_t totalNodes, uint16_t connected);
void onChannelPeersOnline(const char* ch_id, int ch_id_len, uint16_t online);
void reloadChannels();
void onAttachmentDownloadedForAutoPlay(const QString& chId, int64_t msgId);
UtunNode* utunNode() const { return m_node; }
private:
@ -48,6 +61,8 @@ private:
void setupSoundFromConfig();
void setupBridgeCallbacks();
void setupConnects();
void autoPlayCheckNewMessage(const QString& channelId);
QString autoPlayMediaDir() const;
ChannelList *m_channelList;
MessageList *m_messageList;
@ -64,4 +79,8 @@ private:
QString m_pendingChannelSelect;
QString m_currentChannelId;
AudioRecorder *m_recorder = nullptr;
QQueue<AutoPlayItem> m_autoPlayQueue;
QTimer* m_autoPlayTimer = nullptr;
QSet<int64_t> m_autoPlayDownloading; /* msgIds with download in progress */
};

19
tools/chatgui/src/messagedelegate.cpp

@ -1186,3 +1186,22 @@ void MessageDelegate::drawAvatar(QPainter *p, const Layout &L,
p->drawPixmap(L.avatarRect, av);
p->restore();
}
void MessageDelegate::startAutoPlay(const QString& filePath, float durationSec) {
ensureVoiceTimer();
VoicePlayState& st = s_voiceStates[filePath];
st.playing = true;
st.paused = false;
st.durationSec = (durationSec > 0) ? durationSec : 1.0f;
st.startMs = QDateTime::currentMSecsSinceEpoch();
st.pausedPosSec = 0.0f;
st.displayFrac = 0.0f;
st.displayElapsed = 0.0f;
s_voiceTimer->start();
if (s_voiceView) s_voiceView->viewport()->update();
}
void MessageDelegate::stopAllAutoPlay() {
for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it)
it->playing = false;
}

3
tools/chatgui/src/messagedelegate.h

@ -48,6 +48,9 @@ public:
const QStyleOptionViewItem &option,
const QModelIndex &index) override;
static void startAutoPlay(const QString& filePath, float durationSec);
static void stopAllAutoPlay();
private:
struct Layout {
int viewWidth;

Loading…
Cancel
Save