diff --git a/CMakeLists.txt b/CMakeLists.txt index 3242285..ffc361c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,8 +29,9 @@ qt_add_executable(llink src/authdialog.ui src/authdialog.cpp src/settingsdialog.h src/settingsdialog.cpp src/settingsdialog.ui - src/data.h - src/data.cpp + src/models.h + src/store.h + src/store.cpp src/networkstreammodel.h src/networkstreammodel.cpp src/particlelistmodel.h diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index fa080e0..d42b216 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -19,7 +19,7 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), m_trayIcon(nullptr), m_authManager(new AuthManager(this)), m_settingsDialog(nullptr), - m_authDialog(nullptr), m_data(nullptr), m_networkStreamModel(nullptr), m_particleListModel(nullptr), + m_authDialog(nullptr), m_store(nullptr), m_networkStreamModel(nullptr), m_particleListModel(nullptr), m_selectedParticleRow(-1), m_isDragging(false) { m_authDialog = new AuthDialog(this); @@ -95,6 +95,12 @@ MainWindow::MainWindow(QWidget *parent) connect(m_authManager, &AuthManager::signedIn, m_authDialog, &AuthDialog::reset); connect(m_authManager, &AuthManager::signedOut, m_authDialog, &AuthDialog::reset); connect(m_authManager, &AuthManager::signedOut, m_authDialog, &AuthDialog::show); + connect(m_authManager, &AuthManager::signedIn, this, [this]() { + m_store->loadStartupData(); + }); + connect(m_store, &Store::startupDataLoaded, this, [this]() { + ui->topLevelParticlesView->expandAll(); + }); m_authManager->tryRestoreSession(); @@ -143,11 +149,11 @@ void MainWindow::on_settingsButton_clicked() void MainWindow::setupModels() { - // Create data with fake data - m_data = new Data(this); + // Create store (data loaded after sign-in via loadStartupData) + m_store = new Store(this); // Create and set tree model - m_networkStreamModel = new NetworkStreamModel(m_data, this); + m_networkStreamModel = new NetworkStreamModel(m_store, this); ui->topLevelParticlesView->setModel(m_networkStreamModel); ui->topLevelParticlesView->setIndentation(5); ui->topLevelParticlesView->setHeaderHidden(true); @@ -160,7 +166,7 @@ void MainWindow::setupModels() ui->topLevelParticlesView->setColumnWidth(1, 40); // Create and set list model - m_particleListModel = new ParticleListModel(m_data, this); + m_particleListModel = new ParticleListModel(m_store, this); ui->particlesView->setModel(m_particleListModel); // Configure particle list view to use ellipses for long text @@ -175,8 +181,6 @@ void MainWindow::setupModels() connect(ui->particlesView->selectionModel(), &QItemSelectionModel::selectionChanged, this, &MainWindow::onParticleSelectionChanged); - // setup human presence list - ui->presenceList->addItems(QStringList{"agnes", "katie", "stephen", "viet", "anthony"}); } void MainWindow::onTreeSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected) @@ -186,6 +190,7 @@ void MainWindow::onTreeSelectionChanged(const QItemSelection &selected, const QI if (selected.indexes().isEmpty()) { m_particleListModel->setStreamId(QString()); + ui->membersList->clear(); return; } @@ -202,6 +207,17 @@ void MainWindow::onTreeSelectionChanged(const QItemSelection &selected, const QI QString networkId = m_networkStreamModel->networkIdFromIndex(parentIndex); m_selectedNetworkName = networkId; + // Show stream members + ui->membersList->clear(); + const Stream *stream = m_store->streamById(streamId); + if (stream) + { + for (const QString &email : stream->memberEmails) + { + ui->membersList->addItem(email.split("@")[0]); + } + } + // Auto-select first unseen particle (or first particle if all seen) int unseenRow = m_particleListModel->firstUnseenRow(); if (unseenRow >= 0) @@ -220,6 +236,7 @@ void MainWindow::onTreeSelectionChanged(const QItemSelection &selected, const QI m_particleListModel->setStreamId(QString()); m_selectedStreamId.clear(); m_selectedParticleRow = -1; + ui->membersList->clear(); QString networkId = m_networkStreamModel->networkIdFromIndex(index); m_selectedNetworkName = networkId; @@ -247,9 +264,17 @@ void MainWindow::onParticleSelectionChanged(const QItemSelection &selected, cons const Particle *particle = m_particleListModel->particleAtRow(index.row()); if (particle) { - // ui->label->setText(particle->summary); - ui->label_2->setText(particle->createdBy); + ui->label_2->setText(particle->createdByEmail); m_selectedParticleId = particle->id; + + if (particle->type == "text") + { + ui->label->setText(particle->data.value("content").toString()); + } + else + { + ui->label->setText(""); + } } updateStatusBar(); @@ -262,41 +287,30 @@ void MainWindow::updateStatusBar() // Add network if selected if (!m_selectedNetworkName.isEmpty()) { - for (const auto &network : m_data->networks()) + const Network *network = m_store->networkById(m_selectedNetworkName); + if (network) { - if (network.id == m_selectedNetworkName) - { - pathComponents << network.name; - break; - } + pathComponents << network->name; } } // Add stream if selected - if (!m_selectedStreamId.isEmpty() && !m_selectedNetworkName.isEmpty()) + if (!m_selectedStreamId.isEmpty()) { - const QList &streams = m_data->streamsForNetwork(m_selectedNetworkName); - for (const auto &stream : streams) + const Stream *stream = m_store->streamById(m_selectedStreamId); + if (stream) { - if (stream.id == m_selectedStreamId) - { - pathComponents << stream.name; - break; - } + pathComponents << stream->name; } } // Add particle if selected - if (!m_selectedParticleId.isEmpty() && !m_selectedStreamId.isEmpty()) + if (!m_selectedParticleId.isEmpty()) { - const QList &particles = m_data->particlesForStream(m_selectedStreamId); - for (const auto &particle : particles) + const Particle *particle = m_store->particleById(m_selectedParticleId); + if (particle) { - if (particle.id == m_selectedParticleId) - { - pathComponents << particle.id; - break; - } + pathComponents << particle->id; } } @@ -351,9 +365,13 @@ void MainWindow::updatePingIndicator() int variation = (QRandomGenerator::global()->bounded(21)) - 10; // -10 to +10 int currentPing = basePing + variation; if (currentPing < 15) + { currentPing = 15; + } if (currentPing > 35) + { currentPing = 35; + } m_pingLabel->setText("connected " + QString::number(currentPing) + "ms"); } diff --git a/src/MainWindow.h b/src/MainWindow.h index aa67626..122cddc 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -5,7 +5,7 @@ #ifndef LLINK_MAINWINDOW_H #define LLINK_MAINWINDOW_H -#include "data.h" +#include "store.h" #include "networkstreammodel.h" #include "particlelistmodel.h" #include @@ -60,7 +60,7 @@ private: SettingsDialog *m_settingsDialog; // Data and models - Data *m_data; + Store *m_store; NetworkStreamModel *m_networkStreamModel; ParticleListModel *m_particleListModel; diff --git a/src/data.cpp b/src/data.cpp deleted file mode 100644 index d0103ab..0000000 --- a/src/data.cpp +++ /dev/null @@ -1,160 +0,0 @@ -#include "data.h" - -Data::Data(QObject *parent) : QObject(parent) -{ - initializeFakeData(); -} - -void Data::initializeFakeData() -{ - // Network 1: Private - Network net1; - net1.id = "net-private"; - net1.name = "Private"; - net1.openStreamCount = 2; - net1.openStreamCapacity = 5; - net1.countHumans = 1; - m_networks.append(net1); - - // Stream: Personal Notes - Stream stream1; - stream1.id = "stream-notes"; - stream1.name = "Personal Notes"; - stream1.status = Stream::OPEN; - stream1.unseenCount = 3; - m_streams[net1.id].append(stream1); - - // Particles for Personal Notes - m_particles[stream1.id].append({"p1", "text", "viet@flowy.com", "Meeting notes from standup", true}); - m_particles[stream1.id].append({"p2", "link", "viet@flowy.com", "API documentation for new endpoint", true}); - m_particles[stream1.id].append({"p3", "image", "stephen@flowy.com", "Screenshot of bug in login flow", false}); - m_particles[stream1.id].append({"p4", "text", "stephen@flowy.com", "Quick thought about architecture refactor", false}); - m_particles[stream1.id].append({"p5", "link", "anthony@flowy.com", "Design inspiration board from Dribbble", false}); - - // Stream: Quick Captures - Stream stream2; - stream2.id = "stream-captures"; - stream2.name = "Quick Captures"; - stream2.status = Stream::OPEN; - stream2.unseenCount = 0; - m_streams[net1.id].append(stream2); - - // Particles for Quick Captures - m_particles[stream2.id].append({"p6", "text", "alice@flowy.com", "Remember to follow up on client feedback", true}); - m_particles[stream2.id].append({"p7", "image", "bob@flowy.com", "Mockup for new feature dashboard", true}); - - // Network 2: Flowy Labs, Inc - Network net2; - net2.id = "net-flowy"; - net2.name = "Flowy Labs, Inc"; - net2.openStreamCount = 3; - net2.openStreamCapacity = 10; - net2.countHumans = 12; - m_networks.append(net2); - - // Stream: Engineering - Stream stream3; - stream3.id = "stream-eng"; - stream3.name = "wild-shape"; - stream3.status = Stream::OPEN; - stream3.unseenCount = 10; - m_streams[net2.id].append(stream3); - - // Particles for Engineering - m_particles[stream3.id].append({"p8", "text", "charlie@flowy.com", "hey team, anyone seeing issues with the auth service?", true}); - m_particles[stream3.id].append({"p9", "text", "dave@flowy.com", "yeah, getting 401s on the staging server", true}); - m_particles[stream3.id].append({"p10", "text", "eve@flowy.com", "i think it's the JWT expiration. checking now", true}); - m_particles[stream3.id].append({"p11", "link", "eve@flowy.com", "https://github.com/flowy/api/pull/234", true}); - m_particles[stream3.id].append({"p12", "text", "eve@flowy.com", "^ PR ready for review. fixed the token refresh logic", false}); - m_particles[stream3.id].append({"p13", "text", "frank@flowy.com", "looking at it now", false}); - m_particles[stream3.id].append({"p14", "text", "grace@flowy.com", "nice catch! left a few comments about error handling", false}); - m_particles[stream3.id].append({"p15", "text", "eve@flowy.com", "updated, should be good to merge", false}); - m_particles[stream3.id].append({"p16", "text", "charlie@flowy.com", "merged! deploying to staging now", false}); - m_particles[stream3.id].append({"p17", "text", "henry@flowy.com", "btw, we should probably add monitoring for token expiration", false}); - m_particles[stream3.id].append({"p18", "text", "dave@flowy.com", "good idea, i can set that up after lunch", false}); - m_particles[stream3.id].append({"p19", "text", "ivy@flowy.com", "also, can someone review my database migration PR?", false}); - m_particles[stream3.id].append({"p20", "image", "ivy@flowy.com", "here's the schema changes", false}); - m_particles[stream3.id].append({"p21", "text", "jack@flowy.com", "looks good to me. just run it by charlie for final approval", false}); - - // Stream: Design - Stream stream4; - stream4.id = "stream-design"; - stream4.name = "arch-rival"; - stream4.status = Stream::OPEN; - stream4.unseenCount = 4; - m_streams[net2.id].append(stream4); - - // Particles for Design - m_particles[stream4.id].append({"p22", "text", "karen@flowy.com", "starting on the mobile dashboard redesign", true}); - m_particles[stream4.id].append({"p23", "image", "karen@flowy.com", "initial mockup - thoughts?", true}); - m_particles[stream4.id].append({"p24", "text", "leo@flowy.com", "love the direction! maybe try a darker header?", true}); - m_particles[stream4.id].append({"p25", "text", "maya@flowy.com", "agreed, and can we use the new color palette from the design system?", true}); - m_particles[stream4.id].append({"p26", "link", "maya@flowy.com", "https://figma.com/design-system-v2", true}); - m_particles[stream4.id].append({"p27", "text", "karen@flowy.com", "oh perfect! i'll update it", true}); - m_particles[stream4.id].append({"p28", "text", "nathan@flowy.com", "hey, should the settings page follow the same style?", false}); - m_particles[stream4.id].append({"p29", "text", "leo@flowy.com", "yes definitely, keeping it consistent across all pages", false}); - m_particles[stream4.id].append({"p30", "image", "nathan@flowy.com", "cool, here's my wireframe draft", false}); - m_particles[stream4.id].append({"p31", "text", "maya@flowy.com", "looks great! can you add that to the figma file?", false}); - - // Stream: Archive - Stream stream5; - stream5.id = "stream-archive"; - stream5.name = "anthem-project"; - stream5.status = Stream::CLOSED; - stream5.unseenCount = 0; - m_streams[net2.id].append(stream5); - - // Particles for Archive - m_particles[stream5.id].append({"p32", "text", "oliver@flowy.com", "so we're officially deprecating the old API next week", true}); - m_particles[stream5.id].append({"p33", "text", "patricia@flowy.com", "yep, all clients have migrated to v2", true}); - m_particles[stream5.id].append({"p34", "text", "quinn@flowy.com", "great work everyone! this project was a long journey", true}); - m_particles[stream5.id].append({"p35", "link", "quinn@flowy.com", "https://docs.flowy.com/api/v1-migration-guide", true}); - m_particles[stream5.id].append({"p36", "text", "rachel@flowy.com", "should we keep the docs around for a bit?", true}); - m_particles[stream5.id].append({"p37", "text", "sam@flowy.com", "yeah i'd say 6 months, then archive everything", true}); - m_particles[stream5.id].append({"p38", "text", "tina@flowy.com", "agreed. i'll set a reminder", true}); - m_particles[stream5.id].append({"p39", "text", "oliver@flowy.com", "also documented all the lessons learned", true}); - m_particles[stream5.id].append({"p40", "text", "patricia@flowy.com", "nice! that'll be super helpful for future projects", true}); - m_particles[stream5.id].append({"p41", "text", "quinn@flowy.com", "alright, marking this stream as closed. good job team!", true}); -} - -const QList& Data::networks() const -{ - return m_networks; -} - -const QList& Data::streamsForNetwork(const QString &networkId) const -{ - static QList emptyList; - auto it = m_streams.find(networkId); - if (it == m_streams.end()) - { - return emptyList; - } - return it.value(); -} - -const QList& Data::particlesForStream(const QString &streamId) const -{ - static QList emptyList; - auto it = m_particles.find(streamId); - if (it == m_particles.end()) - { - return emptyList; - } - return it.value(); -} - -int Data::networkCount() const -{ - return m_networks.size(); -} - -int Data::streamCount(const QString &networkId) const -{ - return m_streams.value(networkId).size(); -} - -int Data::particleCount(const QString &streamId) const -{ - return m_particles.value(streamId).size(); -} diff --git a/src/data.h b/src/data.h deleted file mode 100644 index 0af4d56..0000000 --- a/src/data.h +++ /dev/null @@ -1,68 +0,0 @@ -#ifndef DATA_H -#define DATA_H - -#include -#include - -struct Network -{ - QString id; - QString name; - int openStreamCount; - int openStreamCapacity; - int countHumans; -}; - -struct Stream -{ - QString id; - QString name; - enum StreamStatus - { - OPEN, - CLOSED, - }; - StreamStatus status; - int unseenCount; -}; - -struct Particle -{ - QString id; - QString type; - QString createdBy; - QString summary; - bool seen; -}; - -class Data : public QObject -{ - Q_OBJECT - -public: - explicit Data(QObject *parent = nullptr); - - // Accessors for models - const QList& networks() const; - const QList& streamsForNetwork(const QString &networkId) const; - const QList& particlesForStream(const QString &streamId) const; - - int networkCount() const; - int streamCount(const QString &networkId) const; - int particleCount(const QString &streamId) const; - -signals: - void networksChanged(); - void streamsChanged(const QString &networkId); - void particlesChanged(const QString &streamId); - -private: - void initializeFakeData(); - QList m_networks; - // Network -> Streams - QMap> m_streams; - // Stream -> Particles - QMap> m_particles; -}; - -#endif // DATA_H diff --git a/src/mainwindow.ui b/src/mainwindow.ui index 3696af8..bce7b20 100644 --- a/src/mainwindow.ui +++ b/src/mainwindow.ui @@ -205,6 +205,19 @@ 0 + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + @@ -247,6 +260,19 @@ + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + @@ -273,19 +299,6 @@ - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - @@ -346,7 +359,7 @@ - + 0 diff --git a/src/models.h b/src/models.h new file mode 100644 index 0000000..818348f --- /dev/null +++ b/src/models.h @@ -0,0 +1,51 @@ +#ifndef MODELS_H +#define MODELS_H + +#include +#include +#include +#include +#include + +struct Human +{ + // Empty if not a registered human + QString id; + QString email; + QString emailPrefix; +}; + +struct Stream +{ + QString id; + QString name; + QString description; + bool isOpen; + std::vector memberEmails; + int unseenCount; +}; + +struct Particle +{ + QString id; + QMap data; + QString type; + QString createdByEmail; + bool seen; + std::vector ackedByEmails; + QDateTime updatedAt; + QDateTime createdAt; +}; + +struct Network +{ + QString id; + QString name; + Human admin; + std::vector members; + int openStreamCount; + int openStreamCapacity; + std::vector streams; +}; + +#endif //MODELS_H diff --git a/src/networkmanager.cpp b/src/networkmanager.cpp index 96116f5..7924ce3 100644 --- a/src/networkmanager.cpp +++ b/src/networkmanager.cpp @@ -67,6 +67,37 @@ QNetworkReply *NetworkManager::post(const QString &path, const QJsonDocument &do return m_qnam->post(request, doc.toJson()); } +QNetworkReply *NetworkManager::put(const QString &path, const QJsonDocument &doc) +{ + QNetworkRequest request = QNetworkRequest(getFullUrl(path)); + setAuthHeader(request); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + return m_qnam->put(request, doc.toJson()); +} + +QNetworkReply *NetworkManager::patch(const QString &path, const QJsonDocument &doc) +{ + QNetworkRequest request = QNetworkRequest(getFullUrl(path)); + setAuthHeader(request); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + return m_qnam->sendCustomRequest(request, "PATCH", doc.toJson()); +} + +QNetworkReply *NetworkManager::del(const QString &path) +{ + QNetworkRequest request = QNetworkRequest(getFullUrl(path)); + setAuthHeader(request); + return m_qnam->deleteResource(request); +} + +QNetworkReply *NetworkManager::del(const QString &path, const QJsonDocument &doc) +{ + QNetworkRequest request = QNetworkRequest(getFullUrl(path)); + setAuthHeader(request); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + return m_qnam->sendCustomRequest(request, "DELETE", doc.toJson()); +} + QString NetworkManager::sessionToken() const { return m_sessionToken; diff --git a/src/networkmanager.h b/src/networkmanager.h index fcf9aaf..40b622c 100644 --- a/src/networkmanager.h +++ b/src/networkmanager.h @@ -27,8 +27,10 @@ public slots: // Network manager discard replies internally, in cases where a consumer callsite prefers to not manage QNetworkReply. QNetworkReply *get(const QString &path); QNetworkReply *post(const QString &path, const QJsonDocument &data); - // QNetworkReply *put(QNetworkRequest &request); - // QNetworkReply *del(QNetworkRequest &request); + QNetworkReply *put(const QString &path, const QJsonDocument &data); + QNetworkReply *patch(const QString &path, const QJsonDocument &data); + QNetworkReply *del(const QString &path); + QNetworkReply *del(const QString &path, const QJsonDocument &data); /// @brief sets session token which will is injected into every request's headers void setSessionToken(const QString &token); diff --git a/src/networkstreammodel.cpp b/src/networkstreammodel.cpp index 19aa2b6..087744c 100644 --- a/src/networkstreammodel.cpp +++ b/src/networkstreammodel.cpp @@ -2,8 +2,16 @@ #include #include -NetworkStreamModel::NetworkStreamModel(Data *data, QObject *parent) : QAbstractItemModel(parent), m_data(data) +NetworkStreamModel::NetworkStreamModel(Store *store, QObject *parent) : QAbstractItemModel(parent), m_store(store) { + connect(m_store, &Store::networksChanged, this, [this]() { + beginResetModel(); + endResetModel(); + }); + connect(m_store, &Store::streamsChanged, this, [this]() { + beginResetModel(); + endResetModel(); + }); } QModelIndex NetworkStreamModel::index(int row, int column, const QModelIndex &parent) const @@ -59,7 +67,7 @@ int NetworkStreamModel::rowCount(const QModelIndex &parent) const if (!parent.isValid()) { // Root level - return number of networks - return m_data->networkCount(); + return m_store->networkCount(); } quintptr id = parent.internalId(); @@ -69,10 +77,9 @@ int NetworkStreamModel::rowCount(const QModelIndex &parent) const if (networkIndex == 0xFFFFFFFF) { int actualNetworkIndex = static_cast(id & 0xFFFFFFFF); - if (actualNetworkIndex >= 0 && actualNetworkIndex < m_data->networks().size()) + if (actualNetworkIndex >= 0 && actualNetworkIndex < m_store->networks().size()) { - const QString &networkId = m_data->networks().at(actualNetworkIndex).id; - return m_data->streamCount(networkId); + return static_cast(m_store->networks().at(actualNetworkIndex).streams.size()); } } @@ -102,9 +109,9 @@ QVariant NetworkStreamModel::data(const QModelIndex &index, int role) const if (role == Qt::DisplayRole && index.column() == 0) { int actualNetworkIndex = static_cast(id & 0xFFFFFFFF); - if (actualNetworkIndex >= 0 && actualNetworkIndex < m_data->networks().size()) + if (actualNetworkIndex >= 0 && actualNetworkIndex < m_store->networks().size()) { - return m_data->networks().at(actualNetworkIndex).name.toUpper(); + return m_store->networks().at(actualNetworkIndex).name.toUpper(); } } // Networks don't show unseen count in column 1 @@ -114,12 +121,11 @@ QVariant NetworkStreamModel::data(const QModelIndex &index, int role) const { // This is a stream item int streamIndex = static_cast(id & 0xFFFFFFFF); - if (networkIndex >= 0 && networkIndex < m_data->networks().size()) + if (networkIndex >= 0 && networkIndex < m_store->networks().size()) { - const QString &networkId = m_data->networks().at(networkIndex).id; - const QList &streams = m_data->streamsForNetwork(networkId); + const auto &streams = m_store->networks().at(networkIndex).streams; - if (streamIndex >= 0 && streamIndex < streams.size()) + if (streamIndex >= 0 && streamIndex < static_cast(streams.size())) { const Stream &stream = streams.at(streamIndex); @@ -174,12 +180,11 @@ QString NetworkStreamModel::streamIdFromIndex(const QModelIndex &index) const if (networkIndex != 0xFFFFFFFF) { int streamIndex = static_cast(id & 0xFFFFFFFF); - if (networkIndex >= 0 && networkIndex < m_data->networks().size()) + if (networkIndex >= 0 && networkIndex < m_store->networks().size()) { - const QString &networkId = m_data->networks().at(networkIndex).id; - const QList &streams = m_data->streamsForNetwork(networkId); + const auto &streams = m_store->networks().at(networkIndex).streams; - if (streamIndex >= 0 && streamIndex < streams.size()) + if (streamIndex >= 0 && streamIndex < static_cast(streams.size())) { return streams.at(streamIndex).id; } @@ -203,9 +208,9 @@ QString NetworkStreamModel::networkIdFromIndex(const QModelIndex &index) const if (networkIndex == 0xFFFFFFFF) { int actualNetworkIndex = static_cast(id & 0xFFFFFFFF); - if (actualNetworkIndex >= 0 && actualNetworkIndex < m_data->networks().size()) + if (actualNetworkIndex >= 0 && actualNetworkIndex < m_store->networks().size()) { - return m_data->networks().at(actualNetworkIndex).id; + return m_store->networks().at(actualNetworkIndex).id; } } diff --git a/src/networkstreammodel.h b/src/networkstreammodel.h index a808558..42fae15 100644 --- a/src/networkstreammodel.h +++ b/src/networkstreammodel.h @@ -2,14 +2,14 @@ #define NETWORKSTREAMMODEL_H #include -#include "data.h" +#include "store.h" class NetworkStreamModel : public QAbstractItemModel { Q_OBJECT public: - explicit NetworkStreamModel(Data *data, QObject *parent = nullptr); + explicit NetworkStreamModel(Store *store, QObject *parent = nullptr); // Required QAbstractItemModel interface QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override; @@ -24,7 +24,7 @@ public: QString networkIdFromIndex(const QModelIndex &index) const; private: - Data *m_data; + Store *m_store; }; #endif // NETWORKSTREAMMODEL_H diff --git a/src/particlelistmodel.cpp b/src/particlelistmodel.cpp index 20e7a66..1aee198 100644 --- a/src/particlelistmodel.cpp +++ b/src/particlelistmodel.cpp @@ -5,9 +5,16 @@ #include #include -ParticleListModel::ParticleListModel(Data *data, QObject *parent) - : QAbstractListModel(parent), m_data(data), m_currentStreamId() +ParticleListModel::ParticleListModel(Store *store, QObject *parent) + : QAbstractListModel(parent), m_store(store), m_currentStreamId() { + connect(m_store, &Store::particlesChanged, this, [this](const QString &streamId) { + if (streamId == m_currentStreamId) + { + beginResetModel(); + endResetModel(); + } + }); } int ParticleListModel::rowCount(const QModelIndex &parent) const @@ -22,7 +29,7 @@ int ParticleListModel::rowCount(const QModelIndex &parent) const return 0; } - return m_data->particleCount(m_currentStreamId); + return m_store->particleCount(m_currentStreamId); } QVariant ParticleListModel::data(const QModelIndex &index, int role) const @@ -37,7 +44,7 @@ QVariant ParticleListModel::data(const QModelIndex &index, int role) const return QVariant(); } - const QList &particles = m_data->particlesForStream(m_currentStreamId); + const QList &particles = m_store->particlesForStream(m_currentStreamId); if (index.row() < 0 || index.row() >= particles.size()) { @@ -48,8 +55,13 @@ QVariant ParticleListModel::data(const QModelIndex &index, int role) const if (role == Qt::DisplayRole) { - QString emailPrefix = particle.createdBy.split("@")[0]; - QString display = QString("[%1] - %2").arg(emailPrefix, particle.summary); + QString emailPrefix = particle.createdByEmail.split("@")[0]; + QString summary = particle.data.value("content").toString(); + if (summary.isEmpty()) + { + summary = particle.type; + } + QString display = QString("[%1] - %2").arg(emailPrefix, summary); if (!particle.seen) { display += " •"; @@ -65,21 +77,6 @@ QVariant ParticleListModel::data(const QModelIndex &index, int role) const } // Unseen items use default foreground color } - // else if (role == Qt::DecorationRole) - // { - // // Return icon based on type - // QStyle::StandardPixmap pixmap; - // if (particle.type == "text") - // pixmap = QStyle::SP_DirIcon; - // else if (particle.type == "link") - // pixmap = QStyle::SP_CommandLink; - // else if (particle.type == "image") - // pixmap = QStyle::SP_MediaPlay; - // else - // return QVariant(); - // - // return qApp->style()->standardIcon(pixmap); - // } return QVariant(); } @@ -98,7 +95,7 @@ int ParticleListModel::firstUnseenRow() const return -1; } - const QList &particles = m_data->particlesForStream(m_currentStreamId); + const QList &particles = m_store->particlesForStream(m_currentStreamId); for (int i = 0; i < particles.size(); ++i) { if (!particles.at(i).seen) @@ -113,11 +110,15 @@ int ParticleListModel::firstUnseenRow() const const Particle *ParticleListModel::particleAtRow(int row) const { if (m_currentStreamId.isEmpty()) + { return nullptr; + } - const QList &particles = m_data->particlesForStream(m_currentStreamId); + const QList &particles = m_store->particlesForStream(m_currentStreamId); if (row >= 0 && row < particles.size()) + { return &particles.at(row); + } return nullptr; } diff --git a/src/particlelistmodel.h b/src/particlelistmodel.h index af1c53a..c7ef061 100644 --- a/src/particlelistmodel.h +++ b/src/particlelistmodel.h @@ -2,14 +2,14 @@ #define PARTICLELISTMODEL_H #include -#include "data.h" +#include "store.h" class ParticleListModel : public QAbstractListModel { Q_OBJECT public: - explicit ParticleListModel(Data *data, QObject *parent = nullptr); + explicit ParticleListModel(Store *store, QObject *parent = nullptr); int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; @@ -19,7 +19,7 @@ public: const Particle* particleAtRow(int row) const; private: - Data *m_data; + Store *m_store; QString m_currentStreamId; }; diff --git a/src/store.cpp b/src/store.cpp new file mode 100644 index 0000000..476a44a --- /dev/null +++ b/src/store.cpp @@ -0,0 +1,879 @@ +#include "store.h" +#include "networkmanager.h" +#include +#include +#include +#include +#include + +// ──────────────────────────────────────────────────────────────── +// Operation +// ──────────────────────────────────────────────────────────────── + +Operation::Operation(QObject *parent) + : QObject(parent) +{ +} + +// ──────────────────────────────────────────────────────────────── +// Store — construction +// ──────────────────────────────────────────────────────────────── + +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(); + QJsonDocument doc = QJsonDocument::fromJson(body); + QString message = doc.isObject() ? doc.object().value("message").toString() : QString(); + if (message.isEmpty()) + { + message = reply->errorString(); + } + emit op->failed(message); + emit op->finished(); + return; + } + + QByteArray body = reply->readAll(); + QJsonDocument doc = body.isEmpty() ? QJsonDocument() : QJsonDocument::fromJson(body); + successHandler(doc); + }); +} + +void Store::wireReply(QNetworkReply *reply, Operation *op) +{ + wireReply(reply, op, [op](const QJsonDocument &) { + emit op->success(); + emit op->finished(); + }); +} + +// ──────────────────────────────────────────────────────────────── +// JSON parsing +// ──────────────────────────────────────────────────────────────── + +Human Store::parseHuman(const QJsonObject &obj) +{ + Human h; + h.id = obj["id"].toString(); + h.email = obj["email"].toString(); + h.emailPrefix = obj["email_prefix"].toString(); + return h; +} + +Network Store::parseNetwork(const QJsonObject &obj) +{ + Network n; + n.id = obj["id"].toString(); + n.name = obj["name"].toString(); + n.admin = parseHuman(obj["admin_human"].toObject()); + n.openStreamCount = obj["open_stream_count"].toInt(); + n.openStreamCapacity = obj["open_stream_capacity"].toInt(); + + const QJsonArray humansArr = obj["humans"].toArray(); + for (const QJsonValue &v : humansArr) + { + n.members.push_back(parseHuman(v.toObject())); + } + + // Streams are parsed at a higher level (populateFromStartupData) so we can + // also extract particles per stream. Individual fetch calls parse streams inline. + return n; +} + +Stream Store::parseStream(const QJsonObject &obj) +{ + Stream s; + s.id = obj["id"].toString(); + s.name = obj["name"].toString(); + s.description = obj["description"].toString(); + s.isOpen = (obj["status"].toString() == "open"); + s.unseenCount = obj["unseen_count"].toInt(); + + const QJsonArray membersArr = obj["members"].toArray(); + for (const QJsonValue &v : membersArr) + { + s.memberEmails.push_back(v.toString()); + } + + return s; +} + +Particle Store::parseParticle(const QJsonObject &obj) +{ + Particle p; + p.id = obj["id"].toString(); + p.type = obj["type"].toString(); + p.createdByEmail = obj["created_by_email"].toString(); + p.seen = obj["seen"].toBool(); + + const QJsonObject dataObj = obj["data"].toObject(); + for (auto it = dataObj.begin(); it != dataObj.end(); ++it) + { + p.data[it.key()] = it.value().toVariant(); + } + + const QJsonArray acksArr = obj["acks"].toArray(); + for (const QJsonValue &v : acksArr) + { + // acks can be objects with { email, acked_at } or plain strings + if (v.isObject()) + { + p.ackedByEmails.push_back(v.toObject()["email"].toString()); + } + else + { + p.ackedByEmails.push_back(v.toString()); + } + } + + p.updatedAt = QDateTime::fromString(obj["updated_at"].toString(), Qt::ISODate); + p.createdAt = QDateTime::fromString(obj["created_at"].toString(), Qt::ISODate); + return p; +} + +void Store::populateFromStartupData(const QJsonObject &root) +{ + m_networks.clear(); + m_particles.clear(); + m_networkIndex.clear(); + m_streamToNetwork.clear(); + + const QJsonArray networksArr = root["networks"].toArray(); + for (const QJsonValue &netVal : networksArr) + { + QJsonObject netObj = netVal.toObject(); + Network network = parseNetwork(netObj); + + const QJsonArray streamsArr = netObj["streams"].toArray(); + for (const QJsonValue &streamVal : streamsArr) + { + QJsonObject streamObj = streamVal.toObject(); + Stream stream = parseStream(streamObj); + network.streams.push_back(stream); + m_streamToNetwork[stream.id] = network.id; + + // Parse particles embedded in stream + QList particleList; + const QJsonArray particlesArr = streamObj["particles"].toArray(); + for (const QJsonValue &partVal : particlesArr) + { + particleList.append(parseParticle(partVal.toObject())); + } + m_particles[stream.id] = particleList; + } + + m_networkIndex[network.id] = m_networks.size(); + m_networks.append(network); + } +} + +// ──────────────────────────────────────────────────────────────── +// State mutation helpers +// ──────────────────────────────────────────────────────────────── + +void Store::upsertNetwork(const Network &network) +{ + if (m_networkIndex.contains(network.id)) + { + int idx = m_networkIndex[network.id]; + std::vector existingStreams = m_networks[idx].streams; + m_networks[idx] = network; + if (network.streams.empty()) + { + m_networks[idx].streams = existingStreams; + } + } + else + { + m_networkIndex[network.id] = m_networks.size(); + m_networks.append(network); + } +} + +void Store::upsertStream(const QString &networkId, const Stream &stream) +{ + if (!m_networkIndex.contains(networkId)) + { + return; + } + + auto &streams = m_networks[m_networkIndex[networkId]].streams; + for (size_t i = 0; i < streams.size(); ++i) + { + if (streams[i].id == stream.id) + { + streams[i] = stream; + m_streamToNetwork[stream.id] = networkId; + return; + } + } + streams.push_back(stream); + m_streamToNetwork[stream.id] = networkId; +} + +void Store::upsertParticle(const QString &streamId, const Particle &particle) +{ + QList &list = m_particles[streamId]; + for (int i = 0; i < list.size(); ++i) + { + if (list[i].id == particle.id) + { + list[i] = particle; + return; + } + } + list.append(particle); +} + +void Store::removeParticleFromState(const QString &particleId) +{ + for (auto it = m_particles.begin(); it != m_particles.end(); ++it) + { + QList &list = it.value(); + for (int i = 0; i < list.size(); ++i) + { + if (list[i].id == particleId) + { + list.removeAt(i); + return; + } + } + } +} + +QString Store::findStreamForParticle(const QString &particleId) const +{ + for (auto it = m_particles.constBegin(); it != m_particles.constEnd(); ++it) + { + for (const auto &p : it.value()) + { + if (p.id == particleId) + { + return it.key(); + } + } + } + return QString(); +} + +// ──────────────────────────────────────────────────────────────── +// Accessors +// ──────────────────────────────────────────────────────────────── + +const QList &Store::networks() const { return m_networks; } + +int Store::networkCount() const { return m_networks.size(); } + +const Network *Store::networkById(const QString &id) const +{ + auto it = m_networkIndex.find(id); + if (it == m_networkIndex.end()) + { + return nullptr; + } + return &m_networks[it.value()]; +} + +QList Store::streamsForNetwork(const QString &networkId) const +{ + auto it = m_networkIndex.find(networkId); + if (it == m_networkIndex.end()) + { + return {}; + } + const auto &streams = m_networks[it.value()].streams; + QList result; + result.reserve(static_cast(streams.size())); + for (const auto &s : streams) + { + result.append(s); + } + return result; +} + +int Store::streamCount(const QString &networkId) const +{ + auto it = m_networkIndex.find(networkId); + if (it == m_networkIndex.end()) + { + return 0; + } + return static_cast(m_networks[it.value()].streams.size()); +} + +const Stream *Store::streamById(const QString &id) const +{ + auto netIt = m_streamToNetwork.find(id); + if (netIt == m_streamToNetwork.end()) + { + return nullptr; + } + auto idxIt = m_networkIndex.find(netIt.value()); + if (idxIt == m_networkIndex.end()) + { + return nullptr; + } + const auto &streams = m_networks[idxIt.value()].streams; + for (const auto &s : streams) + { + if (s.id == id) + { + return &s; + } + } + return nullptr; +} + +const QList &Store::particlesForStream(const QString &streamId) const +{ + static const QList empty; + auto it = m_particles.find(streamId); + if (it == m_particles.end()) + { + return empty; + } + return it.value(); +} + +int Store::particleCount(const QString &streamId) const +{ + return m_particles.value(streamId).size(); +} + +const Particle *Store::particleById(const QString &id) const +{ + for (auto it = m_particles.constBegin(); it != m_particles.constEnd(); ++it) + { + for (const auto &p : it.value()) + { + if (p.id == id) + { + return &p; + } + } + } + return nullptr; +} + +// ──────────────────────────────────────────────────────────────── +// Bootstrap +// ──────────────────────────────────────────────────────────────── + +Operation *Store::loadStartupData() +{ + Operation *op = makeOperation(); + QNetworkReply *reply = NetworkManager::instance().get("/startup"); + + wireReply(reply, op, [this, op](const QJsonDocument &doc) { + if (!doc.isObject()) + { + emit op->failed("Invalid startup data"); + emit op->finished(); + return; + } + populateFromStartupData(doc.object()); + emit startupDataLoaded(); + emit networksChanged(); + emit op->success(); + emit op->finished(); + }); + + return op; +} + +// ──────────────────────────────────────────────────────────────── +// Network operations +// ──────────────────────────────────────────────────────────────── + +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) { + 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) { + 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) { + QJsonObject obj = doc.object(); + Network network = parseNetwork(obj); + + const QJsonArray streamsArr = obj["streams"].toArray(); + for (const QJsonValue &sv : streamsArr) + { + Stream stream = parseStream(sv.toObject()); + network.streams.push_back(stream); + m_streamToNetwork[stream.id] = network.id; + } + 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) + { + arr.append(e); + } + body["email_addresses"] = arr; + + QNetworkReply *reply = NetworkManager::instance().post( + QString("/networks/%1/members").arg(networkId), QJsonDocument(body)); + wireReply(reply, op); + return op; +} + +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; +} + +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; +} + +// ──────────────────────────────────────────────────────────────── +// Stream operations +// ──────────────────────────────────────────────────────────────── + +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; + body["visibility"] = visibility; + QJsonArray membersArr; + for (const QString &m : members) + { + membersArr.append(m); + } + body["members"] = membersArr; + + QNetworkReply *reply = NetworkManager::instance().post( + QString("/networks/%1/streams").arg(networkId), QJsonDocument(body)); + + wireReply(reply, op, [this, op, 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) { + QJsonObject obj = doc.object(); + Stream stream = parseStream(obj); + + QString networkId = m_streamToNetwork.value(streamId); + if (!networkId.isEmpty()) + { + upsertStream(networkId, stream); + emit streamsChanged(networkId); + } + + // Parse particles if present + const QJsonArray particlesArr = obj["particles"].toArray(); + QList particleList; + for (const QJsonValue &pv : particlesArr) + { + particleList.append(parseParticle(pv.toObject())); + } + 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()) + { + body["name"] = name; + } + if (!description.isEmpty()) + { + body["description"] = description; + } + + QNetworkReply *reply = NetworkManager::instance().patch( + QString("/streams/%1").arg(streamId), QJsonDocument(body)); + + wireReply(reply, op, [this, op, streamId](const QJsonDocument &doc) { + if (doc.isObject()) + { + Stream stream = parseStream(doc.object()); + QString networkId = m_streamToNetwork.value(streamId); + if (!networkId.isEmpty()) + { + upsertStream(networkId, stream); + 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 &) { + 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.isOpen = true; + break; + } + } + 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 &) { + 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.isOpen = false; + break; + } + } + 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) + { + arr.append(e); + } + body["emails"] = arr; + + QNetworkReply *reply = NetworkManager::instance().post( + QString("/streams/%1/members").arg(streamId), QJsonDocument(body)); + wireReply(reply, op); + return op; +} + +Operation *Store::removeStreamMembers(const QString &streamId, const QStringList &emails) +{ + Operation *op = makeOperation(); + + QJsonObject body; + QJsonArray arr; + for (const QString &e : emails) + arr.append(e); + body["emails"] = arr; + + QNetworkReply *reply = NetworkManager::instance().del( + QString("/streams/%1/members").arg(streamId), QJsonDocument(body)); + wireReply(reply, op); + return op; +} + +// ──────────────────────────────────────────────────────────────── +// Particle operations +// ──────────────────────────────────────────────────────────────── + +Operation *Store::createParticle(const QString &streamId, const QString &type, + const QMap &data) +{ + Operation *op = makeOperation(); + + QJsonObject body; + body["type"] = type; + QJsonObject dataObj; + for (auto it = data.constBegin(); it != data.constEnd(); ++it) + dataObj[it.key()] = QJsonValue::fromVariant(it.value()); + body["data"] = dataObj; + + QNetworkReply *reply = NetworkManager::instance().post( + QString("/streams/%1/particles").arg(streamId), QJsonDocument(body)); + + wireReply(reply, op, [this, op, 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) { + if (doc.isObject()) + { + Particle particle = parseParticle(doc.object()); + QString streamId = findStreamForParticle(particleId); + if (!streamId.isEmpty()) + { + upsertParticle(streamId, particle); + 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) + dataObj[it.key()] = QJsonValue::fromVariant(it.value()); + body["data"] = dataObj; + + QNetworkReply *reply = NetworkManager::instance().patch( + QString("/particles/%1").arg(particleId), QJsonDocument(body)); + + wireReply(reply, op, [this, op, particleId](const QJsonDocument &doc) { + if (doc.isObject()) + { + Particle particle = parseParticle(doc.object()); + QString streamId = findStreamForParticle(particleId); + if (!streamId.isEmpty()) + { + upsertParticle(streamId, particle); + 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 &) { + QString streamId = findStreamForParticle(particleId); + removeParticleFromState(particleId); + if (!streamId.isEmpty()) + emit particlesChanged(streamId); + emit op->success(); + emit op->finished(); + }); + + return op; +} + +Operation *Store::markParticleSeen(const QString &particleId) +{ + // Optimistic local update + for (auto it = m_particles.begin(); it != m_particles.end(); ++it) + { + for (int i = 0; i < it.value().size(); ++i) + { + if (it.value()[i].id == particleId) + { + it.value()[i].seen = true; + emit particlesChanged(it.key()); + break; + } + } + } + + Operation *op = makeOperation(); + QNetworkReply *reply = NetworkManager::instance().post( + QString("/particles/%1/seen").arg(particleId), QJsonDocument()); + wireReply(reply, op); + return op; +} + +Operation *Store::markParticlesSeen(const QStringList &particleIds) +{ + // Optimistic local update + QSet affectedStreams; + for (const QString &pid : particleIds) + { + for (auto it = m_particles.begin(); it != m_particles.end(); ++it) + { + for (int i = 0; i < it.value().size(); ++i) + { + if (it.value()[i].id == pid) + { + it.value()[i].seen = true; + affectedStreams.insert(it.key()); + break; + } + } + } + } + for (const QString &streamId : affectedStreams) + emit particlesChanged(streamId); + + Operation *op = makeOperation(); + + QJsonObject body; + QJsonArray ids; + for (const QString &id : particleIds) + ids.append(id); + body["particle_ids"] = ids; + + QNetworkReply *reply = NetworkManager::instance().post("/particles/seen", QJsonDocument(body)); + wireReply(reply, op); + return op; +} + +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; +} diff --git a/src/store.h b/src/store.h new file mode 100644 index 0000000..4b40109 --- /dev/null +++ b/src/store.h @@ -0,0 +1,139 @@ +#ifndef STORE_H +#define STORE_H + +#include "models.h" +#include +#include + +QT_BEGIN_NAMESPACE +class QNetworkReply; +QT_END_NAMESPACE + +/// Represents an in-flight API operation. +/// Callers connect to success/failed for one-off feedback. +/// Automatically deletes itself after finished() fires. +class Operation : public QObject +{ + Q_OBJECT + +public: + explicit Operation(QObject *parent = nullptr); + +signals: + void success(); + void failed(const QString &message); + void finished(); +}; + +/// Central data store backed by the Orion API. +/// Holds cached state and exposes read-only accessors + mutating operations. +class Store : public QObject +{ + Q_OBJECT + +public: + explicit Store(QObject *parent = nullptr); + + // ── Read-only accessors ── + + const QList &networks() const; + int networkCount() const; + const Network *networkById(const QString &id) const; + + QList streamsForNetwork(const QString &networkId) const; + int streamCount(const QString &networkId) const; + const Stream *streamById(const QString &id) const; + + const QList &particlesForStream(const QString &streamId) const; + int particleCount(const QString &streamId) const; + const Particle *particleById(const QString &id) const; + + // ── Bootstrap ── + + Operation *loadStartupData(); + + // ── Network operations ── + + Operation *createNetwork(const QString &name); + Operation *fetchNetworks(); + Operation *fetchNetwork(const QString &networkId); + Operation *addNetworkMembers(const QString &networkId, const QStringList &emails); + Operation *removeNetworkMember(const QString &networkId, const QString &email); + Operation *setStreamCapacity(const QString &networkId, int capacity); + + // ── Stream operations ── + + Operation *createStream(const QString &networkId, const QString &name, + const QString &description, const QString &visibility = "network_all", + const QStringList &members = {}); + Operation *fetchStream(const QString &streamId); + Operation *updateStream(const QString &streamId, const QString &name, const QString &description); + Operation *openStream(const QString &streamId); + Operation *closeStream(const QString &streamId); + Operation *addStreamMembers(const QString &streamId, const QStringList &emails); + Operation *removeStreamMembers(const QString &streamId, const QStringList &emails); + + // ── Particle operations ── + + Operation *createParticle(const QString &streamId, const QString &type, + const QMap &data); + Operation *fetchParticle(const QString &particleId); + Operation *updateParticle(const QString &particleId, const QMap &data); + Operation *deleteParticle(const QString &particleId); + Operation *markParticleSeen(const QString &particleId); + Operation *markParticlesSeen(const QStringList &particleIds); + Operation *ackParticle(const QString &particleId); + +signals: + // ── Data-change signals ── + + void networksChanged(); + void streamsChanged(const QString &networkId); + void particlesChanged(const QString &streamId); + + void startupDataLoaded(); + void errorOccurred(const QString &message); + +private: + // ── Internal state ── + + QList m_networks; + QMap> m_particles; // streamId -> particles + QMap m_networkIndex; // networkId -> index in m_networks + QMap m_streamToNetwork; // streamId -> networkId + + // ── JSON parsing ── + + static Human parseHuman(const QJsonObject &obj); + static Network parseNetwork(const QJsonObject &obj); + static Stream parseStream(const QJsonObject &obj); + static Particle parseParticle(const QJsonObject &obj); + void populateFromStartupData(const QJsonObject &root); + + // ── State mutation helpers ── + + void upsertNetwork(const Network &network); + void upsertStream(const QString &networkId, const Stream &stream); + void upsertParticle(const QString &streamId, const Particle &particle); + void removeParticleFromState(const QString &particleId); + + /// 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