diff --git a/src/chat/chat_sync.c b/src/chat/chat_sync.c index a610bbe3..2cd6d22c 100644 --- a/src/chat/chat_sync.c +++ b/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) diff --git a/src/routing_layer/conn_mgr_core.c b/src/routing_layer/conn_mgr_core.c index 1d686875..8b144835 100644 --- a/src/routing_layer/conn_mgr_core.c +++ b/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); } diff --git a/src/routing_layer/topo_group_connect.c b/src/routing_layer/topo_group_connect.c index 6f572be6..b3a71a73 100644 --- a/src/routing_layer/topo_group_connect.c +++ b/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); diff --git a/src/routing_layer/topo_node_sqlite.c b/src/routing_layer/topo_node_sqlite.c index 1e8cc539..3464ddda 100644 --- a/src/routing_layer/topo_node_sqlite.c +++ b/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; } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 59271b9f..05116826 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/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" diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 94c92b10..46f41ed7 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/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 diff --git a/tools/chatgui/db/db_manager.cpp b/tools/chatgui/db/db_manager.cpp index 61e480e2..d131b67e 100644 --- a/tools/chatgui/db/db_manager.cpp +++ b/tools/chatgui/db/db_manager.cpp @@ -229,6 +229,33 @@ QList 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 { diff --git a/tools/chatgui/db/db_manager.h b/tools/chatgui/db/db_manager.h index fae100de..3fe4c748 100644 --- a/tools/chatgui/db/db_manager.h +++ b/tools/chatgui/db/db_manager.h @@ -98,6 +98,7 @@ public: /* ── Сообщения (UI) ── */ QList getMessages(const QString& chId, int limit = 50) const; QList getMessagesSince(const QString& chId, qint64 sinceId) const; + MessageRow getMessageById(const QString& chId, qint64 msgId) const; /* ── Участники канала (UI) ── */ int getChannelMemberCount(const QString& chId) const; diff --git a/tools/chatgui/src/channeldelegate.cpp b/tools/chatgui/src/channeldelegate.cpp index 03a11075..d2426ddd 100644 --- a/tools/chatgui/src/channeldelegate.cpp +++ b/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(); } diff --git a/tools/chatgui/src/channeldelegate.h b/tools/chatgui/src/channeldelegate.h index ebeef847..93ddd98a 100644 --- a/tools/chatgui/src/channeldelegate.h +++ b/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); }; diff --git a/tools/chatgui/src/channellist.cpp b/tools/chatgui/src/channellist.cpp index ad1e679a..0bc6610b 100644 --- a/tools/chatgui/src/channellist.cpp +++ b/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(m_listView), &ChannelListView::inviteByLinkClicked, this, &ChannelList::inviteByLinkRequested); + connect(static_cast(m_listView), &ChannelListView::settingsClicked, + this, &ChannelList::channelSettingsRequested); } void ChannelList::loadChannels() { diff --git a/tools/chatgui/src/channellist.h b/tools/chatgui/src/channellist.h index 5a4d38a5..86209818 100644 --- a/tools/chatgui/src/channellist.h +++ b/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(); diff --git a/tools/chatgui/src/channelsettingsdialog.cpp b/tools/chatgui/src/channelsettingsdialog.cpp new file mode 100644 index 00000000..e0eeab5b --- /dev/null +++ b/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 +#include +#include +#include + +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(); +} diff --git a/tools/chatgui/src/channelsettingsdialog.h b/tools/chatgui/src/channelsettingsdialog.h new file mode 100644 index 00000000..d15fd3d2 --- /dev/null +++ b/tools/chatgui/src/channelsettingsdialog.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +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; +}; diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 4eb7ea09..85c2b144 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/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 #include #include +#include +#include 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(); +} diff --git a/tools/chatgui/src/mainwindow.h b/tools/chatgui/src/mainwindow.h index 2640b77e..401e8227 100644 --- a/tools/chatgui/src/mainwindow.h +++ b/tools/chatgui/src/mainwindow.h @@ -3,6 +3,9 @@ #include #include #include +#include +#include +#include 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 m_autoPlayQueue; + QTimer* m_autoPlayTimer = nullptr; + QSet m_autoPlayDownloading; /* msgIds with download in progress */ }; diff --git a/tools/chatgui/src/messagedelegate.cpp b/tools/chatgui/src/messagedelegate.cpp index aaa2cca5..8707a86d 100644 --- a/tools/chatgui/src/messagedelegate.cpp +++ b/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; +} diff --git a/tools/chatgui/src/messagedelegate.h b/tools/chatgui/src/messagedelegate.h index 1f2d64ad..3b263864 100644 --- a/tools/chatgui/src/messagedelegate.h +++ b/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;