setup electron app with boilerplate

This commit is contained in:
talksik
2026-02-19 19:41:26 -08:00
parent fc6a5e43db
commit 1f249c47a6
5174 changed files with 8743 additions and 0 deletions
+952
View File
@@ -0,0 +1,952 @@
//
// Created by arjun-flowylabs on 1/10/2026.
//
#include "MainWindow.h"
#include "dialogs/authdialog.h"
#include "authmanager.h"
#include "dialogs/createstreamdialog.h"
#include "dialogs/memberpickerdialog.h"
#include "widgets/networkdetailwidget.h"
#include "networkmanager.h"
#include "dialogs/settingsdialog.h"
#include "dialogs/textinputdialog.h"
#include <QApplication>
#include <QJsonDocument>
#include <QHeaderView>
#include <QItemSelectionModel>
#include <QLabel>
#include <QListView>
#include <QListWidget>
#include <QMenu>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QJsonObject>
#include <QPushButton>
#include <QSplitter>
#include <QStatusBar>
#include <QToolButton>
#include <QTreeView>
#include <QSystemTrayIcon>
#include <QTimer>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include "media/mediahandler.h"
#include "dialogs/mediapreviewdialog.h"
#include "mediaparticlegenerator.h"
#include "widgets/textparticlewidget.h"
#include "widgets/mediaparticlewidget.h"
#include <QStackedWidget>
#include <QUrl>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent), m_trayIcon(nullptr), m_networkDetailWidget(nullptr),
m_authManager(new AuthManager(this)), m_authDialog(nullptr), m_settingsDialog(nullptr),
m_textInputDialog(nullptr), m_createStreamDialog(nullptr),
m_store(nullptr), m_networkStreamModel(nullptr), m_particleListModel(nullptr),
m_selectedParticleRow(-1), m_isDragging(false), m_media{new MediaHandler{this}},
m_spaceHeld(false)
{
m_authDialog = new AuthDialog(this);
m_textInputDialog = new TextInputDialog(this);
connect(m_textInputDialog, &TextInputDialog::textSubmitted, this, &MainWindow::onTextMessageSubmitted);
buildUi();
m_topBar->setCursor(Qt::OpenHandCursor);
// Enable frameless window for edge-to-edge experience
setWindowFlags(Qt::FramelessWindowHint | Qt::Window);
setAttribute(Qt::WA_TranslucentBackground, false);
// Style for grey status bar text
QString statusBarLabelStyle = "QLabel { color: #808080; }";
QString pingLabelStyle = "QLabel { color: #00AA00; }"; // Green for ping
m_currentPathLabel = new QLabel(this);
m_currentPathLabel->setMargin(5);
m_currentPathLabel->setStyleSheet(statusBarLabelStyle);
QFont font = QFont();
font.setStyle(QFont::StyleItalic);
m_currentPathLabel->setFont(font);
m_statusBar->addWidget(m_currentPathLabel);
// Add spacer to push right-aligned items to the right
m_statusBar->addWidget(new QLabel(""), 1);
// Ping indicator (green text)
m_pingLabel = new QLabel(this);
m_pingLabel->setMargin(5);
m_pingLabel->setMinimumWidth(100);
m_pingLabel->setAlignment(Qt::AlignCenter);
m_pingLabel->setStyleSheet(pingLabelStyle);
m_statusBar->addPermanentWidget(m_pingLabel);
// Position indicator (vim-style)
m_positionLabel = new QLabel(this);
m_positionLabel->setMargin(5);
m_positionLabel->setMinimumWidth(50);
m_positionLabel->setAlignment(Qt::AlignCenter);
m_positionLabel->setStyleSheet(statusBarLabelStyle);
m_statusBar->addPermanentWidget(m_positionLabel);
// Storage indicator
m_storageLabel = new QLabel(this);
m_storageLabel->setMargin(5);
m_storageLabel->setMinimumWidth(60);
m_storageLabel->setAlignment(Qt::AlignCenter);
m_storageLabel->setStyleSheet(statusBarLabelStyle);
m_statusBar->addPermanentWidget(m_storageLabel);
// Setup ping timer for periodic server connectivity check
m_pingTimer = new QTimer(this);
connect(m_pingTimer, &QTimer::timeout, this, []() {
NetworkManager::instance().ping();
});
connect(&NetworkManager::instance(), &NetworkManager::pingResult, this, [this](int ms) {
m_pingLabel->setText("connected " + QString::number(ms) + "ms");
m_pingLabel->setStyleSheet("QLabel { color: #00AA00; }");
});
connect(&NetworkManager::instance(), &NetworkManager::pingFailed, this, [this]() {
m_pingLabel->setText("disconnected");
m_pingLabel->setStyleSheet("QLabel { color: #AA0000; }");
});
m_pingTimer->start(10000);
NetworkManager::instance().ping();
m_splitter->setStretchFactor(0, 1);
m_splitter->setStretchFactor(1, 4);
m_splitter->setStretchFactor(2, 1);
setupModels();
m_authDialog->setModal(true);
connect(m_authManager, &AuthManager::codeSentToEmail, m_authDialog, &AuthDialog::onCodeSentToEmail);
connect(m_authManager, &AuthManager::errorOccurred, m_authDialog, &AuthDialog::showError);
connect(m_authManager, &AuthManager::signedIn, m_authDialog, &AuthDialog::hide);
connect(m_authDialog, &AuthDialog::codeRequested, m_authManager, &AuthManager::requestSignInCode);
connect(m_authDialog, &AuthDialog::signInRequested, m_authManager, &AuthManager::signIn);
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, &MainWindow::onSignedIn);
connect(m_store, &Store::startupDataLoaded, m_treeView, &QTreeView::expandAll);
connect(m_store, &Store::particleUpdated, this, &MainWindow::onParticleUpdated);
// DocumentSend toolbar button opens text input dialog
connect(m_sendTextButton, &QToolButton::clicked, this, &MainWindow::showTextInputDialog);
// Explicit connects for buttons (no auto-connect without .ui)
connect(m_settingsButton, &QToolButton::clicked, this, &MainWindow::on_settingsButton_clicked);
connect(m_exitButton, &QToolButton::clicked, this, &MainWindow::on_exitButton_clicked);
// "New stream" button
m_createStreamDialog = new CreateStreamDialog(this);
connect(m_newStreamButton, &QPushButton::clicked, this, &MainWindow::onNewStreamClicked);
connect(m_createStreamDialog, &CreateStreamDialog::streamRequested, this, &MainWindow::onCreateStreamRequested);
// "Add members" toolbar button (ListAdd icon in right sidebar)
connect(m_addMembersButton, &QToolButton::clicked, this, &MainWindow::onAddMembersClicked);
m_authManager->tryRestoreSession();
connect(m_media, &MediaHandler::errorOccurred, this, [this](const QString &message) {
m_statusBar->showMessage(message);
});
m_mediaPreviewDialog = new MediaPreviewDialog(this);
m_mediaParticleGenerator = new MediaParticleGenerator(m_store, this);
connect(m_media, &MediaHandler::recordingComplete, this,
[this](const QUrl &url, qint64 durationMs, const QString &mimeType) {
m_media->stop();
m_recordedFileUrl = url;
m_recordedDurationMs = durationMs;
m_recordedMimeType = mimeType;
m_mediaPreviewDialog->showPreview(url);
});
connect(m_mediaPreviewDialog, &MediaPreviewDialog::spaceReleased, this, [this]() {
m_spaceHeld = false;
m_media->recordStop();
});
connect(m_mediaPreviewDialog, &QDialog::accepted, this, [this]() {
m_mediaPreviewDialog->reset();
if (m_selectedStreamId.isEmpty() || m_selectedNetworkId.isEmpty()) return;
m_mediaParticleGenerator->generate(
m_recordedFileUrl, m_recordedMimeType,
m_selectedNetworkId, m_recordedDurationMs, m_selectedStreamId);
});
connect(m_mediaPreviewDialog, &QDialog::rejected, this, [this]() {
m_mediaPreviewDialog->reset();
});
connect(m_mediaParticleGenerator, &MediaParticleGenerator::errorOccurred, this,
[this](const QString &msg) { m_statusBar->showMessage(msg); });
setupTrayIcon();
}
void MainWindow::buildUi()
{
resize(650, 500);
setWindowTitle("Flowy.llink");
auto *centralWidget = new QWidget(this);
setCentralWidget(centralWidget);
auto *mainLayout = new QVBoxLayout(centralWidget);
mainLayout->setContentsMargins(0, 0, 0, 0);
m_splitter = new QSplitter(Qt::Horizontal);
mainLayout->addWidget(m_splitter);
// ── Left sidebar ──
m_leftSidebar = new QWidget();
m_leftSidebar->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
auto *leftLayout = new QVBoxLayout(m_leftSidebar);
leftLayout->setSpacing(5);
leftLayout->setContentsMargins(2, 2, 2, 2);
// Top bar (drag area)
m_topBar = new QWidget();
m_topBar->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
m_topBar->setMinimumHeight(20);
auto *topBarLayout = new QHBoxLayout(m_topBar);
topBarLayout->setSpacing(0);
topBarLayout->setContentsMargins(0, 0, 0, 0);
m_settingsButton = new QToolButton();
m_settingsButton->setText("...");
m_settingsButton->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::HelpAbout));
topBarLayout->addWidget(m_settingsButton);
topBarLayout->addStretch();
m_exitButton = new QToolButton();
m_exitButton->setText("...");
m_exitButton->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::ApplicationExit));
topBarLayout->addWidget(m_exitButton);
leftLayout->addWidget(m_topBar);
// Tree view
m_treeView = new QTreeView();
m_treeView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
leftLayout->addWidget(m_treeView);
// New stream button
m_newStreamButton = new QPushButton("New stream");
m_newStreamButton->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::ListAdd));
leftLayout->addWidget(m_newStreamButton);
m_splitter->addWidget(m_leftSidebar);
// ── Content view ──
m_contentView = new QWidget();
m_contentView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
m_contentView->setMinimumWidth(350);
auto *contentLayout = new QVBoxLayout(m_contentView);
contentLayout->setSpacing(2);
contentLayout->setContentsMargins(0, 0, 0, 2);
// Stacked widget for particle type-specific content
m_contentStack = new QStackedWidget();
m_contentStack->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
m_textParticleWidget = new TextParticleWidget();
m_mediaParticleWidget = new MediaParticleWidget();
m_contentStack->addWidget(m_textParticleWidget);
m_contentStack->addWidget(m_mediaParticleWidget);
contentLayout->addWidget(m_contentStack);
m_creatorLabel = new QLabel();
QFont creatorFont;
creatorFont.setPointSize(15);
m_creatorLabel->setFont(creatorFont);
m_creatorLabel->setAlignment(Qt::AlignCenter);
contentLayout->addWidget(m_creatorLabel);
m_ackLabel = new QLabel();
m_ackLabel->setAlignment(Qt::AlignCenter);
contentLayout->addWidget(m_ackLabel);
// Content toolbar (empty, matches widget_4 from .ui)
auto *contentToolbar = new QWidget();
auto *contentToolbarLayout = new QHBoxLayout(contentToolbar);
contentToolbarLayout->setSpacing(2);
contentToolbarLayout->setContentsMargins(8, 0, 0, 0);
contentLayout->addWidget(contentToolbar);
m_splitter->addWidget(m_contentView);
// ── Right sidebar ──
m_rightSidebar = new QWidget();
m_rightSidebar->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
auto *rightLayout = new QVBoxLayout(m_rightSidebar);
rightLayout->setSpacing(2);
rightLayout->setContentsMargins(2, 2, 2, 2);
// Particle list view
m_particlesView = new QListView();
m_particlesView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
auto sp1 = m_particlesView->sizePolicy();
sp1.setVerticalStretch(2);
m_particlesView->setSizePolicy(sp1);
rightLayout->addWidget(m_particlesView);
// Members list
m_membersList = new QListWidget();
m_membersList->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
auto sp2 = m_membersList->sizePolicy();
sp2.setVerticalStretch(1);
m_membersList->setSizePolicy(sp2);
rightLayout->addWidget(m_membersList);
// "Hold space to speak" label
auto *holdSpaceLabel = new QLabel("Hold space to speak");
holdSpaceLabel->setAlignment(Qt::AlignCenter);
QPalette holdPalette = holdSpaceLabel->palette();
QColor holdColor(153, 156, 145, 217);
holdPalette.setColor(QPalette::WindowText, holdColor);
holdSpaceLabel->setPalette(holdPalette);
rightLayout->addWidget(holdSpaceLabel);
// Empty toolbar row (widget_5)
auto *emptyToolbar = new QWidget();
auto *emptyToolbarLayout = new QHBoxLayout(emptyToolbar);
emptyToolbarLayout->setSpacing(2);
emptyToolbarLayout->setContentsMargins(0, 0, 0, 0);
rightLayout->addWidget(emptyToolbar);
// RTL toolbar row (widget_3)
auto *rtlToolbar = new QWidget();
rtlToolbar->setLayoutDirection(Qt::RightToLeft);
auto *rtlToolbarLayout = new QHBoxLayout(rtlToolbar);
rtlToolbarLayout->setSpacing(2);
rtlToolbarLayout->setContentsMargins(0, 0, 0, 0);
auto *editClearButton = new QToolButton();
editClearButton->setText("...");
editClearButton->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::EditClear));
editClearButton->setToolTip(QStringLiteral("Close stream"));
rtlToolbarLayout->addWidget(editClearButton);
m_addMembersButton = new QToolButton();
m_addMembersButton->setText("...");
m_addMembersButton->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::ListAdd));
m_addMembersButton->setToolTip(QStringLiteral("Add members to stream"));
rtlToolbarLayout->addWidget(m_addMembersButton);
auto *insertImageButton = new QToolButton();
insertImageButton->setText("...");
insertImageButton->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::InsertImage));
insertImageButton->setToolTip(QStringLiteral("Attach files"));
rtlToolbarLayout->addWidget(insertImageButton);
m_sendTextButton = new QToolButton();
m_sendTextButton->setText("...");
m_sendTextButton->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::DocumentSend));
m_sendTextButton->setToolTip(QStringLiteral("Send text message"));
rtlToolbarLayout->addWidget(m_sendTextButton);
auto *cameraVideoButton = new QToolButton();
cameraVideoButton->setText("...");
cameraVideoButton->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::CameraVideo));
cameraVideoButton->setToolTip(QStringLiteral("Toggle camera mode"));
rtlToolbarLayout->addWidget(cameraVideoButton);
rightLayout->addWidget(rtlToolbar);
m_splitter->addWidget(m_rightSidebar);
QFrame* hSeparator = new QFrame();
hSeparator->setFrameShape(QFrame::HLine);
hSeparator->setFrameShadow(QFrame::Plain);
hSeparator->setStyleSheet("color: rgba(128, 128, 128, 0.3);");
hSeparator->setFixedHeight(1);
mainLayout->addWidget(hSeparator);
// ── Network detail widget (hidden by default) ──
m_statusBar = new QStatusBar();
setStatusBar(m_statusBar);
}
void MainWindow::setupTrayIcon()
{
m_trayIcon = new QSystemTrayIcon(this);
m_trayIcon->setIcon(QIcon(":/assets/logo.png"));
m_trayIcon->setToolTip("llink");
QMenu *trayMenu = new QMenu(this);
trayMenu->addAction("Show", this, &MainWindow::show);
trayMenu->addAction("Quit", qApp, &QApplication::quit);
m_trayIcon->setContextMenu(trayMenu);
connect(m_trayIcon, &QSystemTrayIcon::activated, this, [this](QSystemTrayIcon::ActivationReason reason) {
if (reason == QSystemTrayIcon::Trigger)
{
isVisible() ? hide() : show();
}
});
m_trayIcon->show();
}
MainWindow::~MainWindow()
{
if (m_spaceHeld)
{
m_media->stop();
}
}
void MainWindow::on_exitButton_clicked()
{
qApp->exit(0);
}
void MainWindow::on_settingsButton_clicked()
{
if (!m_settingsDialog)
{
m_settingsDialog = new SettingsDialog(this);
connect(m_settingsDialog, &SettingsDialog::signOutRequested, m_authManager, &AuthManager::signOut);
connect(m_settingsDialog, &SettingsDialog::createNetworkRequested, this, [this](const QString &name) {
m_store->createNetwork(name);
});
}
if (m_authManager->sessionData())
{
m_settingsDialog->setAuthLabel("Hello, " + m_authManager->sessionData()->emailPrefix);
}
m_settingsDialog->show();
}
void MainWindow::setupModels()
{
// Create store (data loaded after sign-in via loadStartupData)
m_store = new Store(this);
m_networkDetailWidget = new NetworkDetailWidget(this);
m_splitter->addWidget(m_networkDetailWidget);
m_splitter->setStretchFactor(3, 4);
m_networkDetailWidget->hide();
connect(m_store, &Store::networksChanged, this, &MainWindow::onNetworksChanged);
connect(m_networkDetailWidget, &NetworkDetailWidget::addMemberRequested, this, &MainWindow::onAddNetworkMemberRequested);
// Create and set tree model
m_networkStreamModel = new NetworkStreamModel(m_store, this);
m_treeView->setModel(m_networkStreamModel);
m_treeView->setIndentation(5);
m_treeView->setHeaderHidden(true);
m_treeView->expandAll();
// Configure column behavior: name stretches, unseen count is fixed width
m_treeView->header()->setStretchLastSection(false);
m_treeView->header()->setSectionResizeMode(0, QHeaderView::Stretch);
m_treeView->header()->setSectionResizeMode(1, QHeaderView::Fixed);
m_treeView->setColumnWidth(1, 40);
// Create and set list model
m_particleListModel = new ParticleListModel(m_store, this);
m_particlesView->setModel(m_particleListModel);
// Configure particle list view to use ellipses for long text
m_particlesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_particlesView->setTextElideMode(Qt::ElideRight);
// Connect selection changes
connect(m_treeView->selectionModel(), &QItemSelectionModel::selectionChanged, this,
&MainWindow::onTreeSelectionChanged);
// Connect particle selection changes
connect(m_particlesView->selectionModel(), &QItemSelectionModel::selectionChanged, this,
&MainWindow::onParticleSelectionChanged);
// Install event filter to capture key presses from child views
m_treeView->installEventFilter(this);
m_particlesView->installEventFilter(this);
}
void MainWindow::onSignedIn()
{
m_store->setCurrentUserEmail(m_authManager->sessionData()->email);
m_store->loadStartupData();
}
void MainWindow::onParticleUpdated(const QString &streamId, const QString &particleId)
{
Q_UNUSED(streamId);
if (particleId != m_selectedParticleId)
return;
const Particle *particle = m_store->particleById(particleId);
if (particle && !particle->ackedByEmails.empty())
{
QStringList acked;
for (const auto &email : particle->ackedByEmails)
acked.append(email);
m_ackLabel->setText("Acked by: " + acked.join(", "));
}
else
{
m_ackLabel->setText("");
}
}
void MainWindow::onNewStreamClicked()
{
if (m_selectedNetworkId.isEmpty())
{
return;
}
const Network *network = m_store->networkById(m_selectedNetworkId);
if (network)
{
m_createStreamDialog->setNetworkMembers(network->members);
}
m_createStreamDialog->show();
m_createStreamDialog->raise();
m_createStreamDialog->activateWindow();
}
void MainWindow::onCreateStreamRequested(const QString &name, const QString &visibility, const QStringList &members)
{
m_store->createStream(m_selectedNetworkId, name, "", visibility, members);
}
void MainWindow::onAddMembersClicked()
{
if (m_selectedStreamId.isEmpty() || m_selectedNetworkId.isEmpty())
{
return;
}
const Network *network = m_store->networkById(m_selectedNetworkId);
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 &) {
m_membersList->clear();
const Stream *s = m_store->streamById(m_selectedStreamId);
if (s)
{
for (const QString &email : s->memberEmails)
{
m_membersList->addItem(email.split("@")[0]);
}
}
});
});
}
}
}
void MainWindow::onNetworksChanged()
{
if (!m_selectedNetworkId.isEmpty() && m_networkDetailWidget->isVisible())
{
const Network *network = m_store->networkById(m_selectedNetworkId);
if (network)
{
m_networkDetailWidget->setNetwork(*network, m_authManager->sessionData() ? m_authManager->sessionData()->email : QString());
}
else
{
m_networkDetailWidget->clear();
}
}
}
void MainWindow::onAddNetworkMemberRequested(const QString &networkId, const QString &email)
{
auto *op = m_store->addNetworkMembers(networkId, {email});
connect(op, &Operation::success, this, [this, networkId](const QJsonDocument &) {
m_store->fetchNetwork(networkId);
});
}
void MainWindow::onTreeSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected)
{
Q_UNUSED(deselected);
if (selected.indexes().isEmpty())
{
m_particleListModel->setStreamId(QString());
m_membersList->clear();
m_networkDetailWidget->hide();
m_contentView->show();
m_rightSidebar->show();
return;
}
QModelIndex index = selected.indexes().first();
if (m_networkStreamModel->isStreamItem(index))
{
// Show normal 3-pane view
m_networkDetailWidget->hide();
m_contentView->show();
m_rightSidebar->show();
QString streamId = m_networkStreamModel->streamIdFromIndex(index);
m_particleListModel->setStreamId(streamId);
m_selectedStreamId = streamId;
// Also track the parent network
QModelIndex parentIndex = m_networkStreamModel->parent(index);
QString networkId = m_networkStreamModel->networkIdFromIndex(parentIndex);
m_selectedNetworkId = networkId;
// Show stream members
m_membersList->clear();
const Stream *stream = m_store->streamById(streamId);
if (stream)
{
for (const QString &email : stream->memberEmails)
{
m_membersList->addItem(email.split("@")[0]);
}
}
// Auto-select first unseen particle (or first particle if all seen)
int unseenRow = m_particleListModel->firstUnseenRow();
if (unseenRow >= 0)
{
m_particlesView->setCurrentIndex(m_particleListModel->index(unseenRow, 0));
m_particlesView->setFocus();
}
else if (m_particleListModel->rowCount() > 0)
{
m_particlesView->setCurrentIndex(m_particleListModel->index(m_particleListModel->rowCount() - 1, 0));
m_particlesView->setFocus();
}
}
else // network selected
{
m_particleListModel->setStreamId(QString());
m_selectedStreamId.clear();
m_selectedParticleRow = -1;
m_membersList->clear();
QString networkId = m_networkStreamModel->networkIdFromIndex(index);
m_selectedNetworkId = networkId;
// Show network detail widget
const Network *network = m_store->networkById(networkId);
if (network)
{
m_networkDetailWidget->setNetwork(*network, m_authManager->sessionData() ? m_authManager->sessionData()->email : QString());
}
else
{
m_networkDetailWidget->clear();
}
m_contentView->hide();
m_rightSidebar->hide();
m_networkDetailWidget->show();
}
updateStatusBar();
}
void MainWindow::onParticleSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected)
{
Q_UNUSED(deselected);
if (selected.indexes().isEmpty())
{
m_textParticleWidget->clear();
m_mediaParticleWidget->clear();
m_creatorLabel->setText("");
m_ackLabel->setText("");
m_selectedParticleId.clear();
m_selectedParticleRow = -1;
updateStatusBar();
return;
}
QModelIndex index = selected.indexes().first();
m_selectedParticleRow = index.row();
const Particle *particle = m_particleListModel->particleAtRow(index.row());
if (particle)
{
m_creatorLabel->setText(particle->createdByEmail);
m_selectedParticleId = particle->id;
if (particle->type == "text")
{
m_mediaParticleWidget->clear();
m_textParticleWidget->setParticle(particle);
m_contentStack->setCurrentWidget(m_textParticleWidget);
}
else if (particle->type == "media")
{
m_textParticleWidget->clear();
m_mediaParticleWidget->setParticle(particle);
m_contentStack->setCurrentWidget(m_mediaParticleWidget);
}
// Show ack info
if (!particle->ackedByEmails.empty())
{
QStringList acked;
for (const auto &email : particle->ackedByEmails)
{
acked.append(email);
}
m_ackLabel->setText("Acked by: " + acked.join(", "));
}
else
{
m_ackLabel->setText("");
}
// Mark seen on selection
if (!particle->seen)
{
m_store->markParticleSeen(particle->id);
}
}
updateStatusBar();
}
void MainWindow::updateStatusBar()
{
QStringList pathComponents;
// Add network if selected
if (!m_selectedNetworkId.isEmpty())
{
const Network *network = m_store->networkById(m_selectedNetworkId);
if (network)
{
pathComponents << network->name;
}
}
// Add stream if selected
if (!m_selectedStreamId.isEmpty())
{
const Stream *stream = m_store->streamById(m_selectedStreamId);
if (stream)
{
pathComponents << stream->name;
}
}
// Add particle if selected
if (!m_selectedParticleId.isEmpty())
{
const Particle *particle = m_store->particleById(m_selectedParticleId);
if (particle)
{
pathComponents << "p_" + particle->id.right(5);
}
}
m_currentPathLabel->setText(pathComponents.join(" > "));
// Update position indicator (vim-style)
if (!m_selectedStreamId.isEmpty())
{
int totalCount = m_particleListModel->rowCount();
if (totalCount == 0)
{
m_positionLabel->setText("");
}
else if (totalCount == 1)
{
m_positionLabel->setText("All");
}
else if (m_selectedParticleRow == 0)
{
m_positionLabel->setText("Top");
}
else if (m_selectedParticleRow == totalCount - 1)
{
m_positionLabel->setText("Bot");
}
else if (m_selectedParticleRow >= 0)
{
int percent = (m_selectedParticleRow * 100) / (totalCount - 1);
m_positionLabel->setText(QString::number(percent) + "%");
}
else
{
m_positionLabel->setText("");
}
// Update data size indicator for selected particle
if (!m_selectedParticleId.isEmpty())
{
const Particle *particle = m_store->particleById(m_selectedParticleId);
if (particle)
{
QJsonObject dataObj;
for (auto it = particle->data.constBegin(); it != particle->data.constEnd(); ++it)
{
dataObj.insert(it.key(), QJsonValue::fromVariant(it.value()));
}
int bytes = QJsonDocument(dataObj).toJson(QJsonDocument::Compact).size();
if (bytes < 1024)
m_storageLabel->setText(QString::number(bytes) + " B");
else
m_storageLabel->setText(QString::number(bytes / 1024) + " kB");
}
else
{
m_storageLabel->setText("");
}
}
else
{
m_storageLabel->setText("");
}
}
else
{
m_positionLabel->setText("");
m_storageLabel->setText("");
}
}
void MainWindow::showTextInputDialog()
{
if (m_selectedStreamId.isEmpty())
{
return;
}
m_textInputDialog->show();
m_textInputDialog->raise();
m_textInputDialog->activateWindow();
}
bool MainWindow::eventFilter(QObject *obj, QEvent *event)
{
if (event->type() == QEvent::KeyPress)
{
auto *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Space && !keyEvent->isAutoRepeat() && !m_spaceHeld)
{
if (!m_selectedNetworkId.isEmpty() && !m_selectedStreamId.isEmpty())
{
m_spaceHeld = true;
m_mediaPreviewDialog->showRecording();
m_media->startRecording(m_mediaPreviewDialog->videoSink());
return true;
}
}
if (keyEvent->key() == Qt::Key_T && keyEvent->modifiers() == Qt::NoModifier && !m_selectedStreamId.isEmpty())
{
showTextInputDialog();
return true;
}
if (keyEvent->key() == Qt::Key_A && keyEvent->modifiers() == Qt::NoModifier && !m_selectedParticleId.isEmpty())
{
m_store->ackParticle(m_selectedParticleId);
return true;
}
}
if (event->type() == QEvent::KeyRelease)
{
auto *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Space && !keyEvent->isAutoRepeat() && m_spaceHeld)
{
m_spaceHeld = false;
m_media->recordStop();
return true;
}
}
return QMainWindow::eventFilter(obj, event);
}
void MainWindow::onTextMessageSubmitted(const QString &text)
{
if (m_selectedStreamId.isEmpty())
{
return;
}
auto *op = m_store->createParticle(m_selectedStreamId, "text", {{"content", text}});
connect(op, &Operation::success, this, [this](const QJsonDocument &) {
int lastRow = m_particleListModel->rowCount() - 1;
if (lastRow >= 0)
{
m_particlesView->setCurrentIndex(m_particleListModel->index(lastRow, 0));
m_particlesView->setFocus();
}
});
}
void MainWindow::keyPressEvent(QKeyEvent *event)
{
QMainWindow::keyPressEvent(event);
}
void MainWindow::keyReleaseEvent(QKeyEvent *event)
{
QMainWindow::keyReleaseEvent(event);
}
void MainWindow::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
{
// Check if the click is within the top bar widget area
if (m_topBar && m_topBar->geometry().contains(event->pos()))
{
m_isDragging = true;
setCursor(Qt::ClosedHandCursor);
m_dragPosition = event->globalPosition().toPoint() - frameGeometry().topLeft();
event->accept();
return;
}
}
QMainWindow::mousePressEvent(event);
}
void MainWindow::mouseMoveEvent(QMouseEvent *event)
{
if (m_isDragging && (event->buttons() & Qt::LeftButton))
{
move(event->globalPosition().toPoint() - m_dragPosition);
event->accept();
return;
}
QMainWindow::mouseMoveEvent(event);
}
void MainWindow::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
{
m_isDragging = false;
unsetCursor();
}
QMainWindow::mouseReleaseEvent(event);
}
+144
View File
@@ -0,0 +1,144 @@
//
// Created by arjun-flowylabs on 1/10/2026.
//
#ifndef LLINK_MAINWINDOW_H
#define LLINK_MAINWINDOW_H
#include "store.h"
#include "networkstreammodel.h"
#include "particlelistmodel.h"
#include <QMainWindow>
#include <QUrl>
QT_BEGIN_NAMESPACE
class QSystemTrayIcon;
class AuthManager;
class QItemSelection;
class QLabel;
class QSplitter;
class QTreeView;
class QListView;
class QListWidget;
class QToolButton;
class QPushButton;
class QStatusBar;
class QStackedWidget;
QT_END_NAMESPACE
class AuthDialog;
class CreateStreamDialog;
class MemberPickerDialog;
class NetworkDetailWidget;
class SettingsDialog;
class TextInputDialog;
class MediaHandler;
class MediaPreviewDialog;
class MediaParticleGenerator;
class TextParticleWidget;
class MediaParticleWidget;
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
~MainWindow();
private slots:
void on_exitButton_clicked();
void on_settingsButton_clicked();
void onSignedIn();
void onParticleUpdated(const QString &streamId, const QString &particleId);
void onNewStreamClicked();
void onCreateStreamRequested(const QString &name, const QString &visibility, const QStringList &members);
void onAddMembersClicked();
void onNetworksChanged();
void onAddNetworkMemberRequested(const QString &networkId, const QString &email);
void onTreeSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected);
void onParticleSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected);
void onTextMessageSubmitted(const QString &text);
protected:
bool eventFilter(QObject *obj, QEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
void keyReleaseEvent(QKeyEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
private:
void buildUi();
void setupTrayIcon();
void setupModels();
QSystemTrayIcon *m_trayIcon;
// ── UI widgets ──
QWidget *m_topBar;
QSplitter *m_splitter;
QToolButton *m_settingsButton;
QToolButton *m_exitButton;
QTreeView *m_treeView;
QPushButton *m_newStreamButton;
QStackedWidget *m_contentStack;
TextParticleWidget *m_textParticleWidget;
MediaParticleWidget *m_mediaParticleWidget;
QLabel *m_creatorLabel;
QLabel *m_ackLabel;
QListView *m_particlesView;
QListWidget *m_membersList;
QToolButton *m_sendTextButton;
QToolButton *m_addMembersButton;
QStatusBar *m_statusBar;
QWidget *m_leftSidebar;
QWidget *m_contentView;
QWidget *m_rightSidebar;
NetworkDetailWidget *m_networkDetailWidget;
// ── Auth & dialogs ──
AuthManager *m_authManager;
AuthDialog *m_authDialog;
SettingsDialog *m_settingsDialog;
TextInputDialog *m_textInputDialog;
CreateStreamDialog *m_createStreamDialog;
// Data and models
Store *m_store;
NetworkStreamModel *m_networkStreamModel;
ParticleListModel *m_particleListModel;
void showTextInputDialog();
void updateStatusBar();
QString m_selectedParticleId;
QString m_selectedNetworkId;
QString m_selectedStreamId;
int m_selectedParticleRow;
QLabel *m_currentPathLabel;
QLabel *m_positionLabel;
QLabel *m_storageLabel;
QLabel *m_pingLabel;
QTimer *m_pingTimer;
// Window dragging
QPoint m_dragPosition;
bool m_isDragging;
MediaHandler *m_media;
MediaPreviewDialog *m_mediaPreviewDialog;
MediaParticleGenerator *m_mediaParticleGenerator;
bool m_spaceHeld;
// Temporary recording state
QUrl m_recordedFileUrl;
qint64 m_recordedDurationMs = 0;
QString m_recordedMimeType;
};
#endif // LLINK_MAINWINDOW_H
+228
View File
@@ -0,0 +1,228 @@
#include "authmanager.h"
#include "networkmanager.h"
#include <QDebug>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QSettings>
namespace
{
constexpr char key[] = "network/session_token";
}
AuthManager::AuthManager(QObject *parent)
: QObject{parent}, m_sessionData(nullptr), m_settings(new QSettings(this)), m_isSignedIn(false), m_lastEmail(""),
m_sessionToken("")
{
connect(&NetworkManager::instance(), &NetworkManager::unauthorizedDetected, this, [this]() {
if (!m_isSignedIn)
{
return;
}
setIsSignedIn(false);
if (m_sessionData)
{
delete m_sessionData;
m_sessionData = nullptr;
}
setSessionToken("");
});
}
void AuthManager::tryRestoreSession()
{
QString storageSessionToken = m_settings->value(key).toString();
if (storageSessionToken.isEmpty())
{
return;
}
qDebug() << "Storage session token is: " << storageSessionToken;
// set it here, and unset if it fails...network manager needs to inject into the request
setSessionToken(storageSessionToken);
QNetworkReply *reply = NetworkManager::instance().get("/auth/me");
connect(reply, &QNetworkReply::finished, this, [=]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError)
{
qDebug() << "Saved session token is invalid";
setSessionToken("");
return;
}
qDebug() << "Saved session token is valid";
QByteArray responseData = reply->readAll();
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData);
if (jsonDoc.isNull())
{
qWarning() << "Failed to create a JSON doc.";
setSessionToken("");
return;
}
QJsonObject jsonObj = jsonDoc.object();
if (!m_sessionData)
{
m_sessionData = new SessionData{};
}
m_sessionData->id = jsonObj["id"].toString();
m_sessionData->email = jsonObj["email"].toString();
m_sessionData->emailPrefix = jsonObj["email_prefix"].toString();
setSessionToken(storageSessionToken);
setIsSignedIn(true);
});
}
void AuthManager::setIsSignedIn(bool newValue)
{
if (m_isSignedIn == newValue)
{
return;
}
m_isSignedIn = newValue;
emit isSignedInChanged();
if (m_isSignedIn)
{
emit signedIn();
}
else
{
emit signedOut();
}
}
bool AuthManager::isSignedIn() const
{
return m_isSignedIn;
}
SessionData *AuthManager::sessionData() const
{
return m_sessionData;
}
void AuthManager::requestSignInCode(const QString &email)
{
if (isSignedIn())
{
qWarning() << "Must sign out first";
return;
}
QJsonDocument body;
QJsonObject object;
object["email"] = email;
body.setObject(object);
QNetworkReply *reply = NetworkManager::instance().post("/auth/request-code", body);
connect(reply, &QNetworkReply::finished, this, [=]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError)
{
emit errorOccurred("Unable to send code. Please try again.");
qDebug() << "Error in emailing auth code: " << reply->errorString();
return;
}
qDebug() << "Code successfully sent";
m_lastEmail = email;
emit codeSentToEmail(email);
});
}
void AuthManager::signIn(const QString &code)
{
if (isSignedIn())
{
qWarning() << "Must sign out first";
return;
}
assert(!m_lastEmail.isEmpty());
QJsonDocument body;
QJsonObject object;
object["email"] = m_lastEmail;
object["code"] = code;
body.setObject(object);
QNetworkReply *reply = NetworkManager::instance().post("/auth/sign-in", body);
connect(reply, &QNetworkReply::finished, this, [=]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError)
{
emit errorOccurred("Unable to verify code. Please try again.");
qDebug() << "Error in verify code: " << reply->errorString();
return;
}
qDebug() << "Session successfully created";
QByteArray responseData = reply->readAll();
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData);
if (jsonDoc.isNull())
{
qWarning() << "Failed to create a JSON doc.";
emit errorOccurred("Unable to verify code");
return;
}
QJsonObject jsonObj = jsonDoc.object();
if (!m_sessionData)
{
m_sessionData = new SessionData{};
}
QJsonObject human = jsonObj["human"].toObject();
m_sessionData->id = human["id"].toString();
m_sessionData->email = human["email"].toString();
m_sessionData->emailPrefix = human["email_prefix"].toString();
setSessionToken(jsonObj["token"].toString());
setIsSignedIn(true);
});
}
void AuthManager::signOut()
{
if (!isSignedIn())
{
qWarning() << "Not signed in, skipping sign out";
return;
}
setIsSignedIn(false);
if (m_sessionData)
{
delete m_sessionData;
m_sessionData = nullptr;
}
setSessionToken("");
QNetworkReply *reply = NetworkManager::instance().post("/auth/sign-out", QJsonDocument());
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
}
void AuthManager::setSessionToken(const QString &newValue)
{
if (newValue.isEmpty())
{
m_settings->remove(key);
}
else
{
m_settings->setValue(key, newValue);
}
m_sessionToken = newValue;
NetworkManager::instance().setSessionToken(newValue);
}
+74
View File
@@ -0,0 +1,74 @@
#ifndef AUTHMANAGER_H
#define AUTHMANAGER_H
#include <QObject>
QT_BEGIN_NAMESPACE
class QSettings;
QT_END_NAMESPACE
struct SessionData
{
QString id;
QString email;
QString emailPrefix;
};
class AuthManager : public QObject
{
Q_OBJECT
public:
explicit AuthManager(QObject *parent = nullptr);
bool isSignedIn() const;
/// @brief If isSignedIn() this will contain the human's session data.
/// @returns Session data for the authed human. nullptr if not signed in.
SessionData *sessionData() const;
public slots:
/// @brief Sends a code to the requested email.
///
/// You must verify the code in the signIn method to receive a session.
///
/// @params email to send code to.
void requestSignInCode(const QString &email);
/// @brief Creates a session if authenticated.
///
/// @params code that was sent to the email in the previous step
void signIn(const QString &code);
void signOut();
/// @brief checks if there is a cached session that is valid
void tryRestoreSession();
signals:
void sessionRestoreCompleted();
void isSignedInChanged();
void signedOut();
void signedIn();
void codeSentToEmail(const QString &email);
void errorOccurred(const QString &message);
private:
QString m_lastEmail;
bool m_isSignedIn;
void setIsSignedIn(bool);
SessionData *m_sessionData;
QSettings *m_settings;
QString m_sessionToken;
/// Sets session token internally to class, in storage, and sets networkmanager appropriately
/// Pass "" if you want to remove it.
void setSessionToken(const QString &);
};
#endif // AUTHMANAGER_H
+98
View File
@@ -0,0 +1,98 @@
#include "authdialog.h"
#include "ui_authdialog.h"
#include "../authmanager.h"
AuthDialog::AuthDialog(QWidget *parent)
: QDialog{parent}, ui(new Ui::Dialog)
{
ui->setupUi(this);
// Connect button signals
connect(ui->sendCodeButton, &QPushButton::clicked, this, &AuthDialog::onSendCodeClicked);
connect(ui->signInButton, &QPushButton::clicked, this, &AuthDialog::onSignInClicked);
connect(ui->goBackButton, &QPushButton::clicked, this, &AuthDialog::showEmailPage);
// Start on email page
showEmailPage();
}
AuthDialog::~AuthDialog()
{
delete ui;
}
void AuthDialog::reset()
{
ui->emailLineEdit->clear();
ui->codeLineEdit->clear();
clearError();
showEmailPage();
}
void AuthDialog::showEmailPage()
{
ui->stackedWidget->setCurrentIndex(0);
ui->emailLineEdit->setFocus();
clearError();
}
void AuthDialog::showCodePage()
{
ui->stackedWidget->setCurrentIndex(1);
ui->codeLineEdit->setFocus();
clearError();
}
void AuthDialog::clearError()
{
ui->errorMessage->setText("");
ui->errorMessage->setVisible(false);
ui->codeErrorMessage->setText("");
ui->codeErrorMessage->setVisible(false);
}
void AuthDialog::showError(const QString &message)
{
if (ui->stackedWidget->currentIndex() == 0)
{
// Update error labels on both pages
ui->errorMessage->setText(message);
ui->errorMessage->setVisible(true);
}
else
{
ui->codeErrorMessage->setText(message);
ui->codeErrorMessage->setVisible(true);
}
}
void AuthDialog::onSendCodeClicked()
{
QString email = ui->emailLineEdit->text().trimmed();
if (email.isEmpty())
{
showError("Please enter your email");
return;
}
clearError();
emit codeRequested(email);
}
void AuthDialog::onCodeSentToEmail(const QString &email)
{
showCodePage();
}
void AuthDialog::onSignInClicked()
{
QString code = ui->codeLineEdit->text().trimmed();
if (code.isEmpty())
{
showError("Please enter the code");
return;
}
clearError();
emit signInRequested(code);
}
+39
View File
@@ -0,0 +1,39 @@
#ifndef AUTHDIALOG_H
#define AUTHDIALOG_H
#include <QDialog>
namespace Ui {
class Dialog;
}
class AuthDialog : public QDialog
{
Q_OBJECT
public:
explicit AuthDialog(QWidget *parent = nullptr);
~AuthDialog();
public slots:
void reset();
void showError(const QString &message);
void onCodeSentToEmail(const QString &email);
private slots:
void onSendCodeClicked();
void onSignInClicked();
signals:
void codeRequested(const QString &email);
void signInRequested(const QString &code);
private:
Ui::Dialog *ui;
void showEmailPage();
void showCodePage();
void clearError();
};
#endif // AUTHDIALOG_H
+154
View File
@@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<widget class="QStackedWidget" name="stackedWidget">
<property name="geometry">
<rect>
<x>40</x>
<y>20</y>
<width>321</width>
<height>241</height>
</rect>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="enterEmailPage">
<widget class="QGroupBox" name="groupBox">
<property name="geometry">
<rect>
<x>50</x>
<y>20</y>
<width>231</width>
<height>183</height>
</rect>
</property>
<property name="title">
<string>Sign in</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="emailLabel">
<property name="text">
<string>Email</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="emailLineEdit">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="cursor">
<cursorShape>IBeamCursor</cursorShape>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="errorMessage">
<property name="text">
<string>Error</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="sendCodeButton">
<property name="text">
<string>Send code</string>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
<widget class="QWidget" name="enterCodePage">
<widget class="QGroupBox" name="groupBox_2">
<property name="geometry">
<rect>
<x>50</x>
<y>20</y>
<width>231</width>
<height>201</height>
</rect>
</property>
<property name="title">
<string>Verify code</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QLabel" name="codeLabel">
<property name="text">
<string>Enter the code we sent to your email</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="codeLineEdit">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="cursor">
<cursorShape>IBeamCursor</cursorShape>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="codeErrorMessage">
<property name="text">
<string>Error</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QPushButton" name="goBackButton">
<property name="text">
<string>Cancel</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="signInButton">
<property name="text">
<string>Sign In</string>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
</widget>
</widget>
<resources/>
<connections/>
</ui>
+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
+85
View File
@@ -0,0 +1,85 @@
#include "mediapreviewdialog.h"
#include <QAudioOutput>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QMediaPlayer>
#include <QPushButton>
#include <QVBoxLayout>
#include <QVideoWidget>
MediaPreviewDialog::MediaPreviewDialog(QWidget *parent)
: QDialog(parent)
{
setWindowTitle("Media Preview");
resize(480, 360);
setModal(true);
m_videoWidget = new QVideoWidget(this);
m_player = new QMediaPlayer(this);
m_audioOutput = new QAudioOutput(this);
m_player->setAudioOutput(m_audioOutput);
m_cancelButton = new QPushButton("Cancel", this);
m_sendButton = new QPushButton("Send", this);
m_sendButton->setDefault(true);
auto *buttonLayout = new QHBoxLayout();
buttonLayout->addWidget(m_cancelButton);
buttonLayout->addStretch();
buttonLayout->addWidget(m_sendButton);
auto *layout = new QVBoxLayout(this);
layout->addWidget(m_videoWidget, 1);
layout->addLayout(buttonLayout);
m_cancelButton->hide();
m_sendButton->hide();
connect(m_sendButton, &QPushButton::clicked, this, &QDialog::accept);
connect(m_cancelButton, &QPushButton::clicked, this, &QDialog::reject);
}
QVideoSink *MediaPreviewDialog::videoSink() const
{
return m_videoWidget->videoSink();
}
void MediaPreviewDialog::showRecording()
{
m_player->stop();
m_player->setVideoOutput(nullptr);
m_player->setSource(QUrl());
m_cancelButton->hide();
m_sendButton->hide();
show();
}
void MediaPreviewDialog::showPreview(const QUrl &mediaUrl)
{
m_cancelButton->show();
m_sendButton->show();
m_player->setVideoOutput(m_videoWidget);
m_player->setSource(mediaUrl);
m_player->setLoops(QMediaPlayer::Infinite);
m_player->play();
}
void MediaPreviewDialog::reset()
{
m_player->stop();
m_player->setVideoOutput(nullptr);
m_player->setSource(QUrl());
m_cancelButton->hide();
m_sendButton->hide();
hide();
}
void MediaPreviewDialog::keyReleaseEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Space && !event->isAutoRepeat())
{
emit spaceReleased();
return;
}
QDialog::keyReleaseEvent(event);
}
+49
View File
@@ -0,0 +1,49 @@
#ifndef MEDIAPREVIEWDIALOG_H
#define MEDIAPREVIEWDIALOG_H
#include <QDialog>
class QKeyEvent;
QT_BEGIN_NAMESPACE
class QVideoWidget;
class QVideoSink;
class QMediaPlayer;
class QAudioOutput;
class QPushButton;
QT_END_NAMESPACE
class MediaPreviewDialog : public QDialog
{
Q_OBJECT
public:
explicit MediaPreviewDialog(QWidget *parent = nullptr);
/// Video sink for live camera feed during recording
QVideoSink *videoSink() const;
/// Show dialog in recording mode (live camera preview, buttons hidden)
void showRecording();
/// Transition to preview mode: loop playback, show Cancel/Send buttons
void showPreview(const QUrl &mediaUrl);
/// Stop playback and reset state
void reset();
signals:
void spaceReleased();
protected:
void keyReleaseEvent(QKeyEvent *event) override;
private:
QVideoWidget *m_videoWidget;
QMediaPlayer *m_player;
QAudioOutput *m_audioOutput;
QPushButton *m_cancelButton;
QPushButton *m_sendButton;
};
#endif // MEDIAPREVIEWDIALOG_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
+95
View File
@@ -0,0 +1,95 @@
#include "settingsdialog.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QPushButton>
#include <QStackedWidget>
#include <QVBoxLayout>
SettingsDialog::SettingsDialog(QWidget *parent)
: QDialog(parent)
{
buildUi();
setModal(true);
m_listWidget->addItem("Account");
m_listWidget->addItem("Create Network");
connect(m_listWidget, &QListWidget::currentRowChanged,
m_stackedWidget, &QStackedWidget::setCurrentIndex);
m_listWidget->setCurrentRow(0);
connect(m_signOutButton, &QPushButton::clicked, this, [this]() {
emit signOutRequested();
close();
});
connect(m_createNetworkButton, &QPushButton::clicked, this, [this]() {
QString name = m_networkNameEdit->text().trimmed();
if (name.isEmpty())
return;
emit createNetworkRequested(name);
m_networkNameEdit->clear();
});
}
void SettingsDialog::setAuthLabel(const QString &text)
{
m_authLabel->setText(text);
}
void SettingsDialog::buildUi()
{
resize(600, 500);
setWindowTitle("Settings");
auto *mainLayout = new QHBoxLayout(this);
m_listWidget = new QListWidget();
m_listWidget->setMaximumWidth(220);
mainLayout->addWidget(m_listWidget);
m_stackedWidget = new QStackedWidget();
mainLayout->addWidget(m_stackedWidget);
// ── Account page ──
auto *accountPage = new QWidget();
auto *accountLayout = new QVBoxLayout(accountPage);
accountLayout->addStretch();
m_authLabel = new QLabel("Hello, ");
m_authLabel->setAlignment(Qt::AlignCenter);
accountLayout->addWidget(m_authLabel);
m_signOutButton = new QPushButton("Sign Out");
accountLayout->addWidget(m_signOutButton);
accountLayout->addStretch();
m_stackedWidget->addWidget(accountPage);
// ── Create Network page ──
auto *createNetworkPage = new QWidget();
auto *createNetworkLayout = new QVBoxLayout(createNetworkPage);
auto *createNetworkLabel = new QLabel("Create a new network");
QFont labelFont;
labelFont.setPointSize(16);
createNetworkLabel->setFont(labelFont);
createNetworkLayout->addWidget(createNetworkLabel);
m_networkNameEdit = new QLineEdit();
m_networkNameEdit->setPlaceholderText("Network name");
createNetworkLayout->addWidget(m_networkNameEdit);
m_createNetworkButton = new QPushButton("Create");
createNetworkLayout->addWidget(m_createNetworkButton);
createNetworkLayout->addStretch();
m_stackedWidget->addWidget(createNetworkPage);
}
+38
View File
@@ -0,0 +1,38 @@
#ifndef SETTINGSDIALOG_H
#define SETTINGSDIALOG_H
#include <QDialog>
QT_BEGIN_NAMESPACE
class QLabel;
class QLineEdit;
class QListWidget;
class QPushButton;
class QStackedWidget;
QT_END_NAMESPACE
class SettingsDialog : public QDialog
{
Q_OBJECT
public:
explicit SettingsDialog(QWidget *parent = nullptr);
void setAuthLabel(const QString &text);
signals:
void signOutRequested();
void createNetworkRequested(const QString &name);
private:
void buildUi();
QListWidget *m_listWidget;
QStackedWidget *m_stackedWidget;
QLabel *m_authLabel;
QPushButton *m_signOutButton;
QLineEdit *m_networkNameEdit;
QPushButton *m_createNetworkButton;
};
#endif // SETTINGSDIALOG_H
+30
View File
@@ -0,0 +1,30 @@
#include "textinputdialog.h"
#include <QLineEdit>
#include <QPushButton>
#include <QVBoxLayout>
TextInputDialog::TextInputDialog(QWidget *parent)
: QDialog(parent), m_lineEdit(new QLineEdit(this)), m_sendButton(new QPushButton("Send", this))
{
setWindowTitle("Send Text Message");
auto *layout = new QVBoxLayout(this);
layout->addWidget(m_lineEdit);
layout->addWidget(m_sendButton);
connect(m_lineEdit, &QLineEdit::returnPressed, this, &TextInputDialog::onSubmit);
connect(m_sendButton, &QPushButton::clicked, this, &TextInputDialog::onSubmit);
}
void TextInputDialog::onSubmit()
{
QString text = m_lineEdit->text().trimmed();
if (text.isEmpty())
{
return;
}
emit textSubmitted(text);
m_lineEdit->clear();
hide();
}
+29
View File
@@ -0,0 +1,29 @@
#ifndef TEXTINPUTDIALOG_H
#define TEXTINPUTDIALOG_H
#include <QDialog>
QT_BEGIN_NAMESPACE
class QLineEdit;
class QPushButton;
QT_END_NAMESPACE
class TextInputDialog : public QDialog
{
Q_OBJECT
public:
explicit TextInputDialog(QWidget *parent = nullptr);
signals:
void textSubmitted(const QString &text);
private slots:
void onSubmit();
private:
QLineEdit *m_lineEdit;
QPushButton *m_sendButton;
};
#endif // TEXTINPUTDIALOG_H
+339
View File
@@ -0,0 +1,339 @@
#include "mediahandler.h"
#include <QPermission>
#include <QMediaCaptureSession>
#include <QApplication>
#include <QCameraDevice>
#include <QCameraFormat>
#include <QMediaRecorder>
#include <QMediaFormat>
#include <QMimeType>
#include <QAudioInput>
#include <QCamera>
#include <QUrl>
MediaHandler::MediaHandler(QObject *parent) : QObject{parent},
m_captureSession{nullptr},
m_audioInput{nullptr},
m_camera{nullptr},
m_videoSink{nullptr},
m_recorder{nullptr}
{
}
MediaHandler::~MediaHandler()
{
}
MediaHandler::State MediaHandler::state() const
{
return m_state;
}
MediaHandler::CaptureMode MediaHandler::captureMode() const
{
return m_captureMode;
}
QVideoSink *MediaHandler::videoSink() const
{
return m_videoSink;
}
void MediaHandler::startPreview(QVideoSink *sink)
{
if (m_state != OFF)
{
return;
}
m_pendingRecord = false;
startCapture(sink);
}
void MediaHandler::startRecording(QVideoSink *sink)
{
if (m_state == RECORDING || m_state == PREPARING)
{
return;
}
if (m_state == ON)
{
m_cancelledRecording = false;
m_recorder->record();
return;
}
// OFF
m_pendingRecord = true;
startCapture(sink);
}
void MediaHandler::startCapture(QVideoSink *sink)
{
setState(PREPARING);
// microphone permission (always needed)
QMicrophonePermission microphonePermission;
switch (qApp->checkPermission(microphonePermission))
{
case Qt::PermissionStatus::Undetermined:
qApp->requestPermission(microphonePermission, this, [this, sink]() {
startCapture(sink);
});
return;
case Qt::PermissionStatus::Denied:
qDebug("Microphone permission is not granted!");
setState(OFF);
emit micPermissionDenied();
return;
case Qt::PermissionStatus::Granted:
qDebug("Mic permission granted.");
break;
}
// camera permission (skip for AUDIO_ONLY)
if (m_captureMode != AUDIO_ONLY)
{
QCameraPermission cameraPermission;
switch (qApp->checkPermission(cameraPermission))
{
case Qt::PermissionStatus::Undetermined:
qApp->requestPermission(cameraPermission, this, [this, sink]() {
startCapture(sink);
});
return;
case Qt::PermissionStatus::Denied:
qDebug("Camera permission is not granted!");
setState(OFF);
emit cameraPermissionDenied();
return;
case Qt::PermissionStatus::Granted:
qDebug("Camera permission granted.");
break;
}
}
// Create capture session and audio input
if (!m_captureSession)
{
m_captureSession = new QMediaCaptureSession{this};
}
// TODO: handle QMediaDevices:audioInputsChanged()
if (!m_audioInput)
{
m_audioInput = new QAudioInput{this};
m_captureSession->setAudioInput(m_audioInput);
}
// Camera setup (skip for AUDIO_ONLY)
// TODO: handle QMediaDevices:videoInputsChanged()
if (m_captureMode != AUDIO_ONLY)
{
if (!m_camera)
{
QCameraDevice cameraDevice = QMediaDevices::defaultVideoInput();
if (cameraDevice.videoFormats().empty())
{
setState(OFF);
emit errorOccurred("Unable to find suitable camera formats");
return;
}
QCameraFormat cameraFormat = cameraDevice.videoFormats().at(0);
for (auto format : cameraDevice.videoFormats())
{
qDebug() << "Format: " << format.resolution();
qDebug() << "framerate: " << format.minFrameRate() << " " << format.maxFrameRate();
if (format.isNull())
{
continue;
}
if (format.resolution().width() == 1280 && format.resolution().height() == 720)
{
cameraFormat = format;
}
}
m_camera = new QCamera(cameraDevice);
m_camera->setCameraFormat(cameraFormat);
connect(m_camera, &QCamera::activeChanged, this, [this](bool active) {
if (active)
{
setState(ON);
if (m_pendingRecord)
{
m_pendingRecord = false;
m_cancelledRecording = false;
m_recorder->record();
}
}
});
connect(m_camera, &QCamera::errorOccurred, this, [this](QCamera::Error error, const QString &message) {
if (error != QCamera::NoError)
{
stop();
emit errorOccurred(message);
}
});
m_captureSession->setCamera(m_camera);
}
m_videoSink = sink;
m_captureSession->setVideoSink(m_videoSink);
}
else
{
// AUDIO_ONLY: no camera
m_captureSession->setCamera(nullptr);
m_captureSession->setVideoSink(nullptr);
m_videoSink = nullptr;
}
// Recorder setup
if (!m_recorder)
{
QMediaFormat mediaFormat{QMediaFormat::MPEG4};
mediaFormat.setAudioCodec(QMediaFormat::AudioCodec::AAC);
if (m_captureMode != AUDIO_ONLY)
{
mediaFormat.setVideoCodec(QMediaFormat::VideoCodec::H264);
}
if (!mediaFormat.isSupported(QMediaFormat::Encode))
{
setState(OFF);
emit errorOccurred("Unsupported codec");
return;
}
m_recorder = new QMediaRecorder(this);
m_captureSession->setRecorder(m_recorder);
m_recorder->setQuality(QMediaRecorder::HighQuality);
m_recorder->setAutoStop(true);
m_recorder->setMediaFormat(mediaFormat);
connect(m_recorder, &QMediaRecorder::recorderStateChanged, this, [this](QMediaRecorder::RecorderState state) {
switch (state)
{
case QMediaRecorder::StoppedState:
if (m_state == RECORDING)
{
setState(ON);
}
if (!m_cancelledRecording)
{
qint64 duration = m_recorder->duration();
QString mimeType = m_recorder->mediaFormat().mimeType().name();
qDebug() << "Recording location " << m_recorder->actualLocation().toString();
emit recordingComplete(m_recorder->actualLocation(), duration, mimeType);
}
m_cancelledRecording = false;
break;
case QMediaRecorder::PausedState:
break;
case QMediaRecorder::RecordingState:
setState(RECORDING);
break;
}
});
}
if (m_captureMode != AUDIO_ONLY)
{
m_camera->start();
}
else
{
// AUDIO_ONLY: no async camera startup, go straight to ON
setState(ON);
if (m_pendingRecord)
{
m_pendingRecord = false;
m_cancelledRecording = false;
m_recorder->record();
}
}
}
void MediaHandler::stop()
{
if (m_state == OFF)
{
return;
}
m_pendingRecord = false;
if (m_state == RECORDING && m_recorder)
{
m_cancelledRecording = true;
m_recorder->stop();
}
if (m_camera)
{
m_camera->stop();
delete m_camera;
m_camera = nullptr;
}
if (m_captureSession)
{
m_captureSession->setVideoSink(nullptr);
}
m_videoSink = nullptr;
if (m_recorder)
{
delete m_recorder;
m_recorder = nullptr;
}
setState(OFF);
}
void MediaHandler::recordStop()
{
if (m_state != RECORDING)
{
return;
}
m_cancelledRecording = false;
m_recorder->stop();
}
void MediaHandler::recordCancel()
{
if (m_state != RECORDING)
{
return;
}
m_cancelledRecording = true;
m_recorder->stop();
}
void MediaHandler::setCaptureMode(CaptureMode newMode)
{
if (m_captureMode == newMode)
{
return;
}
m_captureMode = newMode;
emit captureModeChanged();
}
void MediaHandler::setState(State state)
{
if (m_state == state)
{
return;
}
m_state = state;
emit stateChanged();
}
+99
View File
@@ -0,0 +1,99 @@
#ifndef MEDIAHANDLER_H
#define MEDIAHANDLER_H
#include <QObject>
#include <QMediaDevices>
QT_BEGIN_NAMESPACE
class QMediaCaptureSession;
class QVideoSink;
class QAudioInput;
class QCamera;
class QMediaRecorder;
QT_END_NAMESPACE
// TODO: create simple structs to represent camera and audio input devices
/// MediaHandler allows you to preview and capture video, audio, and screen
class MediaHandler : public QObject
{
Q_OBJECT
public:
explicit MediaHandler(QObject *parent = nullptr);
~MediaHandler();
enum State
{
OFF = 0,
PREPARING,
ON,
RECORDING
};
State state() const;
enum CaptureMode
{
AUDIO_ONLY = 0,
CAMERA,
SCREEN
};
CaptureMode captureMode() const;
QVideoSink *videoSink() const;
// QList<CameraDevices>
// QList<AudioDevices>
public slots:
/// Only impacts the next time start is called
void setCaptureMode(CaptureMode);
/// Start preview: OFF → ON
void startPreview(QVideoSink *sink = nullptr);
/// Start recording: OFF → ON → RECORDING (auto-records when ready)
/// or ON → RECORDING (immediate)
void startRecording(QVideoSink *sink = nullptr);
/// Clean up resources, will stop all capture, recording, preview
void stop();
void recordCancel();
void recordStop();
// void setCameraDevice();
// void setAudioInputDevice();
signals:
/// User has denied mic permission, and must go to settings to enable
void micPermissionDenied();
/// User has denied camera permission, and must go to settings to enable
void cameraPermissionDenied();
void captureModeChanged();
void stateChanged();
void recordingComplete(const QUrl &absoluteFilePath, qint64 durationMs, const QString &mimeType);
void errorOccurred(const QString &);
private:
QMediaCaptureSession *m_captureSession;
QAudioInput *m_audioInput;
QCamera *m_camera;
QVideoSink *m_videoSink;
QMediaRecorder *m_recorder;
QMediaDevices m_devices;
CaptureMode m_captureMode = CAMERA;
State m_state = OFF;
void setState(State);
void startCapture(QVideoSink *sink);
bool m_pendingRecord = false;
bool m_cancelledRecording = false;
};
#endif //MEDIAHANDLER_H
+152
View File
@@ -0,0 +1,152 @@
#include "mediaparticlegenerator.h"
#include "networkmanager.h"
#include "store.h"
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
MediaParticleGenerator::MediaParticleGenerator(Store *store, QObject *parent)
: QObject{parent}, m_store{store}, m_qnam{new QNetworkAccessManager{this}}
{
}
bool MediaParticleGenerator::isGenerating() const
{
return m_isGenerating;
}
void MediaParticleGenerator::generate(const QUrl &url, const QString &mimeType, const QString &networkId, qint64 durationMs, const QString &streamId)
{
if (m_isGenerating)
{
qWarning() << "Already generating media particle, ignoring request";
return;
}
if (!url.isValid())
{
emit errorOccurred("Invalid URL");
return;
}
assert(!mimeType.isEmpty() && "mimeType should never be empty here");
assert(!networkId.isEmpty() && "networkId should never be empty here");
QFile *file = new QFile{url.toLocalFile()};
assert(file->exists() && "File should exist at this point");
assert(file->size() > 0 && "File should have non-zero size at this point");
cleanup();
setGenerating(true);
m_uploadInfo = new UploadInfo{};
m_uploadInfo->networkId = networkId;
m_uploadInfo->streamId = streamId;
m_uploadInfo->mimeType = mimeType;
m_uploadInfo->durationMs = durationMs;
m_uploadInfo->file = file;
m_uploadInfo->bytes = file->size();
// 1. get presigned url
QJsonObject body;
body["network_id"] = m_uploadInfo->networkId;
body["name"] = url.fileName();
body["content_type"] = m_uploadInfo->mimeType;
body["content_length"] = m_uploadInfo->bytes;
QNetworkReply *reply = NetworkManager::instance().post("/depot/upload", QJsonDocument(body));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
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();
}
qWarning() << message;
cleanup();
emit errorOccurred("Failed to upload media");
return;
}
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
onUploadUrlReceived(doc);
});
}
void MediaParticleGenerator::cleanup()
{
if (m_uploadInfo)
{
if (m_uploadInfo->file)
{
m_uploadInfo->file->close();
delete m_uploadInfo->file;
}
delete m_uploadInfo;
m_uploadInfo = nullptr;
}
setGenerating(false);
}
void MediaParticleGenerator::onUploadUrlReceived(const QJsonDocument &response)
{
QJsonObject obj = response.object();
m_uploadInfo->uploadUrl = obj["upload_url"].toString();
QJsonObject uploadHeaders = obj["upload_headers"].toObject();
m_uploadInfo->objectId = obj["object_id"].toString();
if (!m_uploadInfo->file->open(QIODevice::ReadOnly))
{
cleanup();
emit errorOccurred("Failed to open media file");
return;
}
QNetworkRequest request = QNetworkRequest(QUrl(m_uploadInfo->uploadUrl));
for (auto it = uploadHeaders.constBegin(); it != uploadHeaders.constEnd(); ++it)
{
request.setRawHeader(it.key().toUtf8(), it.value().toString().toUtf8());
}
QNetworkReply *reply = m_qnam->put(request, m_uploadInfo->file);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError)
{
qWarning() << "Failed to upload media: " << reply->errorString();
cleanup();
emit errorOccurred("Failed to upload media");
return;
}
onUploadComplete();
});
}
void MediaParticleGenerator::setGenerating(bool generating)
{
if (m_isGenerating != generating)
{
m_isGenerating = generating;
emit isGeneratingChanged();
}
}
void MediaParticleGenerator::onUploadComplete()
{
auto op = m_store->createParticle(m_uploadInfo->streamId, "media", {
{"object_id", m_uploadInfo->objectId},
{"duration_ms", m_uploadInfo->durationMs},
{"mime_type", m_uploadInfo->mimeType}
});
connect(op, &Operation::success, this, &MediaParticleGenerator::complete);
connect(op, &Operation::success, this, &MediaParticleGenerator::cleanup);
}
+59
View File
@@ -0,0 +1,59 @@
#ifndef MEDIAPARTICLEGENERATOR_H
#define MEDIAPARTICLEGENERATOR_H
#include <QObject>
class Store;
QT_BEGIN_NAMESPACE
class QNetworkAccessManager;
class QFile;
class QNetworkReply;
QT_END_NAMESPACE
class MediaParticleGenerator : public QObject
{
Q_OBJECT
public:
explicit MediaParticleGenerator(Store *, QObject *parent = nullptr);
bool isGenerating() const;
public slots:
void generate(const QUrl &url, const QString &mimeType, const QString &networkId, qint64 durationMs, const QString &streamId);
signals:
void isGeneratingChanged();
void errorOccurred(const QString &message);
void complete();
private:
bool m_isGenerating = false;
void setGenerating(bool generating);
Store *m_store;
QNetworkAccessManager *m_qnam;
void onUploadUrlReceived(const QJsonDocument &response);
void onUploadComplete();
/// Does not support cancelling in flight, but will clean up after completion or error
void cleanup();
struct UploadInfo
{
QString networkId;
QString streamId;
QString mimeType;
qint64 durationMs;
QFile *file = nullptr;
QString objectId;
QString uploadUrl;
qint64 bytes;
};
UploadInfo *m_uploadInfo = nullptr;
};
#endif //MEDIAPARTICLEGENERATOR_H
+51
View File
@@ -0,0 +1,51 @@
#ifndef MODELS_H
#define MODELS_H
#include <QObject>
#include <QDateTime>
#include <QMap>
#include <QVariant>
#include <vector>
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<QString> memberEmails;
int unseenCount;
};
struct Particle
{
QString id;
QMap<QString, QVariant> data;
QString type;
QString createdByEmail;
bool seen;
std::vector<QString> ackedByEmails;
QDateTime updatedAt;
QDateTime createdAt;
};
struct Network
{
QString id;
QString name;
Human admin;
std::vector<Human> members;
int openStreamCount;
int openStreamCapacity;
std::vector<Stream> streams;
};
#endif //MODELS_H
+146
View File
@@ -0,0 +1,146 @@
#include "networkmanager.h"
#include <QElapsedTimer>
#include <QJsonDocument>
#include <QNetworkReply>
namespace
{
constexpr char API_URL[] = "https://orion.dev.flowy.live";
}
NetworkManager::NetworkManager()
{
m_qnam = new QNetworkAccessManager(this);
connect(m_qnam, &QNetworkAccessManager::finished, this, [this](QNetworkReply *reply) {
// In case particular instances of QNetworkReply are not cleaned up.
// It's safe to be redundant with deleteLater
// Calling deleteLater here also doesn't disrupt QNetworkReply::finished slots, as this both here
// and in those slots are fired in tandem and deleteLater waits for return to event loop to perform deleteLater
reply->deleteLater();
int statusCode = reply->attribute(QNetworkRequest::Attribute::HttpStatusCodeAttribute).toInt();
qDebug() << "Network request status code: " << statusCode;
if (statusCode == 401)
{
qWarning() << "Unauthorized request: " << reply->errorString();
emit unauthorizedDetected();
}
});
}
NetworkManager::~NetworkManager()
{
}
NetworkManager &NetworkManager::instance()
{
static NetworkManager instance;
return instance;
}
void NetworkManager::setAuthHeader(QNetworkRequest &request)
{
if (m_sessionToken.isEmpty())
{
return;
}
QString headerValue = QString("Bearer %1").arg(m_sessionToken);
request.setRawHeader("Authorization", headerValue.toUtf8());
}
QString NetworkManager::getFullUrl(const QString &url)
{
return QString("%1%2").arg(API_URL, url);
}
QNetworkReply *NetworkManager::get(const QString &path)
{
QNetworkRequest req = QNetworkRequest(getFullUrl(path));
setAuthHeader(req);
return m_qnam->get(req);
}
QNetworkReply *NetworkManager::post(const QString &path, const QJsonDocument &doc)
{
QNetworkRequest request = QNetworkRequest(getFullUrl(path));
setAuthHeader(request);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
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());
}
QNetworkRequest NetworkManager::buildRequest(const QString &path)
{
QNetworkRequest request(getFullUrl(path));
setAuthHeader(request);
return request;
}
QString NetworkManager::sessionToken() const
{
return m_sessionToken;
}
void NetworkManager::setSessionToken(const QString &newToken)
{
m_sessionToken = newToken;
}
class PingTimer : public QObject
{
public:
QElapsedTimer timer;
explicit PingTimer(QObject *parent = nullptr) : QObject(parent) { timer.start(); }
};
void NetworkManager::ping()
{
auto *pingTimer = new PingTimer(this);
QNetworkRequest request(getFullUrl(""));
QNetworkReply *reply = m_qnam->get(request);
connect(reply, &QNetworkReply::finished, pingTimer, [this, pingTimer, reply]() {
int elapsed = static_cast<int>(pingTimer->timer.elapsed());
pingTimer->deleteLater();
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (statusCode > 0)
{
emit pingResult(elapsed);
}
else
{
emit pingFailed();
}
});
}
+63
View File
@@ -0,0 +1,63 @@
#ifndef NETWORKMANAGER_H
#define NETWORKMANAGER_H
#include <QObject>
#include <QNetworkRequest>
QT_BEGIN_NAMESPACE
class QNetworkReply;
class QNetworkAccessManager;
class QJsonDocument;
QT_END_NAMESPACE
class NetworkManager : public QObject
{
Q_OBJECT
public:
static NetworkManager &instance();
// Delete copy constructor and assignment
NetworkManager(const NetworkManager &) = delete;
void operator=(const NetworkManager &) = delete;
QString sessionToken() const;
public slots:
// Convenience methods that inject session token.
// 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(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);
void ping();
/// @brief Builds an authenticated QNetworkRequest for the given path.
/// Callers can further configure the request (e.g. redirect policy) before sending.
QNetworkRequest buildRequest(const QString &path);
/// @brief sets session token which will is injected into every request's headers
void setSessionToken(const QString &token);
signals:
/// @brief Emitted when a request is made and fails required authentication.
void unauthorizedDetected();
void pingResult(int ms);
void pingFailed();
private:
NetworkManager();
~NetworkManager();
QString m_sessionToken;
QNetworkAccessManager *m_qnam;
void setAuthHeader(QNetworkRequest &request);
/// @brief Combines the api url with given path
/// @param path should be like "/auth/me"
QString getFullUrl(const QString &path);
};
#endif // NETWORKMANAGER_H
+259
View File
@@ -0,0 +1,259 @@
#include "networkstreammodel.h"
#include <QBrush>
#include <QColor>
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();
});
// 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
{
if (!hasIndex(row, column, parent))
{
return QModelIndex();
}
if (!parent.isValid())
{
// Top-level item (Network)
// Encode: networkIndex in lower 32 bits, -1 in upper 32 bits
quintptr id = static_cast<quintptr>(row) | (static_cast<quintptr>(0xFFFFFFFF) << 32);
return createIndex(row, column, id);
}
else
{
// Child item (Stream)
// Decode parent to get network index
quintptr parentId = parent.internalId();
int networkIndex = static_cast<int>(parentId & 0xFFFFFFFF);
// Encode: streamIndex in lower 32 bits, networkIndex in upper 32 bits
quintptr id = static_cast<quintptr>(row) | (static_cast<quintptr>(networkIndex) << 32);
return createIndex(row, column, id);
}
}
QModelIndex NetworkStreamModel::parent(const QModelIndex &child) const
{
if (!child.isValid())
{
return QModelIndex();
}
quintptr id = child.internalId();
int networkIndex = static_cast<int>((id >> 32) & 0xFFFFFFFF);
// If networkIndex is 0xFFFFFFFF, this is a network item (no parent)
if (networkIndex == 0xFFFFFFFF)
{
return QModelIndex();
}
// This is a stream item, return its parent network
quintptr parentId = static_cast<quintptr>(networkIndex) | (static_cast<quintptr>(0xFFFFFFFF) << 32);
return createIndex(networkIndex, 0, parentId);
}
int NetworkStreamModel::rowCount(const QModelIndex &parent) const
{
if (!parent.isValid())
{
// Root level - return number of networks
return m_store->networkCount();
}
quintptr id = parent.internalId();
int networkIndex = static_cast<int>((id >> 32) & 0xFFFFFFFF);
// If this is a network item, return number of streams
if (networkIndex == 0xFFFFFFFF)
{
int actualNetworkIndex = static_cast<int>(id & 0xFFFFFFFF);
if (actualNetworkIndex >= 0 && actualNetworkIndex < m_store->networks().size())
{
return static_cast<int>(m_store->networks().at(actualNetworkIndex).streams.size());
}
}
// Stream items have no children
return 0;
}
int NetworkStreamModel::columnCount(const QModelIndex &parent) const
{
Q_UNUSED(parent);
return 2; // Column 0: name, Column 1: unseen count
}
QVariant NetworkStreamModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid())
{
return QVariant();
}
quintptr id = index.internalId();
int networkIndex = static_cast<int>((id >> 32) & 0xFFFFFFFF);
if (networkIndex == 0xFFFFFFFF)
{
int actualNetworkIndex = static_cast<int>(id & 0xFFFFFFFF);
// This is a network item
if (role == Qt::DisplayRole && index.column() == 0)
{
if (actualNetworkIndex >= 0 && actualNetworkIndex < m_store->networks().size())
{
return m_store->networks().at(actualNetworkIndex).name.toUpper();
}
} else if (role == Qt::DisplayRole && index.column() == 1)
{
if (actualNetworkIndex >= 0 && actualNetworkIndex < m_store->networks().size())
{
auto net = m_store->networks().at(actualNetworkIndex);
return QString("%1/%2").arg(net.openStreamCount).arg(net.openStreamCapacity);
}
}
// Networks don't show unseen count in column 1
return QVariant();
}
else
{
// This is a stream item
int streamIndex = static_cast<int>(id & 0xFFFFFFFF);
if (networkIndex >= 0 && networkIndex < m_store->networks().size())
{
const auto &streams = m_store->networks().at(networkIndex).streams;
if (streamIndex >= 0 && streamIndex < static_cast<int>(streams.size()))
{
const Stream &stream = streams.at(streamIndex);
if (role == Qt::DisplayRole)
{
if (index.column() == 0)
{
// Column 0: stream name
return stream.name;
}
else if (index.column() == 1)
{
// Column 1: unseen count (only show if > 0)
if (stream.unseenCount > 0)
{
return stream.unseenCount;
}
}
}
else if (role == Qt::TextAlignmentRole && index.column() == 1)
{
// Right-align the unseen count
return static_cast<int>(Qt::AlignRight | Qt::AlignVCenter);
}
else if (role == Qt::ForegroundRole)
{
// Make streams with no unseen items grey (fully caught up)
if (stream.unseenCount == 0)
{
return QBrush(QColor(128, 128, 128));
}
// Streams with unseen items use default color
}
}
}
}
return QVariant();
}
QString NetworkStreamModel::streamIdFromIndex(const QModelIndex &index) const
{
if (!index.isValid())
{
return QString();
}
quintptr id = index.internalId();
int networkIndex = static_cast<int>((id >> 32) & 0xFFFFFFFF);
// Check if this is a stream item (not a network)
if (networkIndex != 0xFFFFFFFF)
{
int streamIndex = static_cast<int>(id & 0xFFFFFFFF);
if (networkIndex >= 0 && networkIndex < m_store->networks().size())
{
const auto &streams = m_store->networks().at(networkIndex).streams;
if (streamIndex >= 0 && streamIndex < static_cast<int>(streams.size()))
{
return streams.at(streamIndex).id;
}
}
}
return QString();
}
QString NetworkStreamModel::networkIdFromIndex(const QModelIndex &index) const
{
if (!index.isValid())
{
return QString();
}
quintptr id = index.internalId();
int networkIndex = static_cast<int>((id >> 32) & 0xFFFFFFFF);
// Check if this is a stream item (not a network)
if (networkIndex == 0xFFFFFFFF)
{
int actualNetworkIndex = static_cast<int>(id & 0xFFFFFFFF);
if (actualNetworkIndex >= 0 && actualNetworkIndex < m_store->networks().size())
{
return m_store->networks().at(actualNetworkIndex).id;
}
}
return QString();
}
bool NetworkStreamModel::isStreamItem(const QModelIndex &index) const
{
if (!index.isValid())
{
return false;
}
quintptr id = index.internalId();
int networkIndex = static_cast<int>((id >> 32) & 0xFFFFFFFF);
// If networkIndex is not 0xFFFFFFFF, this is a stream item
return networkIndex != 0xFFFFFFFF;
}
+30
View File
@@ -0,0 +1,30 @@
#ifndef NETWORKSTREAMMODEL_H
#define NETWORKSTREAMMODEL_H
#include <QAbstractItemModel>
#include "store.h"
class NetworkStreamModel : public QAbstractItemModel
{
Q_OBJECT
public:
explicit NetworkStreamModel(Store *store, QObject *parent = nullptr);
// Required QAbstractItemModel interface
QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override;
QModelIndex parent(const QModelIndex &child) const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
int columnCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
// Helper methods for MainWindow
QString streamIdFromIndex(const QModelIndex &index) const;
bool isStreamItem(const QModelIndex &index) const;
QString networkIdFromIndex(const QModelIndex &index) const;
private:
Store *m_store;
};
#endif // NETWORKSTREAMMODEL_H
+136
View File
@@ -0,0 +1,136 @@
#include "particlelistmodel.h"
#include <QApplication>
#include <QBrush>
#include <QColor>
#include <QFont>
#include <QStyle>
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();
}
});
// 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
{
if (parent.isValid())
{
return 0;
}
if (m_currentStreamId.isEmpty())
{
return 0;
}
return m_store->particleCount(m_currentStreamId);
}
QVariant ParticleListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid())
{
return QVariant();
}
if (m_currentStreamId.isEmpty())
{
return QVariant();
}
const QList<Particle> &particles = m_store->particlesForStream(m_currentStreamId);
if (index.row() < 0 || index.row() >= particles.size())
{
return QVariant();
}
const Particle &particle = particles.at(index.row());
if (role == Qt::DisplayRole)
{
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);
return display;
}
else if (role == Qt::ForegroundRole)
{
// Make seen particles subtle grey, unseen stay default color
if (particle.seen)
{
return QBrush(QColor(128, 128, 128)); // Medium grey for seen items
}
// Unseen items use default foreground color
}
return QVariant();
}
void ParticleListModel::setStreamId(const QString &streamId)
{
beginResetModel();
m_currentStreamId = streamId;
endResetModel();
}
int ParticleListModel::firstUnseenRow() const
{
if (m_currentStreamId.isEmpty())
{
return -1;
}
const QList<Particle> &particles = m_store->particlesForStream(m_currentStreamId);
for (int i = 0; i < particles.size(); ++i)
{
if (!particles.at(i).seen)
{
return i;
}
}
return -1; // All seen or empty
}
const Particle *ParticleListModel::particleAtRow(int row) const
{
if (m_currentStreamId.isEmpty())
{
return nullptr;
}
const QList<Particle> &particles = m_store->particlesForStream(m_currentStreamId);
if (row >= 0 && row < particles.size())
{
return &particles.at(row);
}
return nullptr;
}
+26
View File
@@ -0,0 +1,26 @@
#ifndef PARTICLELISTMODEL_H
#define PARTICLELISTMODEL_H
#include <QAbstractListModel>
#include "store.h"
class ParticleListModel : public QAbstractListModel
{
Q_OBJECT
public:
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;
void setStreamId(const QString &streamId);
int firstUnseenRow() const; // Returns -1 if none unseen
const Particle* particleAtRow(int row) const;
private:
Store *m_store;
QString m_currentStreamId;
};
#endif // PARTICLELISTMODEL_H
+822
View File
@@ -0,0 +1,822 @@
#include "store.h"
#include "networkmanager.h"
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
// ────────────────────────────────────────────────────────────────
// Operation
// ────────────────────────────────────────────────────────────────
Operation::Operation(QNetworkReply *reply, QObject *parent)
: QObject(parent)
{
connect(this, &Operation::finished, this, &QObject::deleteLater);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
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();
}
qWarning() << message;
emit failed(message);
emit finished();
return;
}
QByteArray body = reply->readAll();
QJsonDocument doc = body.isEmpty() ? QJsonDocument() : QJsonDocument::fromJson(body);
emit success(doc);
emit finished();
});
}
// ────────────────────────────────────────────────────────────────
// Store — construction
// ────────────────────────────────────────────────────────────────
Store::Store(QObject *parent)
: QObject(parent)
{
}
// ────────────────────────────────────────────────────────────────
// 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<Particle> 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<Stream> 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<Particle> &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<Particle> &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<Network> &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<Stream> 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<Stream> result;
result.reserve(static_cast<int>(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<int>(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<Particle> &Store::particlesForStream(const QString &streamId) const
{
static const QList<Particle> 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()
{
QNetworkReply *reply = NetworkManager::instance().get("/startup");
auto *op = new Operation(reply, this);
connect(op, &Operation::success, this, [this](const QJsonDocument &doc) {
if (!doc.isObject())
{
qWarning() << "Invalid startup data";
return;
}
populateFromStartupData(doc.object());
emit startupDataLoaded();
emit networksChanged();
});
return op;
}
// ────────────────────────────────────────────────────────────────
// Network operations
// ────────────────────────────────────────────────────────────────
Operation *Store::createNetwork(const QString &name)
{
QJsonObject body;
body["name"] = name;
QNetworkReply *reply = NetworkManager::instance().post("/networks", QJsonDocument(body));
auto *op = new Operation(reply, this);
connect(op, &Operation::success, this, [this](const QJsonDocument &doc) {
if (doc.isObject())
{
upsertNetwork(parseNetwork(doc.object()));
emit networksChanged();
}
});
return op;
}
Operation *Store::fetchNetworks()
{
QNetworkReply *reply = NetworkManager::instance().get("/networks");
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();
});
return op;
}
Operation *Store::fetchNetwork(const QString &networkId)
{
QNetworkReply *reply = NetworkManager::instance().get(QString("/networks/%1").arg(networkId));
auto *op = new Operation(reply, this);
connect(op, &Operation::success, this, [this, 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();
});
return op;
}
Operation *Store::addNetworkMembers(const QString &networkId, const QStringList &emails)
{
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));
return new Operation(reply, this);
}
Operation *Store::removeNetworkMember(const QString &networkId, const QString &email)
{
QNetworkReply *reply = NetworkManager::instance().del(
QString("/networks/%1/members/%2").arg(networkId, email));
return new Operation(reply, this);
}
Operation *Store::setStreamCapacity(const QString &networkId, int capacity)
{
QJsonObject body;
body["capacity"] = capacity;
QNetworkReply *reply = NetworkManager::instance().put(
QString("/networks/%1/capacity").arg(networkId), QJsonDocument(body));
return new Operation(reply, this);
}
// ────────────────────────────────────────────────────────────────
// Stream operations
// ────────────────────────────────────────────────────────────────
Operation *Store::createStream(const QString &networkId, const QString &name,
const QString &description, const QString &visibility,
const QStringList &members)
{
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));
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);
}
});
return op;
}
Operation *Store::fetchStream(const QString &streamId)
{
QNetworkReply *reply = NetworkManager::instance().get(QString("/streams/%1").arg(streamId));
auto *op = new Operation(reply, this);
connect(op, &Operation::success, this, [this, 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<Particle> particleList;
for (const QJsonValue &pv : particlesArr)
{
particleList.append(parseParticle(pv.toObject()));
}
m_particles[streamId] = particleList;
emit particlesChanged(streamId);
});
return op;
}
Operation *Store::updateStream(const QString &streamId, const QString &name, const QString &description)
{
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));
auto *op = new Operation(reply, this);
connect(op, &Operation::success, this, [this, 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);
}
}
});
return op;
}
Operation *Store::openStream(const QString &streamId)
{
QNetworkReply *reply = NetworkManager::instance().post(
QString("/streams/%1/open").arg(streamId), 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))
{
auto &streams = m_networks[m_networkIndex[networkId]].streams;
for (auto &s : streams)
{
if (s.id == streamId)
{
s.isOpen = true;
break;
}
}
emit streamsChanged(networkId);
}
});
return op;
}
Operation *Store::closeStream(const QString &streamId)
{
QNetworkReply *reply = NetworkManager::instance().post(
QString("/streams/%1/close").arg(streamId), 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))
{
auto &streams = m_networks[m_networkIndex[networkId]].streams;
for (auto &s : streams)
{
if (s.id == streamId)
{
s.isOpen = false;
break;
}
}
emit streamsChanged(networkId);
}
});
return op;
}
Operation *Store::addStreamMembers(const QString &streamId, const QStringList &emails)
{
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));
return new Operation(reply, this);
}
Operation *Store::removeStreamMembers(const QString &streamId, const QStringList &emails)
{
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));
return new Operation(reply, this);
}
// ────────────────────────────────────────────────────────────────
// Particle operations
// ────────────────────────────────────────────────────────────────
Operation *Store::createParticle(const QString &streamId, const QString &type,
const QMap<QString, QVariant> &data)
{
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));
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);
}
});
return op;
}
Operation *Store::fetchParticle(const QString &particleId)
{
QNetworkReply *reply = NetworkManager::instance().get(QString("/particles/%1").arg(particleId));
auto *op = new Operation(reply, this);
connect(op, &Operation::success, this, [this, 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);
}
}
});
return op;
}
Operation *Store::updateParticle(const QString &particleId, const QMap<QString, QVariant> &data)
{
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));
auto *op = new Operation(reply, this);
connect(op, &Operation::success, this, [this, 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);
}
}
});
return op;
}
Operation *Store::deleteParticle(const QString &particleId)
{
QNetworkReply *reply = NetworkManager::instance().del(QString("/particles/%1").arg(particleId));
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);
});
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)
{
it.value()[i].seen = true;
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;
}
}
}
QNetworkReply *reply = NetworkManager::instance().post(
QString("/particles/%1/seen").arg(particleId), QJsonDocument());
return new Operation(reply, this);
}
Operation *Store::markParticlesSeen(const QStringList &particleIds)
{
// Optimistic local update
QSet<QString> 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);
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));
return new Operation(reply, this);
}
void Store::setCurrentUserEmail(const QString &email) { m_currentUserEmail = email; }
QString Store::currentUserEmail() const { return m_currentUserEmail; }
Operation *Store::ackParticle(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)
{
auto &emails = it.value()[i].ackedByEmails;
if (!m_currentUserEmail.isEmpty() &&
std::find(emails.begin(), emails.end(), m_currentUserEmail) == emails.end())
{
emails.push_back(m_currentUserEmail);
emit particleUpdated(it.key(), particleId);
}
break;
}
}
}
QNetworkReply *reply = NetworkManager::instance().post(
QString("/particles/%1/ack").arg(particleId), QJsonDocument());
return new Operation(reply, this);
}
+134
View File
@@ -0,0 +1,134 @@
#ifndef STORE_H
#define STORE_H
#include "models.h"
#include <QList>
#include <QMap>
QT_BEGIN_NAMESPACE
class QJsonDocument;
class QNetworkReply;
QT_END_NAMESPACE
/// Represents an in-flight API operation.
/// 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(QNetworkReply *reply, QObject *parent = nullptr);
signals:
void success(const QJsonDocument &response);
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<Network> &networks() const;
int networkCount() const;
const Network *networkById(const QString &id) const;
QList<Stream> streamsForNetwork(const QString &networkId) const;
int streamCount(const QString &networkId) const;
const Stream *streamById(const QString &id) const;
const QList<Particle> &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<QString, QVariant> &data);
Operation *fetchParticle(const QString &particleId);
Operation *updateParticle(const QString &particleId, const QMap<QString, QVariant> &data);
Operation *deleteParticle(const QString &particleId);
Operation *markParticleSeen(const QString &particleId);
Operation *markParticlesSeen(const QStringList &particleIds);
Operation *ackParticle(const QString &particleId);
void setCurrentUserEmail(const QString &email);
QString currentUserEmail() const;
signals:
// ── Data-change signals ──
void networksChanged();
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);
private:
// ── Internal state ──
QList<Network> m_networks;
QMap<QString, QList<Particle>> m_particles; // streamId -> particles
QMap<QString, int> m_networkIndex; // networkId -> index in m_networks
QMap<QString, QString> m_streamToNetwork; // streamId -> networkId
QString m_currentUserEmail;
// ── 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;
};
#endif // STORE_H
+34
View File
@@ -0,0 +1,34 @@
//
// Created by arjun-flowylabs on 1/13/2026.
//
#include "VerticalLabel.h"
#include <QPainter>
VerticalLabel::VerticalLabel(const QString &text, QWidget *parent) : QLabel(text, parent)
{
}
QSize VerticalLabel::sizeHint() const
{
QSize s = QLabel::sizeHint();
return QSize(s.height(), s.width());
}
QSize VerticalLabel::minimumSizeHint() const
{
QSize s = QLabel::minimumSizeHint();
return QSize(s.height(), s.width());
}
void VerticalLabel::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(palette().color(QPalette::WindowText));
painter.setFont(font());
painter.translate(0, height());
painter.rotate(-90);
painter.drawText(QRect(0, 0, height(), width()), Qt::AlignCenter, text());
}
+23
View File
@@ -0,0 +1,23 @@
//
// Created by arjun-flowylabs on 1/13/2026.
//
#ifndef LLINK_VERTICALLABEL_H
#define LLINK_VERTICALLABEL_H
#include <QLabel>
class VerticalLabel : public QLabel
{
public:
explicit VerticalLabel(const QString &text, QWidget *parent = nullptr);
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
protected:
void paintEvent(QPaintEvent *) override;
};
#endif // LLINK_VERTICALLABEL_H
+92
View File
@@ -0,0 +1,92 @@
#include "mediaparticlewidget.h"
#include "../models.h"
#include "../networkmanager.h"
#include <QAudioOutput>
#include <QLabel>
#include <QMediaPlayer>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QVBoxLayout>
#include <QVideoWidget>
MediaParticleWidget::MediaParticleWidget(QWidget *parent)
: QWidget(parent)
{
m_nam = new QNetworkAccessManager(this);
// To prevent our network request from fetching bytes in memory
m_nam->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(2);
m_videoWidget = new QVideoWidget();
m_videoWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
layout->addWidget(m_videoWidget);
m_durationLabel = new QLabel();
m_durationLabel->setAlignment(Qt::AlignCenter);
layout->addWidget(m_durationLabel);
m_player = new QMediaPlayer(this);
m_audioOutput = new QAudioOutput(this);
m_player->setAudioOutput(m_audioOutput);
m_player->setVideoOutput(m_videoWidget);
m_player->setLoops(QMediaPlayer::Infinite);
connect(m_player, &QMediaPlayer::durationChanged, this, [this](qint64 duration) {
if (duration > 0)
{
int secs = static_cast<int>(duration / 1000);
int mins = secs / 60;
secs = secs % 60;
m_durationLabel->setText(QString("%1:%2").arg(mins).arg(secs, 2, 10, QChar('0')));
}
});
}
void MediaParticleWidget::setParticle(const Particle *particle)
{
if (!particle) return;
clear();
fetchAndPlay(particle->id);
}
void MediaParticleWidget::clear()
{
m_player->stop();
m_player->setSource(QUrl());
m_durationLabel->setText("");
}
void MediaParticleWidget::fetchAndPlay(const QString &particleId)
{
QNetworkRequest request = NetworkManager::instance().buildRequest("/particles/" + particleId + "/download");
QNetworkReply *reply = m_nam->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError)
{
qWarning() << "MediaParticleWidget: download request failed:" << reply->errorString();
return;
}
// m_nam uses ManualRedirectPolicy, so the reply stops at the 3xx
// without downloading the media. Extract the pre-signed URL from Location.
QUrl mediaUrl = reply->header(QNetworkRequest::LocationHeader).toUrl();
if (!mediaUrl.isValid())
{
qWarning() << "MediaParticleWidget: no Location header in download response";
return;
}
if (mediaUrl.isRelative())
{
mediaUrl = reply->url().resolved(mediaUrl);
}
m_player->setSource(mediaUrl);
m_player->play();
});
}
+32
View File
@@ -0,0 +1,32 @@
#ifndef MEDIAPARTICLEWIDGET_H
#define MEDIAPARTICLEWIDGET_H
#include <QWidget>
class QNetworkAccessManager;
class QVideoWidget;
class QMediaPlayer;
class QAudioOutput;
class QLabel;
struct Particle;
class MediaParticleWidget : public QWidget
{
Q_OBJECT
public:
explicit MediaParticleWidget(QWidget *parent = nullptr);
void setParticle(const Particle *particle);
void clear();
private:
QNetworkAccessManager *m_nam;
QVideoWidget *m_videoWidget;
QMediaPlayer *m_player;
QAudioOutput *m_audioOutput;
QLabel *m_durationLabel;
void fetchAndPlay(const QString &particleId);
};
#endif // MEDIAPARTICLEWIDGET_H
+101
View File
@@ -0,0 +1,101 @@
#include "networkdetailwidget.h"
#include <QInputDialog>
#include <QLabel>
#include <QListWidget>
#include <QProgressBar>
#include <QPushButton>
#include <QVBoxLayout>
#include <QHBoxLayout>
NetworkDetailWidget::NetworkDetailWidget(QWidget *parent)
: QWidget(parent)
{
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(16, 16, 16, 16);
// Network name
m_nameLabel = new QLabel(this);
QFont nameFont;
nameFont.setPointSize(24);
m_nameLabel->setFont(nameFont);
layout->addWidget(m_nameLabel);
// Admin label
m_adminLabel = new QLabel(this);
layout->addWidget(m_adminLabel);
// Stream capacity progress bar
m_streamBar = new QProgressBar(this);
m_streamBar->setMinimum(0);
m_streamBar->setTextVisible(false);
layout->addWidget(m_streamBar);
m_streamBarLabel = new QLabel(this);
layout->addWidget(m_streamBarLabel);
layout->addStretch();
// Members header
auto *membersHeader = new QLabel("Members", this);
QFont headerFont;
headerFont.setBold(true);
membersHeader->setFont(headerFont);
layout->addWidget(membersHeader);
// Members list
m_membersList = new QListWidget(this);
layout->addWidget(m_membersList, 1);
// Button row
auto *buttonRow = new QHBoxLayout;
m_addMemberBtn = new QPushButton("Add Member", this);
buttonRow->addWidget(m_addMemberBtn);
buttonRow->addStretch();
layout->addLayout(buttonRow);
connect(m_addMemberBtn, &QPushButton::clicked, this, [this]() {
bool ok;
QString email = QInputDialog::getText(this, "Add Member",
"Email address:", QLineEdit::Normal,
QString(), &ok);
if (ok && !email.isEmpty())
{
emit addMemberRequested(m_networkId, email);
}
});
}
void NetworkDetailWidget::setNetwork(const Network &network, const QString &currentUserEmail)
{
m_networkId = network.id;
m_nameLabel->setText(network.name);
m_adminLabel->setText("Admin: " + network.admin.emailPrefix);
m_streamBar->setMaximum(network.openStreamCapacity > 0 ? network.openStreamCapacity : 1);
m_streamBar->setValue(network.openStreamCount);
m_streamBarLabel->setText(QString("%1 / %2 open streams")
.arg(network.openStreamCount)
.arg(network.openStreamCapacity));
m_membersList->clear();
for (const auto &member : network.members)
{
m_membersList->addItem(member.email);
}
bool isAdmin = (!currentUserEmail.isEmpty() &&
currentUserEmail == network.admin.email);
m_addMemberBtn->setVisible(isAdmin);
}
void NetworkDetailWidget::clear()
{
m_networkId.clear();
m_nameLabel->setText("");
m_adminLabel->setText("");
m_streamBar->setValue(0);
m_streamBarLabel->setText("");
m_membersList->clear();
m_addMemberBtn->hide();
}
+38
View File
@@ -0,0 +1,38 @@
#ifndef NETWORKDETAILWIDGET_H
#define NETWORKDETAILWIDGET_H
#include "../models.h"
#include <QWidget>
QT_BEGIN_NAMESPACE
class QLabel;
class QProgressBar;
class QListWidget;
class QPushButton;
QT_END_NAMESPACE
class NetworkDetailWidget : public QWidget
{
Q_OBJECT
public:
explicit NetworkDetailWidget(QWidget *parent = nullptr);
void setNetwork(const Network &network, const QString &currentUserEmail);
void clear();
signals:
void addMemberRequested(const QString &networkId, const QString &email);
private:
QString m_networkId;
QLabel *m_nameLabel;
QLabel *m_adminLabel;
QProgressBar *m_streamBar;
QLabel *m_streamBarLabel;
QListWidget *m_membersList;
QPushButton *m_addMemberBtn;
};
#endif // NETWORKDETAILWIDGET_H
+35
View File
@@ -0,0 +1,35 @@
#include "textparticlewidget.h"
#include "../models.h"
#include <QLabel>
#include <QVBoxLayout>
TextParticleWidget::TextParticleWidget(QWidget *parent)
: QWidget(parent)
{
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addStretch();
m_label = new QLabel();
QFont font;
font.setPointSize(36);
m_label->setFont(font);
m_label->setAlignment(Qt::AlignCenter);
m_label->setWordWrap(true);
m_label->setScaledContents(true);
layout->addWidget(m_label);
layout->addStretch();
}
void TextParticleWidget::setParticle(const Particle *particle)
{
if (!particle) return;
m_label->setText(particle->data.value("content").toString());
}
void TextParticleWidget::clear()
{
m_label->setText("");
}
+22
View File
@@ -0,0 +1,22 @@
#ifndef TEXTPARTICLEWIDGET_H
#define TEXTPARTICLEWIDGET_H
#include <QWidget>
class QLabel;
struct Particle;
class TextParticleWidget : public QWidget
{
Q_OBJECT
public:
explicit TextParticleWidget(QWidget *parent = nullptr);
void setParticle(const Particle *particle);
void clear();
private:
QLabel *m_label;
};
#endif // TEXTPARTICLEWIDGET_H