diff --git a/CMakeLists.txt b/CMakeLists.txt index b021283..36cb21e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,10 @@ qt_add_executable(llink src/particlelistmodel.cpp src/textinputdialog.h src/textinputdialog.cpp + src/createstreamdialog.h + src/createstreamdialog.cpp + src/memberpickerdialog.h + src/memberpickerdialog.cpp ) qt_add_resources(llink diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 3466d56..c38b24e 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -5,10 +5,13 @@ #include "MainWindow.h" #include "authdialog.h" #include "authmanager.h" +#include "createstreamdialog.h" +#include "memberpickerdialog.h" #include "settingsdialog.h" #include "textinputdialog.h" #include "ui_mainwindow.h" #include +#include #include #include #include @@ -23,7 +26,7 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), m_trayIcon(nullptr), m_authManager(new AuthManager(this)), m_settingsDialog(nullptr), m_authDialog(nullptr), m_store(nullptr), m_networkStreamModel(nullptr), m_particleListModel(nullptr), - m_selectedParticleRow(-1), m_isDragging(false), m_textInputDialog(nullptr) + m_selectedParticleRow(-1), m_isDragging(false), m_textInputDialog(nullptr), m_createStreamDialog(nullptr) { m_authDialog = new AuthDialog(this); m_textInputDialog = new TextInputDialog(this); @@ -110,6 +113,57 @@ MainWindow::MainWindow(QWidget *parent) // DocumentSend toolbar button opens text input dialog connect(ui->toolButton_3, &QToolButton::clicked, this, &MainWindow::showTextInputDialog); + // "New stream" button + m_createStreamDialog = new CreateStreamDialog(this); + connect(ui->pushButton, &QPushButton::clicked, this, [this]() { + if (m_selectedNetworkName.isEmpty()) + return; + const Network *network = m_store->networkById(m_selectedNetworkName); + if (network) + m_createStreamDialog->setNetworkMembers(network->members); + m_createStreamDialog->show(); + m_createStreamDialog->raise(); + m_createStreamDialog->activateWindow(); + }); + connect(m_createStreamDialog, &CreateStreamDialog::streamRequested, this, + [this](const QString &name, const QString &visibility, const QStringList &members) { + m_store->createStream(m_selectedNetworkName, name, "", visibility, members); + }); + + // "Add members" toolbar button (ListAdd icon in right sidebar) + connect(ui->toolButton_7, &QToolButton::clicked, this, [this]() { + if (m_selectedStreamId.isEmpty() || m_selectedNetworkName.isEmpty()) + return; + const Network *network = m_store->networkById(m_selectedNetworkName); + const Stream *stream = m_store->streamById(m_selectedStreamId); + if (!network || !stream) + return; + + MemberPickerDialog picker("Add Members", this); + picker.setMembers(network->members, stream->memberEmails); + + if (picker.exec() == QDialog::Accepted) + { + QStringList emails = picker.selectedEmails(); + if (!emails.isEmpty()) + { + auto *op = m_store->addStreamMembers(m_selectedStreamId, emails); + connect(op, &Operation::success, this, [this](const QJsonDocument &) { + auto *fetchOp = m_store->fetchStream(m_selectedStreamId); + connect(fetchOp, &Operation::success, this, [this](const QJsonDocument &) { + ui->membersList->clear(); + const Stream *s = m_store->streamById(m_selectedStreamId); + if (s) + { + for (const QString &email : s->memberEmails) + ui->membersList->addItem(email.split("@")[0]); + } + }); + }); + } + } + }); + m_authManager->tryRestoreSession(); setupTrayIcon(); @@ -286,6 +340,12 @@ void MainWindow::onParticleSelectionChanged(const QItemSelection &selected, cons { ui->label->setText(""); } + + // Mark seen on selection + if (!particle->seen) + { + m_store->markParticleSeen(particle->id); + } } updateStatusBar(); @@ -407,6 +467,11 @@ bool MainWindow::eventFilter(QObject *obj, QEvent *event) showTextInputDialog(); return true; } + if (keyEvent->key() == Qt::Key_A && keyEvent->modifiers() == Qt::NoModifier && !m_selectedParticleId.isEmpty()) + { + m_store->ackParticle(m_selectedParticleId); + return true; + } } return QMainWindow::eventFilter(obj, event); } diff --git a/src/MainWindow.h b/src/MainWindow.h index 8385f46..4dff0d6 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -18,6 +18,8 @@ class QLabel; QT_END_NAMESPACE class AuthDialog; +class CreateStreamDialog; +class MemberPickerDialog; class SettingsDialog; class TextInputDialog; @@ -62,6 +64,7 @@ private: SettingsDialog *m_settingsDialog; TextInputDialog *m_textInputDialog; + CreateStreamDialog *m_createStreamDialog; // Data and models Store *m_store; diff --git a/src/createstreamdialog.cpp b/src/createstreamdialog.cpp new file mode 100644 index 0000000..2abea8e --- /dev/null +++ b/src/createstreamdialog.cpp @@ -0,0 +1,80 @@ +#include "createstreamdialog.h" +#include +#include +#include +#include +#include +#include + +CreateStreamDialog::CreateStreamDialog(QWidget *parent) + : QDialog(parent) +{ + setWindowTitle("New Stream"); + setMinimumWidth(320); + + auto *layout = new QVBoxLayout(this); + + layout->addWidget(new QLabel("Stream name:")); + m_nameEdit = new QLineEdit(this); + m_nameEdit->setPlaceholderText("e.g. design-review"); + layout->addWidget(m_nameEdit); + + layout->addWidget(new QLabel("Visibility:")); + m_visibilityCombo = new QComboBox(this); + m_visibilityCombo->addItem("Everyone in network", "network_all"); + m_visibilityCombo->addItem("Specific members", "custom"); + layout->addWidget(m_visibilityCombo); + + m_membersLabel = new QLabel("Select members:"); + m_membersList = new QListWidget(this); + m_membersLabel->setVisible(false); + m_membersList->setVisible(false); + layout->addWidget(m_membersLabel); + layout->addWidget(m_membersList); + + connect(m_visibilityCombo, &QComboBox::currentIndexChanged, this, [this](int) { + bool specific = (m_visibilityCombo->currentData().toString() == "custom"); + m_membersLabel->setVisible(specific); + m_membersList->setVisible(specific); + }); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + layout->addWidget(buttons); + + connect(buttons, &QDialogButtonBox::accepted, this, [this]() { + QString name = m_nameEdit->text().trimmed(); + if (name.isEmpty()) + return; + + QString visibility = m_visibilityCombo->currentData().toString(); + QStringList members; + if (visibility == "custom") + { + for (int i = 0; i < m_membersList->count(); ++i) + { + auto *item = m_membersList->item(i); + if (item->checkState() == Qt::Checked) + members.append(item->data(Qt::UserRole).toString()); + } + } + + emit streamRequested(name, visibility, members); + m_nameEdit->clear(); + m_visibilityCombo->setCurrentIndex(0); + accept(); + }); + + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +void CreateStreamDialog::setNetworkMembers(const std::vector &members) +{ + m_membersList->clear(); + for (const auto &human : members) + { + auto *item = new QListWidgetItem(human.email, m_membersList); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(Qt::Unchecked); + item->setData(Qt::UserRole, human.email); + } +} diff --git a/src/createstreamdialog.h b/src/createstreamdialog.h new file mode 100644 index 0000000..2dd34c5 --- /dev/null +++ b/src/createstreamdialog.h @@ -0,0 +1,34 @@ +#ifndef CREATESTREAMDIALOG_H +#define CREATESTREAMDIALOG_H + +#include "models.h" +#include + +QT_BEGIN_NAMESPACE +class QLineEdit; +class QComboBox; +class QLabel; +class QListWidget; +QT_END_NAMESPACE + +class CreateStreamDialog : public QDialog +{ + Q_OBJECT + +public: + explicit CreateStreamDialog(QWidget *parent = nullptr); + + /// Call before showing to populate the member checklist. + void setNetworkMembers(const std::vector &members); + +signals: + void streamRequested(const QString &name, const QString &visibility, const QStringList &memberEmails); + +private: + QLineEdit *m_nameEdit; + QComboBox *m_visibilityCombo; + QLabel *m_membersLabel; + QListWidget *m_membersList; +}; + +#endif // CREATESTREAMDIALOG_H diff --git a/src/memberpickerdialog.cpp b/src/memberpickerdialog.cpp new file mode 100644 index 0000000..6401098 --- /dev/null +++ b/src/memberpickerdialog.cpp @@ -0,0 +1,55 @@ +#include "memberpickerdialog.h" +#include +#include +#include +#include +#include + +MemberPickerDialog::MemberPickerDialog(const QString &title, QWidget *parent) + : QDialog(parent) +{ + setWindowTitle(title); + setMinimumWidth(300); + + auto *layout = new QVBoxLayout(this); + + m_list = new QListWidget(this); + layout->addWidget(m_list); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + layout->addWidget(buttons); + + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +void MemberPickerDialog::setMembers(const std::vector &members, const std::vector &exclude) +{ + m_list->clear(); + + QSet excludeSet; + for (const auto &e : exclude) + excludeSet.insert(e); + + for (const auto &human : members) + { + if (excludeSet.contains(human.email)) + continue; + auto *item = new QListWidgetItem(human.email, m_list); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(Qt::Unchecked); + item->setData(Qt::UserRole, human.email); + } +} + +QStringList MemberPickerDialog::selectedEmails() const +{ + QStringList result; + for (int i = 0; i < m_list->count(); ++i) + { + auto *item = m_list->item(i); + if (item->checkState() == Qt::Checked) + result.append(item->data(Qt::UserRole).toString()); + } + return result; +} diff --git a/src/memberpickerdialog.h b/src/memberpickerdialog.h new file mode 100644 index 0000000..ee06bcb --- /dev/null +++ b/src/memberpickerdialog.h @@ -0,0 +1,29 @@ +#ifndef MEMBERPICKERDIALOG_H +#define MEMBERPICKERDIALOG_H + +#include "models.h" +#include + +QT_BEGIN_NAMESPACE +class QListWidget; +QT_END_NAMESPACE + +/// A simple dialog that shows a checklist of humans and returns the selected emails. +class MemberPickerDialog : public QDialog +{ + Q_OBJECT + +public: + explicit MemberPickerDialog(const QString &title, QWidget *parent = nullptr); + + /// Populate the checklist. Members whose emails appear in `exclude` are hidden. + void setMembers(const std::vector &members, const std::vector &exclude = {}); + + /// Returns the emails of all checked items. + QStringList selectedEmails() const; + +private: + QListWidget *m_list; +}; + +#endif // MEMBERPICKERDIALOG_H diff --git a/src/networkstreammodel.cpp b/src/networkstreammodel.cpp index 087744c..7a43291 100644 --- a/src/networkstreammodel.cpp +++ b/src/networkstreammodel.cpp @@ -12,6 +12,26 @@ NetworkStreamModel::NetworkStreamModel(Store *store, QObject *parent) : QAbstrac beginResetModel(); endResetModel(); }); + + // Granular update: repaint a single stream row when its properties change (e.g. unseen count) + connect(m_store, &Store::streamUpdated, this, [this](const QString &streamId) { + const auto &networks = m_store->networks(); + for (int ni = 0; ni < networks.size(); ++ni) + { + const auto &streams = networks.at(ni).streams; + for (int si = 0; si < static_cast(streams.size()); ++si) + { + if (streams[si].id == streamId) + { + // Build indices for both columns of this stream row + QModelIndex topLeft = index(si, 0, index(ni, 0)); + QModelIndex bottomRight = index(si, 1, index(ni, 0)); + emit dataChanged(topLeft, bottomRight, {Qt::DisplayRole, Qt::ForegroundRole}); + return; + } + } + } + }); } QModelIndex NetworkStreamModel::index(int row, int column, const QModelIndex &parent) const diff --git a/src/particlelistmodel.cpp b/src/particlelistmodel.cpp index 1aee198..b408d71 100644 --- a/src/particlelistmodel.cpp +++ b/src/particlelistmodel.cpp @@ -15,6 +15,22 @@ ParticleListModel::ParticleListModel(Store *store, QObject *parent) endResetModel(); } }); + + // Granular update: repaint a single row when a particle property changes + connect(m_store, &Store::particleUpdated, this, [this](const QString &streamId, const QString &particleId) { + if (streamId != m_currentStreamId) + return; + const QList &particles = m_store->particlesForStream(m_currentStreamId); + for (int i = 0; i < particles.size(); ++i) + { + if (particles.at(i).id == particleId) + { + QModelIndex idx = index(i, 0); + emit dataChanged(idx, idx, {Qt::DisplayRole, Qt::ForegroundRole}); + return; + } + } + }); } int ParticleListModel::rowCount(const QModelIndex &parent) const diff --git a/src/store.cpp b/src/store.cpp index 476a44a..1150597 100644 --- a/src/store.cpp +++ b/src/store.cpp @@ -10,35 +10,14 @@ // Operation // ──────────────────────────────────────────────────────────────── -Operation::Operation(QObject *parent) +Operation::Operation(QNetworkReply *reply, QObject *parent) : QObject(parent) { -} + connect(this, &Operation::finished, this, &QObject::deleteLater); -// ──────────────────────────────────────────────────────────────── -// Store — construction -// ──────────────────────────────────────────────────────────────── + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); -Store::Store(QObject *parent) - : QObject(parent) -{ -} - -// ──────────────────────────────────────────────────────────────── -// Operation helpers -// ──────────────────────────────────────────────────────────────── - -Operation *Store::makeOperation() -{ - auto *op = new Operation(this); - connect(op, &Operation::finished, op, &QObject::deleteLater); - return op; -} - -void Store::wireReply(QNetworkReply *reply, Operation *op, - std::function successHandler) -{ - connect(reply, &QNetworkReply::finished, this, [reply, op, successHandler = std::move(successHandler)]() { if (reply->error() != QNetworkReply::NoError) { QByteArray body = reply->readAll(); @@ -48,23 +27,26 @@ void Store::wireReply(QNetworkReply *reply, Operation *op, { message = reply->errorString(); } - emit op->failed(message); - emit op->finished(); + qWarning() << message; + emit failed(message); + emit finished(); return; } QByteArray body = reply->readAll(); QJsonDocument doc = body.isEmpty() ? QJsonDocument() : QJsonDocument::fromJson(body); - successHandler(doc); + emit success(doc); + emit finished(); }); } -void Store::wireReply(QNetworkReply *reply, Operation *op) +// ──────────────────────────────────────────────────────────────── +// Store — construction +// ──────────────────────────────────────────────────────────────── + +Store::Store(QObject *parent) + : QObject(parent) { - wireReply(reply, op, [op](const QJsonDocument &) { - emit op->success(); - emit op->finished(); - }); } // ──────────────────────────────────────────────────────────────── @@ -381,23 +363,18 @@ const Particle *Store::particleById(const QString &id) const Operation *Store::loadStartupData() { - Operation *op = makeOperation(); QNetworkReply *reply = NetworkManager::instance().get("/startup"); - - wireReply(reply, op, [this, op](const QJsonDocument &doc) { + auto *op = new Operation(reply, this); + connect(op, &Operation::success, this, [this](const QJsonDocument &doc) { if (!doc.isObject()) { - emit op->failed("Invalid startup data"); - emit op->finished(); + qWarning() << "Invalid startup data"; return; } populateFromStartupData(doc.object()); emit startupDataLoaded(); emit networksChanged(); - emit op->success(); - emit op->finished(); }); - return op; } @@ -407,51 +384,41 @@ Operation *Store::loadStartupData() Operation *Store::createNetwork(const QString &name) { - Operation *op = makeOperation(); - QJsonObject body; body["name"] = name; QNetworkReply *reply = NetworkManager::instance().post("/networks", QJsonDocument(body)); - - wireReply(reply, op, [this, op](const QJsonDocument &doc) { + auto *op = new Operation(reply, this); + connect(op, &Operation::success, this, [this](const QJsonDocument &doc) { if (doc.isObject()) { upsertNetwork(parseNetwork(doc.object())); emit networksChanged(); } - emit op->success(); - emit op->finished(); }); - return op; } Operation *Store::fetchNetworks() { - Operation *op = makeOperation(); QNetworkReply *reply = NetworkManager::instance().get("/networks"); - - wireReply(reply, op, [this, op](const QJsonDocument &doc) { + auto *op = new Operation(reply, this); + connect(op, &Operation::success, this, [this](const QJsonDocument &doc) { const QJsonArray arr = doc.array(); for (const QJsonValue &v : arr) { upsertNetwork(parseNetwork(v.toObject())); } emit networksChanged(); - emit op->success(); - emit op->finished(); }); - return op; } Operation *Store::fetchNetwork(const QString &networkId) { - Operation *op = makeOperation(); QNetworkReply *reply = NetworkManager::instance().get(QString("/networks/%1").arg(networkId)); - - wireReply(reply, op, [this, op, networkId](const QJsonDocument &doc) { + auto *op = new Operation(reply, this); + connect(op, &Operation::success, this, [this, networkId](const QJsonDocument &doc) { QJsonObject obj = doc.object(); Network network = parseNetwork(obj); @@ -464,17 +431,12 @@ Operation *Store::fetchNetwork(const QString &networkId) } upsertNetwork(network); emit networksChanged(); - emit op->success(); - emit op->finished(); }); - return op; } Operation *Store::addNetworkMembers(const QString &networkId, const QStringList &emails) { - Operation *op = makeOperation(); - QJsonObject body; QJsonArray arr; for (const QString &e : emails) @@ -485,30 +447,24 @@ Operation *Store::addNetworkMembers(const QString &networkId, const QStringList QNetworkReply *reply = NetworkManager::instance().post( QString("/networks/%1/members").arg(networkId), QJsonDocument(body)); - wireReply(reply, op); - return op; + return new Operation(reply, this); } Operation *Store::removeNetworkMember(const QString &networkId, const QString &email) { - Operation *op = makeOperation(); QNetworkReply *reply = NetworkManager::instance().del( QString("/networks/%1/members/%2").arg(networkId, email)); - wireReply(reply, op); - return op; + return new Operation(reply, this); } Operation *Store::setStreamCapacity(const QString &networkId, int capacity) { - Operation *op = makeOperation(); - QJsonObject body; body["capacity"] = capacity; QNetworkReply *reply = NetworkManager::instance().put( QString("/networks/%1/capacity").arg(networkId), QJsonDocument(body)); - wireReply(reply, op); - return op; + return new Operation(reply, this); } // ──────────────────────────────────────────────────────────────── @@ -519,8 +475,6 @@ Operation *Store::createStream(const QString &networkId, const QString &name, const QString &description, const QString &visibility, const QStringList &members) { - Operation *op = makeOperation(); - QJsonObject body; body["name"] = name; body["description"] = description; @@ -534,27 +488,23 @@ Operation *Store::createStream(const QString &networkId, const QString &name, QNetworkReply *reply = NetworkManager::instance().post( QString("/networks/%1/streams").arg(networkId), QJsonDocument(body)); - - wireReply(reply, op, [this, op, networkId](const QJsonDocument &doc) { + auto *op = new Operation(reply, this); + connect(op, &Operation::success, this, [this, networkId](const QJsonDocument &doc) { if (doc.isObject()) { Stream stream = parseStream(doc.object()); upsertStream(networkId, stream); emit streamsChanged(networkId); } - emit op->success(); - emit op->finished(); }); - return op; } Operation *Store::fetchStream(const QString &streamId) { - Operation *op = makeOperation(); QNetworkReply *reply = NetworkManager::instance().get(QString("/streams/%1").arg(streamId)); - - wireReply(reply, op, [this, op, streamId](const QJsonDocument &doc) { + auto *op = new Operation(reply, this); + connect(op, &Operation::success, this, [this, streamId](const QJsonDocument &doc) { QJsonObject obj = doc.object(); Stream stream = parseStream(obj); @@ -574,18 +524,12 @@ Operation *Store::fetchStream(const QString &streamId) } m_particles[streamId] = particleList; emit particlesChanged(streamId); - - emit op->success(); - emit op->finished(); }); - return op; } Operation *Store::updateStream(const QString &streamId, const QString &name, const QString &description) { - Operation *op = makeOperation(); - QJsonObject body; if (!name.isEmpty()) { @@ -598,8 +542,8 @@ Operation *Store::updateStream(const QString &streamId, const QString &name, con QNetworkReply *reply = NetworkManager::instance().patch( QString("/streams/%1").arg(streamId), QJsonDocument(body)); - - wireReply(reply, op, [this, op, streamId](const QJsonDocument &doc) { + auto *op = new Operation(reply, this); + connect(op, &Operation::success, this, [this, streamId](const QJsonDocument &doc) { if (doc.isObject()) { Stream stream = parseStream(doc.object()); @@ -610,20 +554,16 @@ Operation *Store::updateStream(const QString &streamId, const QString &name, con emit streamsChanged(networkId); } } - emit op->success(); - emit op->finished(); }); - return op; } Operation *Store::openStream(const QString &streamId) { - Operation *op = makeOperation(); QNetworkReply *reply = NetworkManager::instance().post( QString("/streams/%1/open").arg(streamId), QJsonDocument()); - - wireReply(reply, op, [this, op, streamId](const QJsonDocument &) { + auto *op = new Operation(reply, this); + connect(op, &Operation::success, this, [this, streamId](const QJsonDocument &) { QString networkId = m_streamToNetwork.value(streamId); if (m_networkIndex.contains(networkId)) { @@ -638,20 +578,16 @@ Operation *Store::openStream(const QString &streamId) } emit streamsChanged(networkId); } - emit op->success(); - emit op->finished(); }); - return op; } Operation *Store::closeStream(const QString &streamId) { - Operation *op = makeOperation(); QNetworkReply *reply = NetworkManager::instance().post( QString("/streams/%1/close").arg(streamId), QJsonDocument()); - - wireReply(reply, op, [this, op, streamId](const QJsonDocument &) { + auto *op = new Operation(reply, this); + connect(op, &Operation::success, this, [this, streamId](const QJsonDocument &) { QString networkId = m_streamToNetwork.value(streamId); if (m_networkIndex.contains(networkId)) { @@ -666,17 +602,12 @@ Operation *Store::closeStream(const QString &streamId) } emit streamsChanged(networkId); } - emit op->success(); - emit op->finished(); }); - return op; } Operation *Store::addStreamMembers(const QString &streamId, const QStringList &emails) { - Operation *op = makeOperation(); - QJsonObject body; QJsonArray arr; for (const QString &e : emails) @@ -687,14 +618,11 @@ Operation *Store::addStreamMembers(const QString &streamId, const QStringList &e QNetworkReply *reply = NetworkManager::instance().post( QString("/streams/%1/members").arg(streamId), QJsonDocument(body)); - wireReply(reply, op); - return op; + return new Operation(reply, this); } Operation *Store::removeStreamMembers(const QString &streamId, const QStringList &emails) { - Operation *op = makeOperation(); - QJsonObject body; QJsonArray arr; for (const QString &e : emails) @@ -703,8 +631,7 @@ Operation *Store::removeStreamMembers(const QString &streamId, const QStringList QNetworkReply *reply = NetworkManager::instance().del( QString("/streams/%1/members").arg(streamId), QJsonDocument(body)); - wireReply(reply, op); - return op; + return new Operation(reply, this); } // ──────────────────────────────────────────────────────────────── @@ -714,8 +641,6 @@ Operation *Store::removeStreamMembers(const QString &streamId, const QStringList Operation *Store::createParticle(const QString &streamId, const QString &type, const QMap &data) { - Operation *op = makeOperation(); - QJsonObject body; body["type"] = type; QJsonObject dataObj; @@ -725,27 +650,23 @@ Operation *Store::createParticle(const QString &streamId, const QString &type, QNetworkReply *reply = NetworkManager::instance().post( QString("/streams/%1/particles").arg(streamId), QJsonDocument(body)); - - wireReply(reply, op, [this, op, streamId](const QJsonDocument &doc) { + auto *op = new Operation(reply, this); + connect(op, &Operation::success, this, [this, streamId](const QJsonDocument &doc) { if (doc.isObject()) { Particle particle = parseParticle(doc.object()); upsertParticle(streamId, particle); emit particlesChanged(streamId); } - emit op->success(); - emit op->finished(); }); - return op; } Operation *Store::fetchParticle(const QString &particleId) { - Operation *op = makeOperation(); QNetworkReply *reply = NetworkManager::instance().get(QString("/particles/%1").arg(particleId)); - - wireReply(reply, op, [this, op, particleId](const QJsonDocument &doc) { + auto *op = new Operation(reply, this); + connect(op, &Operation::success, this, [this, particleId](const QJsonDocument &doc) { if (doc.isObject()) { Particle particle = parseParticle(doc.object()); @@ -756,17 +677,12 @@ Operation *Store::fetchParticle(const QString &particleId) emit particlesChanged(streamId); } } - emit op->success(); - emit op->finished(); }); - return op; } Operation *Store::updateParticle(const QString &particleId, const QMap &data) { - Operation *op = makeOperation(); - QJsonObject body; QJsonObject dataObj; for (auto it = data.constBegin(); it != data.constEnd(); ++it) @@ -775,8 +691,8 @@ Operation *Store::updateParticle(const QString &particleId, const QMapsuccess(); - emit op->finished(); }); - return op; } Operation *Store::deleteParticle(const QString &particleId) { - Operation *op = makeOperation(); QNetworkReply *reply = NetworkManager::instance().del(QString("/particles/%1").arg(particleId)); - - wireReply(reply, op, [this, op, particleId](const QJsonDocument &) { + auto *op = new Operation(reply, this); + connect(op, &Operation::success, this, [this, particleId](const QJsonDocument &) { QString streamId = findStreamForParticle(particleId); removeParticleFromState(particleId); if (!streamId.isEmpty()) emit particlesChanged(streamId); - emit op->success(); - emit op->finished(); }); - return op; } @@ -818,20 +727,36 @@ Operation *Store::markParticleSeen(const QString &particleId) { for (int i = 0; i < it.value().size(); ++i) { - if (it.value()[i].id == particleId) + if (it.value()[i].id == particleId && !it.value()[i].seen) { it.value()[i].seen = true; - emit particlesChanged(it.key()); + QString streamId = it.key(); + + // Decrement the parent stream's unseen count + QString networkId = m_streamToNetwork.value(streamId); + if (m_networkIndex.contains(networkId)) + { + auto &streams = m_networks[m_networkIndex[networkId]].streams; + for (auto &s : streams) + { + if (s.id == streamId && s.unseenCount > 0) + { + s.unseenCount--; + break; + } + } + } + + emit particleUpdated(streamId, particleId); + emit streamUpdated(streamId); break; } } } - Operation *op = makeOperation(); QNetworkReply *reply = NetworkManager::instance().post( QString("/particles/%1/seen").arg(particleId), QJsonDocument()); - wireReply(reply, op); - return op; + return new Operation(reply, this); } Operation *Store::markParticlesSeen(const QStringList &particleIds) @@ -856,8 +781,6 @@ Operation *Store::markParticlesSeen(const QStringList &particleIds) for (const QString &streamId : affectedStreams) emit particlesChanged(streamId); - Operation *op = makeOperation(); - QJsonObject body; QJsonArray ids; for (const QString &id : particleIds) @@ -865,15 +788,12 @@ Operation *Store::markParticlesSeen(const QStringList &particleIds) body["particle_ids"] = ids; QNetworkReply *reply = NetworkManager::instance().post("/particles/seen", QJsonDocument(body)); - wireReply(reply, op); - return op; + return new Operation(reply, this); } Operation *Store::ackParticle(const QString &particleId) { - Operation *op = makeOperation(); QNetworkReply *reply = NetworkManager::instance().post( QString("/particles/%1/ack").arg(particleId), QJsonDocument()); - wireReply(reply, op); - return op; + return new Operation(reply, this); } diff --git a/src/store.h b/src/store.h index 4b40109..7c74128 100644 --- a/src/store.h +++ b/src/store.h @@ -6,21 +6,22 @@ #include QT_BEGIN_NAMESPACE +class QJsonDocument; class QNetworkReply; QT_END_NAMESPACE /// Represents an in-flight API operation. -/// Callers connect to success/failed for one-off feedback. +/// Wraps a QNetworkReply: owns cleanup, parses JSON, and emits signals. /// Automatically deletes itself after finished() fires. class Operation : public QObject { Q_OBJECT public: - explicit Operation(QObject *parent = nullptr); + explicit Operation(QNetworkReply *reply, QObject *parent = nullptr); signals: - void success(); + void success(const QJsonDocument &response); void failed(const QString &message); void finished(); }; @@ -91,6 +92,10 @@ signals: void streamsChanged(const QString &networkId); void particlesChanged(const QString &streamId); + // Granular signals for in-place property updates (no structural change) + void particleUpdated(const QString &streamId, const QString &particleId); + void streamUpdated(const QString &streamId); + void startupDataLoaded(); void errorOccurred(const QString &message); @@ -120,20 +125,6 @@ private: /// Returns the streamId that contains the given particle, or empty QString. QString findStreamForParticle(const QString &particleId) const; - // ── Operation helpers ── - - /// Creates an Operation parented to this Store, wired to self-destruct after finished(). - Operation *makeOperation(); - - /// Connects reply->finished to a handler. On error, emits op->failed + op->finished. - /// On success, calls successHandler with the parsed QJsonDocument. - /// successHandler is responsible for emitting op->success() and op->finished(). - void wireReply(QNetworkReply *reply, Operation *op, - std::function successHandler); - - /// Simplified wireReply for operations that don't need to parse a response body - /// (e.g. 204 No Content). Emits op->success() and op->finished() automatically. - void wireReply(QNetworkReply *reply, Operation *op); }; #endif // STORE_H