Browse Source

chatgui/chatgui-android: уведомления со звуком, флаги и прочие улучшения UI

v2
evgeny 3 weeks ago
parent
commit
0f308fdbd7
  1. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt
  2. 24
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  3. 38
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NotificationSound.kt
  4. 58
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt
  5. 17
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  6. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  7. BIN
      tools/chatgui-android/app/src/main/res/raw/msg_incoming.mp3
  8. 1
      tools/chatgui/resources/chatgui.qrc
  9. 6
      tools/chatgui/src/accountdelegate.cpp
  10. 20
      tools/chatgui/src/flagpainter.cpp
  11. 1
      tools/chatgui/src/flagpainter.h
  12. 9
      tools/chatgui/src/main.cpp
  13. 103
      tools/chatgui/src/mainwindow.cpp
  14. 7
      tools/chatgui/src/mainwindow.h
  15. 2
      tools/chatgui/src/memberlistmodel.cpp
  16. 1
      tools/chatgui/src/memberlistmodel.h
  17. 6
      tools/chatgui/src/memberpropsdialog.cpp
  18. 8
      tools/chatgui/src/sound_manager.cpp
  19. 1
      tools/chatgui/transport/node_config.cpp
  20. 12
      tools/chatgui/transport/utun_node.cpp

5
tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt

@ -5,6 +5,7 @@ import android.util.Log
import com.utun.chat.data.LogManager
import com.utun.chat.data.ConfigProvider
import com.utun.chat.data.NativeLib
import com.utun.chat.data.NotificationSound
import com.utun.chat.data.ChatEventCallback
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@ -29,6 +30,10 @@ class ChatApplication : Application() {
logManager = LogManager
configProvider = ConfigProvider(this)
/* Notification sound state: global toggle + per-group mute */
NotificationSound.enabled = configProvider.getSoundEnabled()
NotificationSound.mutedChannels = configProvider.getMutedChannels()
/* One-time event callback registration — survives ViewModel recreation */
NativeLib.setEventCallback(AppEventHandler)

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

@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@ -48,6 +49,8 @@ class ConfigProvider(private val context: Context) {
private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
private val connectionModeKey = stringPreferencesKey("connection_mode")
private val serviceEnabledKey = booleanPreferencesKey("service_enabled")
private val soundEnabledKey = booleanPreferencesKey("sound_enabled")
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
private fun keysFilePath(): String {
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
@ -220,6 +223,27 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[serviceEnabledKey] = enabled }
}
/* ── Notification sound (global toggle + per-group mute) ── */
fun getSoundEnabled(): Boolean = runBlocking {
context.dataStore.data.first()[soundEnabledKey] ?: true
}
suspend fun setSoundEnabled(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[soundEnabledKey] = enabled }
}
fun getMutedChannels(): Set<String> = runBlocking {
context.dataStore.data.first()[mutedChannelsKey] ?: emptySet()
}
suspend fun setChannelMuted(channelId: String, muted: Boolean) {
context.dataStore.edit { prefs ->
val cur = prefs[mutedChannelsKey] ?: emptySet()
prefs[mutedChannelsKey] = if (muted) cur + channelId else cur - channelId
}
}
/* ── Server entries ── */
suspend fun detectInterfaces(saved: List<ServerEntry> = emptyList()): List<ServerEntry> {

38
tools/chatgui-android/app/src/main/java/com/utun/chat/data/NotificationSound.kt

@ -0,0 +1,38 @@
package com.utun.chat.data
import android.media.AudioAttributes
import android.media.MediaPlayer
import com.utun.chat.ChatApplication
import com.utun.chat.R
/* Уведомительный звук входящих сообщений (msg_incoming.mp3).
Глобальный тумблер (enabled) + per-group mute (mutedChannels). */
object NotificationSound {
@Volatile var enabled: Boolean = true
@Volatile var mutedChannels: Set<String> = emptySet()
fun isMuted(channelId: String): Boolean = channelId in mutedChannels
fun setMuted(channelId: String, muted: Boolean) {
mutedChannels = if (muted) mutedChannels + channelId else mutedChannels - channelId
}
fun play(channelId: String) {
if (!enabled || isMuted(channelId)) return
try {
val mp = MediaPlayer.create(ChatApplication.instance, R.raw.msg_incoming)
?: return
mp.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
mp.setOnCompletionListener { it.release() }
mp.start()
LogManager.addLog("DEBUG", "Sound", "notification played ch=$channelId")
} catch (e: Exception) {
LogManager.addLog("WARN", "Sound", "play failed: ${e.message}")
}
}
}

58
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt

@ -23,6 +23,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.Channel
import com.utun.chat.data.NotificationSound
import com.utun.chat.ChatApplication
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -39,6 +42,8 @@ fun ChannelListScreen(
onRefresh: () -> Unit = {}
) {
var showCreateDialog by remember { mutableStateOf(false) }
var menuChannelId by remember { mutableStateOf<String?>(null) }
var optionsChannel by remember { mutableStateOf<Channel?>(null) }
LaunchedEffect(Unit) { onRefresh() }
@ -88,9 +93,22 @@ fun ChannelListScreen(
Box(Modifier.width(4.dp).fillMaxHeight().background(barColor))
Row(Modifier.fillMaxWidth().padding(12.dp)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
IconButton(onClick = { onInviteClick(ch) }, modifier = Modifier.size(44.dp)) {
IconButton(onClick = { menuChannelId = ch.id }, modifier = Modifier.size(44.dp)) {
Text("#", fontSize = 22.sp)
}
DropdownMenu(
expanded = menuChannelId == ch.id,
onDismissRequest = { menuChannelId = null }
) {
DropdownMenuItem(
text = { Text("Invite link") },
onClick = { menuChannelId = null; onInviteClick(ch) }
)
DropdownMenuItem(
text = { Text("Options") },
onClick = { menuChannelId = null; optionsChannel = ch }
)
}
if (badgeText.isNotEmpty()) {
val badgeSize = 18.dp
Box(
@ -137,6 +155,44 @@ fun ChannelListScreen(
}
)
}
optionsChannel?.let { ch ->
ChannelOptionsDialog(
channel = ch,
onDismiss = { optionsChannel = null }
)
}
}
@Composable
fun ChannelOptionsDialog(channel: Channel, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
var soundOn by remember(channel.id) { mutableStateOf(!NotificationSound.isMuted(channel.id)) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(channel.name) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Sound", modifier = Modifier.weight(1f))
Switch(
checked = soundOn,
onCheckedChange = {
soundOn = it
NotificationSound.setMuted(channel.id, !it)
scope.launch {
ChatApplication.instance.configProvider.setChannelMuted(channel.id, !it)
}
}
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Close") }
}
)
}
@Composable

17
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt

@ -17,6 +17,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.utun.chat.ChatApplication
import com.utun.chat.data.NativeLib
import com.utun.chat.data.NotificationSound
import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState
import com.utun.chat.headless.HeadlessService
@ -447,6 +448,9 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
var micTesting by remember { mutableStateOf(false) }
var micLevel by remember { mutableStateOf(0f) }
/* Notification sound */
var soundEnabled by remember { mutableStateOf(provider.getSoundEnabled()) }
LaunchedEffect(micTesting) {
if (micTesting) {
while (true) { micLevel = NativeLib.voiceGetPeakLevel(); delay(25) }
@ -486,6 +490,19 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
/* ── Notification sound ── */
Text("Notification sound", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Play sound on incoming message", modifier = Modifier.weight(1f))
Switch(checked = soundEnabled, onCheckedChange = {
soundEnabled = it
NotificationSound.enabled = it
scope.launch { provider.setSoundEnabled(it) }
})
}
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Microphone test ── */
Text("Microphone test", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {

5
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -203,6 +203,11 @@ class ChatViewModel : ViewModel() {
val chLen = data[0].toInt() and 0xFF
if (data.size >= 1 + chLen) {
val chId = String(data, 1, chLen)
if (data.size >= 1 + chLen + 8) {
val author = java.nio.ByteBuffer.wrap(data, 1 + chLen, 8)
.order(java.nio.ByteOrder.LITTLE_ENDIAN).long
if (author != NativeLib.getMyNodeId()) NotificationSound.play(chId)
}
val cur = _currentChannel.value
if (cur != null && cur.id == chId) refreshMessages(chId)
refreshChannels()

BIN
tools/chatgui-android/app/src/main/res/raw/msg_incoming.mp3

Binary file not shown.

1
tools/chatgui/resources/chatgui.qrc

@ -9,6 +9,7 @@
<file>icons/emoji.png</file>
<file>icons/mic.png</file>
<file>icons/send.png</file>
<file>sounds/msg_incoming.mp3</file>
<file>bg.jpg</file>
</qresource>
</RCC>

6
tools/chatgui/src/accountdelegate.cpp

@ -49,6 +49,10 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
int n = __builtin_popcount(visualFlags);
flagX += nameFont.pixelSize() * n + (n - 1) * 2;
}
if (index.data(Qt::UserRole + 4).toBool()) {
FlagPainter::drawStorage(painter, flagX, option.rect.top() + (option.rect.height() - nameFont.pixelSize()) / 2, nameFont.pixelSize());
flagX += nameFont.pixelSize() + 2;
}
QString name = index.data(Qt::DisplayRole).toString();
painter->drawText(QRect(flagX, option.rect.top() + 2, w - (flagX - x), yMid - option.rect.top() - 2),
@ -62,7 +66,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
QString sub = index.data(Qt::UserRole + 1).toString();
if (!sub.isEmpty())
painter->drawText(QRect(x, yMid + 2, w, option.rect.bottom() - yMid - 2),
painter->drawText(QRect(flagX, yMid + 2, w - (flagX - x), option.rect.bottom() - yMid - 2),
Qt::AlignLeft | Qt::AlignTop, sub);
painter->restore();

20
tools/chatgui/src/flagpainter.cpp

@ -67,3 +67,23 @@ void FlagPainter::drawShield(QPainter* p, int x, int y, int sz) {
p->drawPath(path);
p->restore();
}
void FlagPainter::drawStorage(QPainter* p, int x, int y, int sz) {
p->save();
p->setRenderHint(QPainter::Antialiasing);
qreal cx = x + sz / 2.0, t = y + 1.0, b = y + sz - 1.0;
qreal w = sz * 0.36, gap = sz * 0.20;
QPainterPath path;
path.moveTo(cx - w, t);
path.lineTo(cx + w, t);
path.lineTo(cx + w, b);
path.lineTo(cx - w, b);
path.closeSubpath();
p->setPen(QPen(QColor("#37474F"), 0.8));
p->setBrush(QColor("#78909C"));
p->drawPath(path);
p->setBrush(QColor("#ECEFF1"));
p->drawEllipse(QPointF(cx, t), w, gap);
p->drawEllipse(QPointF(cx, b), w, gap);
p->restore();
}

1
tools/chatgui/src/flagpainter.h

@ -13,6 +13,7 @@
class FlagPainter {
public:
static void draw(QPainter* p, uint8_t flags, int x, int y, int sz);
static void drawStorage(QPainter* p, int x, int y, int sz);
private:
static void drawLightning(QPainter* p, int x, int y, int sz);

9
tools/chatgui/src/main.cpp

@ -18,6 +18,7 @@
#ifdef _WIN32
static UtunNode* g_ctrl_node = nullptr;
static MainWindow* g_mainWindow = nullptr;
static DWORD count_threads(void) {
DWORD pid = GetCurrentProcessId(), count = 0;
@ -34,7 +35,12 @@ static DWORD count_threads(void) {
static BOOL WINAPI console_ctrl_handler(DWORD dwCtrlType) {
if (dwCtrlType == CTRL_C_EVENT || dwCtrlType == CTRL_BREAK_EVENT) {
if (g_ctrl_node) g_ctrl_node->requestStop();
if (g_mainWindow)
QMetaObject::invokeMethod(g_mainWindow, [g_mainWindow] {
g_mainWindow->requestShutdown();
}, Qt::QueuedConnection);
else if (g_ctrl_node)
g_ctrl_node->requestStop();
return TRUE;
}
return FALSE;
@ -149,6 +155,7 @@ int main(int argc, char *argv[]) {
#ifdef _WIN32
g_ctrl_node = w.utunNode();
g_mainWindow = &w;
SetConsoleCtrlHandler(console_ctrl_handler, TRUE);
#endif

103
tools/chatgui/src/mainwindow.cpp

@ -28,6 +28,7 @@ extern "C" {
#include <QSplitter>
#include <QCloseEvent>
#include <QApplication>
#include <QScreen>
#include <QClipboard>
#include <QStyle>
#include <QMessageBox>
@ -38,6 +39,7 @@ extern "C" {
#include <cmath>
#include <QJsonDocument>
#include <QJsonObject>
#include <csignal>
extern "C" {
#include "chat/chat_core.h"
@ -49,12 +51,16 @@ void chat_core_save_ui_state_trampoline(void* arg);
static MainWindow* s_mainWindow = nullptr;
/* Сохранение last_channel_id в БД (ui_state), как все остальные UI-настройки.
Пишется из utun-потока через trampoline, чтобы не трогать sqlite из GUI-потока. */
static void saveLastChannelId(const QString& channelId) {
const char key[] = "last_channel_id";
QByteArray val = channelId.toUtf8();
size_t klen = sizeof(key) - 1, vlen = (size_t)val.size(), total = klen + 1 + vlen + 1;
#ifndef _WIN32
static volatile std::sig_atomic_t g_signalFlag = 0;
static void unixSignalHandler(int) { g_signalFlag = 1; }
#endif
/* Сохранение key/value в БД (ui_state). Пишется из utun-потока через trampoline,
чтобы не трогать sqlite из GUI-потока. Буфер формата key\0value\0, ≤256 байт. */
static void postUiStateKv(const char* key, const QString& value) {
QByteArray val = value.toUtf8();
size_t klen = strlen(key), vlen = (size_t)val.size(), total = klen + 1 + vlen + 1;
if (total > 256) return;
void* arg = u_malloc(total);
memcpy(arg, key, klen + 1);
@ -62,6 +68,10 @@ static void saveLastChannelId(const QString& channelId) {
gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg);
}
static void saveLastChannelId(const QString& channelId) {
postUiStateKv("last_channel_id", channelId);
}
static void onMsgReceivedCallback(const char* ch_id, int ch_id_len, uint64_t author) {
if (s_mainWindow)
s_mainWindow->onMessageReceived(ch_id, ch_id_len, author);
@ -136,6 +146,14 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath,
setupConnects();
}
connect(m_autoPlayTimer, &QTimer::timeout, this, &MainWindow::autoPlayTimerTick);
#ifndef _WIN32
std::signal(SIGINT, unixSignalHandler);
std::signal(SIGTERM, unixSignalHandler);
m_shutdownTimer = new QTimer(this);
connect(m_shutdownTimer, &QTimer::timeout, this, &MainWindow::onShutdownCheck);
m_shutdownTimer->start(200);
#endif
}
MainWindow::~MainWindow() {
@ -167,6 +185,7 @@ MainWindow::~MainWindow() {
void MainWindow::setupUi() {
auto *splitter = new QSplitter(Qt::Horizontal, this);
m_splitter = splitter;
m_channelList = new ChannelList(m_db, splitter);
m_recorder = new AudioRecorder(this);
@ -186,6 +205,49 @@ void MainWindow::setupUi() {
setCentralWidget(splitter);
}
void MainWindow::saveWindowGeometry() {
QRect g = normalGeometry();
if (g.isEmpty()) g = geometry();
postUiStateKv("window_geometry",
QStringLiteral("%1,%2,%3,%4").arg(g.width()).arg(g.height()).arg(g.x()).arg(g.y()));
postUiStateKv("window_maximized", isMaximized() ? QStringLiteral("1") : QStringLiteral("0"));
if (m_splitter) {
const QList<int> sizes = m_splitter->sizes();
if (sizes.size() == 3)
postUiStateKv("window_splitter",
QStringLiteral("%1,%2,%3").arg(sizes[0]).arg(sizes[1]).arg(sizes[2]));
}
}
void MainWindow::restoreWindowGeometry() {
if (!m_db) return;
const QStringList p = m_db->getUiState("window_geometry").split(',');
if (p.size() != 4) return;
int w = p[0].toInt(), h = p[1].toInt(), x = p[2].toInt(), y = p[3].toInt();
if (w <= 0 || h <= 0) return;
QRect target(x, y, w, h);
bool visible = false;
for (const QScreen* s : QGuiApplication::screens()) {
if (s->availableGeometry().intersects(target)) { visible = true; break; }
}
if (!visible) target.moveTo(0, 0);
setGeometry(target);
if (m_db->getUiStateInt("window_maximized", 0))
showMaximized();
if (m_splitter) {
const QStringList sp = m_db->getUiState("window_splitter").split(',');
if (sp.size() == 3) {
int s0 = sp[0].toInt(), s1 = sp[1].toInt(), s2 = sp[2].toInt();
if (s0 > 0 && s1 > 0 && s2 > 0)
m_splitter->setSizes({s0, s1, s2});
}
}
}
void MainWindow::setupNode() {
m_node = new UtunNode(this);
connect(m_node, &UtunNode::error, this, [](const QString& msg) {
@ -247,12 +309,14 @@ void MainWindow::setupSoundFromConfig() {
QString baseDir = QDir::isAbsolutePath(soundDir) ? soundDir : binDir.absoluteFilePath(soundDir);
auto resolvePath = [&baseDir](const QString& f) { return f.isEmpty() ? QString() : QDir::cleanPath(baseDir + "/" + f); };
struct { const char* name; QString file; } events[] = {
{"msg_incoming", msgInFile}, {"msg_outgoing", msgOutFile},
{"connect", connectFile}, {"disconnect", disconnectFile},
};
for (auto& e : events) { if (!e.file.isEmpty()) sm->loadSound(e.name, resolvePath(e.file)); }
if (msgInFile.isEmpty())
sm->loadSound("msg_incoming", ":/sounds/msg_incoming.mp3");
else
sm->loadSound("msg_incoming", resolvePath(msgInFile));
if (!msgOutFile.isEmpty()) sm->loadSound("msg_outgoing", resolvePath(msgOutFile));
if (!connectFile.isEmpty()) sm->loadSound("connect", resolvePath(connectFile));
if (!disconnectFile.isEmpty()) sm->loadSound("disconnect", resolvePath(disconnectFile));
if (m_db && m_db->isOpen()) {
sm->setEnabled(m_db->getUiStateInt("sound_enabled", 1));
sm->setVolume(m_db->getUiStateInt("sound_volume", 80) / 100.0f);
@ -307,6 +371,7 @@ void MainWindow::setupBridgeCallbacks() {
if (!s_mainWindow || !s_mainWindow->m_db) return;
s_mainWindow->m_db->setDb(chat_core_get_db());
s_mainWindow->m_channelList->loadChannels();
s_mainWindow->restoreWindowGeometry();
QString lastCid = s_mainWindow->m_db->getUiState("last_channel_id");
if (!lastCid.isEmpty()) {
/* восстанавливаем канал только если он реально существует в БД —
@ -402,9 +467,7 @@ void MainWindow::setupTray() {
});
QAction *quitAction = m_trayMenu->addAction("Quit");
connect(quitAction, &QAction::triggered, this, [this]() {
if (m_node) m_node->requestStop();
});
connect(quitAction, &QAction::triggered, this, &MainWindow::requestShutdown);
m_trayIcon->setContextMenu(m_trayMenu);
@ -416,12 +479,24 @@ void MainWindow::setupTray() {
void MainWindow::closeEvent(QCloseEvent *event) {
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "MainWindow: closeEvent — hiding to tray");
saveWindowGeometry();
hide();
m_trayIcon->showMessage("vibechat", "Application minimized to tray.",
QSystemTrayIcon::Information, 2000);
event->ignore();
}
void MainWindow::requestShutdown() {
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "MainWindow: requestShutdown — saving geometry and stopping node");
saveWindowGeometry();
if (m_node) m_node->requestStop();
}
void MainWindow::onShutdownCheck() {
if (g_signalFlag)
requestShutdown();
}
void MainWindow::onTrayActivated(QSystemTrayIcon::ActivationReason reason) {
if (reason == QSystemTrayIcon::Trigger ||
reason == QSystemTrayIcon::DoubleClick) {

7
tools/chatgui/src/mainwindow.h

@ -11,6 +11,7 @@ class ChannelList;
class MessageList;
class AccountList;
class DbManager;
class QSplitter;
class UtunNode;
class AudioRecorder;
class ConnMonitorWindow;
@ -59,22 +60,27 @@ public:
void onChannelPeersOnline(const char* ch_id, int ch_id_len, uint16_t online);
void reloadChannels();
void onAttachmentDownloadedForAutoPlay(const QString& chId, int64_t msgId);
void requestShutdown();
UtunNode* utunNode() const { return m_node; }
private:
void setupUi();
void saveWindowGeometry();
void restoreWindowGeometry();
void setupTray();
void setupNode();
void setupMessaging();
void setupSoundFromConfig();
void setupBridgeCallbacks();
void setupConnects();
void onShutdownCheck();
void autoPlayCheckNewMessage(const QString& channelId);
QString autoPlayMediaDir() const;
ChannelList *m_channelList;
MessageList *m_messageList;
AccountList *m_accountList;
QSplitter *m_splitter = nullptr;
QSystemTrayIcon *m_trayIcon;
QMenu *m_trayMenu;
DbManager *m_db;
@ -93,5 +99,6 @@ private:
QQueue<AutoPlayItem> m_autoPlayQueue;
QTimer* m_autoPlayTimer = nullptr;
QTimer* m_shutdownTimer = nullptr;
QSet<int64_t> m_autoPlayDownloading; /* msgIds with download in progress */
};

2
tools/chatgui/src/memberlistmodel.cpp

@ -51,6 +51,7 @@ QVariant MemberListModel::data(const QModelIndex& index, int role) const {
QString("%1").arg(it->nodeId, 16, 16, QChar('0'));
if (role == Qt::UserRole + 2) return it->isSelf;
if (role == Qt::UserRole + 3) return it->flags;
if (role == Qt::UserRole + 4) return (bool)it->storage;
return {};
}
return {};
@ -94,6 +95,7 @@ static void deserialize_member(const uint8_t* src, MemberListItem& item) {
item.rtt = (rtt == 0xFFFF) ? -1 : (int)rtt;
int nat = (int)src[13];
item.natType = (nat == 0xFF) ? -1 : nat;
item.storage = src[14];
item.online = (online != 0);
char name[64];

1
tools/chatgui/src/memberlistmodel.h

@ -19,6 +19,7 @@ struct MemberListItem {
int rtt = -1;
int natType = -1;
uint8_t flags = 0;
uint8_t storage = 0;
};
class MemberListModel : public QAbstractListModel {

6
tools/chatgui/src/memberpropsdialog.cpp

@ -47,6 +47,7 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId,
QString curNick;
bool curVerified = false;
bool curDeleted = false;
bool curStorage = false;
m_currentVer = 0;
if (m_db->m_db) {
QString tbl = m_db->peersTableName(channelId);
@ -65,6 +66,8 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId,
if (vr) { vr += 12; curVerified = (strncmp(vr, "yes", 3) == 0); }
const char* dl = strstr(tags, "\"deleted\":\"");
if (dl) { dl += 11; curDeleted = (strncmp(dl, "yes", 3) == 0); }
const char* sg = strstr(tags, "\"storage\":\"");
if (sg) { sg += 11; curStorage = (strncmp(sg, "yes", 3) == 0); }
}
curNick = DbManager::colText(st, 1);
}
@ -100,6 +103,7 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId,
layout->addWidget(m_superCb);
m_storageCb = new QCheckBox("Storage (priority attachment keeper)", this);
m_storageCb->setChecked(curStorage);
m_storageCb->setEnabled(haveKey);
layout->addWidget(m_storageCb);
@ -180,7 +184,7 @@ void MemberPropsDialog::onSave() {
/* signed fields — через chat_member_tags_commit_trampoline */
if (m_edPrivkey.size() != 32) { accept(); return; }
int ver = m_currentVer + 1;
int ver = m_currentVer;
QString json;
json += QString("{\"ver\":\"%1\"").arg(ver);
json += QString(",\"admin\":\"%1\"").arg(m_adminCb->isChecked() ? "yes" : "no");

8
tools/chatgui/src/sound_manager.cpp

@ -131,13 +131,17 @@ bool SoundManager::loadSound(const QString& name, const QString& filePath) {
}
void SoundManager::play(const QString& name) {
if (!m_enabled || !m_initialized) return;
if (!m_enabled) return;
if (!m_initialized) { DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "SoundManager::play: not initialized"); return; }
EventSettings es = m_eventSettings.value(name);
if (!es.enabled) return;
auto it = m_sounds.find(name);
if (it == m_sounds.end()) return;
if (it == m_sounds.end()) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "SoundManager::play: sound '%s' not loaded", qPrintable(name));
return;
}
ma_sound* s = it->sound;
float effectiveVolume = m_volume * es.volume;

1
tools/chatgui/transport/node_config.cpp

@ -125,6 +125,7 @@ static bool isSectionValid(const QString& section) {
if (section == "tcp_proxy_client") return true;
if (section == "tcp_proxy_server") return true;
if (section == "ntp") return true;
if (section == "chat" || section == "chatserver") return true;
return false;
}

12
tools/chatgui/transport/utun_node.cpp

@ -2,7 +2,6 @@
#include "utun_node.h"
#include <cinttypes>
#include <QDir>
#include <csignal>
#include "../../lib/socket_compat.h"
// === C headers ===
@ -34,11 +33,6 @@ extern "C" {
static UtunNode* g_currentNode = nullptr;
static void signal_handler(int sig) {
(void)sig;
if (g_currentNode) g_currentNode->requestStop();
}
UtunNode::UtunNode(QObject* parent) : QObject(parent) {}
UtunNode::~UtunNode() {
@ -272,9 +266,6 @@ void UtunNode::runLoop() {
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "chat_core + chat_sync initialized");
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "utun_node: entering poll loop");
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);
while (!m_stop) {
uasync_poll(ua, 100);
}
@ -287,9 +278,6 @@ void UtunNode::runLoop() {
m_instance = nullptr;
}
signal(SIGINT, SIG_DFL);
signal(SIGTERM, SIG_DFL);
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "runLoop: emitting stopped");
QMetaObject::invokeMethod(this, [this] { emit stopped(); });
}

Loading…
Cancel
Save