// // Created by arjun-flowylabs on 1/10/2026. // #include "MainWindow.h" #include "authdialog.h" #include "authmanager.h" #include "createstreamdialog.h" #include "memberpickerdialog.h" #include "networkmanager.h" #include "settingsdialog.h" #include "textinputdialog.h" #include "ui_mainwindow.h" #include #include #include #include #include #include #include #include #include #include #include #include MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), m_trayIcon(nullptr), m_authManager(new AuthManager(this)), m_settingsDialog(nullptr), m_authDialog(nullptr), m_store(nullptr), m_networkStreamModel(nullptr), m_particleListModel(nullptr), m_selectedParticleRow(-1), m_isDragging(false), m_textInputDialog(nullptr), m_createStreamDialog(nullptr) { m_authDialog = new AuthDialog(this); m_textInputDialog = new TextInputDialog(this); connect(m_textInputDialog, &TextInputDialog::textSubmitted, this, &MainWindow::onTextMessageSubmitted); ui = new Ui::MainWindow; ui->setupUi(this); QWidget *topBar = ui->widget; 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); ui->statusbar->addWidget(m_currentPathLabel); // Add spacer to push right-aligned items to the right ui->statusbar->addPermanentWidget(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); ui->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); ui->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); ui->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(); ui->splitter->setStretchFactor(0, 1); ui->splitter->setStretchFactor(1, 4); ui->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, [this]() { m_store->setCurrentUserEmail(m_authManager->sessionData()->email); m_store->loadStartupData(); }); connect(m_store, &Store::startupDataLoaded, this, [this]() { ui->topLevelParticlesView->expandAll(); }); connect(m_store, &Store::particleUpdated, this, [this](const QString &, const QString &particleId) { if (particleId == m_selectedParticleId) { const Particle *particle = m_store->particleById(particleId); if (particle && !particle->ackedByEmails.empty()) { QStringList acked; for (const auto &email : particle->ackedByEmails) acked.append(email); ui->label_3->setText("Acked by: " + acked.join(", ")); } else { ui->label_3->setText(""); } } }); // DocumentSend toolbar button opens text input dialog connect(ui->toolButton_3, &QToolButton::clicked, this, &MainWindow::showTextInputDialog); // "New stream" button m_createStreamDialog = new CreateStreamDialog(this); connect(ui->pushButton, &QPushButton::clicked, this, [this]() { if (m_selectedNetworkName.isEmpty()) return; const Network *network = m_store->networkById(m_selectedNetworkName); if (network) m_createStreamDialog->setNetworkMembers(network->members); m_createStreamDialog->show(); m_createStreamDialog->raise(); m_createStreamDialog->activateWindow(); }); connect(m_createStreamDialog, &CreateStreamDialog::streamRequested, this, [this](const QString &name, const QString &visibility, const QStringList &members) { m_store->createStream(m_selectedNetworkName, name, "", visibility, members); }); // "Add members" toolbar button (ListAdd icon in right sidebar) connect(ui->toolButton_7, &QToolButton::clicked, this, [this]() { if (m_selectedStreamId.isEmpty() || m_selectedNetworkName.isEmpty()) return; const Network *network = m_store->networkById(m_selectedNetworkName); const Stream *stream = m_store->streamById(m_selectedStreamId); if (!network || !stream) return; MemberPickerDialog picker("Add Members", this); picker.setMembers(network->members, stream->memberEmails); if (picker.exec() == QDialog::Accepted) { QStringList emails = picker.selectedEmails(); if (!emails.isEmpty()) { auto *op = m_store->addStreamMembers(m_selectedStreamId, emails); connect(op, &Operation::success, this, [this](const QJsonDocument &) { auto *fetchOp = m_store->fetchStream(m_selectedStreamId); connect(fetchOp, &Operation::success, this, [this](const QJsonDocument &) { ui->membersList->clear(); const Stream *s = m_store->streamById(m_selectedStreamId); if (s) { for (const QString &email : s->memberEmails) ui->membersList->addItem(email.split("@")[0]); } }); }); } } }); m_authManager->tryRestoreSession(); setupTrayIcon(); } 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() { delete ui; } void MainWindow::on_exitButton_clicked() { qApp->exit(0); } void MainWindow::on_settingsButton_clicked() { if (!m_settingsDialog) { m_settingsDialog = new SettingsDialog(m_authManager, this); } m_settingsDialog->show(); } void MainWindow::setupModels() { // Create store (data loaded after sign-in via loadStartupData) m_store = new Store(this); // Create and set tree model m_networkStreamModel = new NetworkStreamModel(m_store, this); ui->topLevelParticlesView->setModel(m_networkStreamModel); ui->topLevelParticlesView->setIndentation(5); ui->topLevelParticlesView->setHeaderHidden(true); ui->topLevelParticlesView->expandAll(); // Configure column behavior: name stretches, unseen count is fixed width ui->topLevelParticlesView->header()->setStretchLastSection(false); ui->topLevelParticlesView->header()->setSectionResizeMode(0, QHeaderView::Stretch); ui->topLevelParticlesView->header()->setSectionResizeMode(1, QHeaderView::Fixed); ui->topLevelParticlesView->setColumnWidth(1, 40); // Create and set list model m_particleListModel = new ParticleListModel(m_store, this); ui->particlesView->setModel(m_particleListModel); // Configure particle list view to use ellipses for long text ui->particlesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); ui->particlesView->setTextElideMode(Qt::ElideRight); // Connect selection changes connect(ui->topLevelParticlesView->selectionModel(), &QItemSelectionModel::selectionChanged, this, &MainWindow::onTreeSelectionChanged); // Connect particle selection changes connect(ui->particlesView->selectionModel(), &QItemSelectionModel::selectionChanged, this, &MainWindow::onParticleSelectionChanged); // Install event filter to capture key presses from child views ui->topLevelParticlesView->installEventFilter(this); ui->particlesView->installEventFilter(this); } void MainWindow::onTreeSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected) { Q_UNUSED(deselected); if (selected.indexes().isEmpty()) { m_particleListModel->setStreamId(QString()); ui->membersList->clear(); return; } QModelIndex index = selected.indexes().first(); if (m_networkStreamModel->isStreamItem(index)) { 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_selectedNetworkName = networkId; // Show stream members ui->membersList->clear(); const Stream *stream = m_store->streamById(streamId); if (stream) { for (const QString &email : stream->memberEmails) { ui->membersList->addItem(email.split("@")[0]); } } // Auto-select first unseen particle (or first particle if all seen) int unseenRow = m_particleListModel->firstUnseenRow(); if (unseenRow >= 0) { ui->particlesView->setCurrentIndex(m_particleListModel->index(unseenRow, 0)); ui->particlesView->setFocus(); } else if (m_particleListModel->rowCount() > 0) { ui->particlesView->setCurrentIndex(m_particleListModel->index(m_particleListModel->rowCount() - 1, 0)); ui->particlesView->setFocus(); } } else // network selected { m_particleListModel->setStreamId(QString()); m_selectedStreamId.clear(); m_selectedParticleRow = -1; ui->membersList->clear(); QString networkId = m_networkStreamModel->networkIdFromIndex(index); m_selectedNetworkName = networkId; } updateStatusBar(); } void MainWindow::onParticleSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected) { Q_UNUSED(deselected); if (selected.indexes().isEmpty()) { ui->label->setText(""); ui->label_2->setText(""); ui->label_3->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) { ui->label_2->setText(particle->createdByEmail); m_selectedParticleId = particle->id; if (particle->type == "text") { ui->label->setText(particle->data.value("content").toString()); } else { ui->label->setText(""); } // Show ack info if (!particle->ackedByEmails.empty()) { QStringList acked; for (const auto &email : particle->ackedByEmails) acked.append(email); ui->label_3->setText("Acked by: " + acked.join(", ")); } else { ui->label_3->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_selectedNetworkName.isEmpty()) { const Network *network = m_store->networkById(m_selectedNetworkName); 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(event); 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; } } 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) { ui->particlesView->setCurrentIndex(m_particleListModel->index(lastRow, 0)); ui->particlesView->setFocus(); } }); } void MainWindow::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { // Check if the click is within the top bar widget area QWidget *topBar = ui->widget; // The top bar widget from the UI if (topBar && 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); }