feat: create stream and add members to stream

This commit is contained in:
talksik
2026-02-06 14:28:54 -08:00
parent dd655c9679
commit e204c638c7
11 changed files with 385 additions and 168 deletions
+4
View File
@@ -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
+66 -1
View File
@@ -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 <QApplication>
#include <QJsonDocument>
#include <QHeaderView>
#include <QItemSelectionModel>
#include <QLabel>
@@ -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);
}
+3
View File
@@ -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;
+80
View File
@@ -0,0 +1,80 @@
#include "createstreamdialog.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QVBoxLayout>
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<Human> &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);
}
}
+34
View File
@@ -0,0 +1,34 @@
#ifndef CREATESTREAMDIALOG_H
#define CREATESTREAMDIALOG_H
#include "models.h"
#include <QDialog>
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<Human> &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
+55
View File
@@ -0,0 +1,55 @@
#include "memberpickerdialog.h"
#include <QDialogButtonBox>
#include <QLabel>
#include <QListWidget>
#include <QSet>
#include <QVBoxLayout>
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<Human> &members, const std::vector<QString> &exclude)
{
m_list->clear();
QSet<QString> 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;
}
+29
View File
@@ -0,0 +1,29 @@
#ifndef MEMBERPICKERDIALOG_H
#define MEMBERPICKERDIALOG_H
#include "models.h"
#include <QDialog>
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<Human> &members, const std::vector<QString> &exclude = {});
/// Returns the emails of all checked items.
QStringList selectedEmails() const;
private:
QListWidget *m_list;
};
#endif // MEMBERPICKERDIALOG_H
+20
View File
@@ -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<int>(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
+16
View File
@@ -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<Particle> &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
+70 -150
View File
@@ -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<void(const QJsonDocument &)> 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<QString, QVariant> &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<QString, QVariant> &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 QMap<QString,
QNetworkReply *reply = NetworkManager::instance().patch(
QString("/particles/%1").arg(particleId), QJsonDocument(body));
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());
@@ -787,27 +703,20 @@ Operation *Store::updateParticle(const QString &particleId, const QMap<QString,
emit particlesChanged(streamId);
}
}
emit op->success();
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);
}
+8 -17
View File
@@ -6,21 +6,22 @@
#include <QMap>
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<void(const QJsonDocument &)> 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