setup electron app with boilerplate
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 ¤tUserEmail)
|
||||
{
|
||||
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();
|
||||
}
|
||||
@@ -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 ¤tUserEmail);
|
||||
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
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user