Files
llink/src/MainWindow.cpp
T

579 lines
17 KiB
C++

//
// 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 <QApplication>
#include <QJsonDocument>
#include <QHeaderView>
#include <QItemSelectionModel>
#include <QLabel>
#include <QMenu>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QJsonObject>
#include <QToolButton>
#include <QSystemTrayIcon>
#include <QTimer>
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<QKeyEvent *>(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);
}