diff --git a/qt-port/CMakeLists.txt b/qt-port/CMakeLists.txt index 011e3047..4d509192 100644 --- a/qt-port/CMakeLists.txt +++ b/qt-port/CMakeLists.txt @@ -15,6 +15,7 @@ set(COMIC_ART_DIR "${CMAKE_SOURCE_DIR}/../v1.0-pre-modern/comicart" add_library(comic_platform STATIC platform/QtCanvas.cpp + platform/BrowserLaunch.cpp ) target_include_directories(comic_platform PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} diff --git a/qt-port/app/ComicWidget.cpp b/qt-port/app/ComicWidget.cpp index bf4bf621..5b42dd3a 100644 --- a/qt-port/app/ComicWidget.cpp +++ b/qt-port/app/ComicWidget.cpp @@ -9,6 +9,7 @@ #include "platform/QtCanvas.h" #include +#include #include #include #include @@ -120,11 +121,13 @@ ComicWidget::ComicWidget(QWidget *parent) update(); }); connect(&m_rpg, &RpgActorClient::spriteReady, this, [this](const QString &nick) { + m_rpgFetchInFlight.remove(nick.trimmed().toLower()); // Sheet may have been cached by a parallel path; apply + refresh bodies. if (auto sheet = m_rpg.cachedSheetForNick(nick)) { applyRpgSheet(nick, *sheet); relayout(); update(); + emit contentResized(); } }); m_rpg.refreshRegistry(); @@ -234,20 +237,11 @@ void ComicWidget::ensureRpgSpriteAsync(const QString &nick) return; } m_rpgFetchInFlight.insert(key); - // Off the IRC/TLS stack: nested QEventLoop is OK once processLine has returned. - QTimer::singleShot(0, this, [this, nick, key]() { - if (m_scene.hasRpgSpriteForNick(nick.toStdString())) { - m_rpgFetchInFlight.remove(key); - return; - } - auto sheet = m_rpg.spriteSheetForNick(nick, 4000, /*allowLiveFetch=*/true); - m_rpgFetchInFlight.remove(key); - if (sheet && !sheet->isNull()) { - applyRpgSheet(nick, *sheet); - update(); - emit contentResized(); - } - }); + // True async (QNetworkReply) — never nest QEventLoop on the UI thread. + m_rpg.requestSpriteAsync(nick); + // Flight flag clears when spriteReady fires or after a short settle window + // (request may no-op for bare nicks with no DID). + QTimer::singleShot(15000, this, [this, key]() { m_rpgFetchInFlight.remove(key); }); } bool ComicWidget::looksLikeImageUrl(const QUrl &url) @@ -313,11 +307,16 @@ QString ComicWidget::stripUrls(const QString &text) } void ComicWidget::fetchAndShowImage(const QUrl &url, const QString &caption, - const QString &nick, const QString &msgid) + const QString &nick, const QString &msgid, + const QString ×tamp) { if (!url.isValid()) { return; } + if (m_panelBatchDepth > 0) { + m_deferredImageFetches.append({url, caption, nick, msgid, timestamp}); + return; + } // One panel per (url, nick) — history/live can otherwise fire the same fetch // multiple times and stamp the photo onto many frames. const QString who = nick.isEmpty() ? QStringLiteral("you") : nick; @@ -337,7 +336,8 @@ void ComicWidget::fetchAndShowImage(const QUrl &url, const QString &caption, QNetworkReply *reply = m_nam.get(req); const QString cap = caption; const QString mid = msgid; - connect(reply, &QNetworkReply::finished, this, [this, reply, who, cap, url, flightKey, mid]() { + connect(reply, &QNetworkReply::finished, this, [this, reply, who, cap, url, flightKey, mid, + timestamp]() { reply->deleteLater(); m_imageFetchInFlight.remove(flightKey); @@ -350,7 +350,11 @@ void ComicWidget::fetchAndShowImage(const QUrl &url, const QString &caption, return; } ensureRpgSprite(who, /*blocking=*/false); - m_scene.addLine(line.toStdString(), SM_SAY, who.toStdString()); + const QString ts = + timestamp.isEmpty() + ? QDateTime::currentDateTime().toString(QStringLiteral("MMM d, h:mm AP")) + : timestamp; + m_scene.addLine(line.toStdString(), SM_SAY, who.toStdString(), ts.toStdString()); if (!mid.isEmpty()) { m_scene.setMsgIdForLastBalloon(who.toStdString(), mid.toStdString()); } @@ -359,8 +363,7 @@ void ComicWidget::fetchAndShowImage(const QUrl &url, const QString &caption, while (m_imagesShown.size() > 64) { m_imagesShown.erase(m_imagesShown.begin()); } - relayout(); - update(); + finishPanelUpdate(); }; if (reply->error() != QNetworkReply::NoError) { @@ -387,7 +390,12 @@ void ComicWidget::fetchAndShowImage(const QUrl &url, const QString &caption, return; } ensureRpgSprite(who, /*blocking=*/false); - m_scene.addImageLine(img, cap.toStdString(), SM_SAY, who.toStdString()); + const QString ts = + timestamp.isEmpty() + ? QDateTime::currentDateTime().toString(QStringLiteral("MMM d, h:mm AP")) + : timestamp; + m_scene.addImageLine(img, cap.toStdString(), SM_SAY, who.toStdString(), + ts.toStdString()); if (!mid.isEmpty()) { m_scene.setMsgIdForLastBalloon(who.toStdString(), mid.toStdString()); } @@ -396,11 +404,41 @@ void ComicWidget::fetchAndShowImage(const QUrl &url, const QString &caption, while (m_imagesShown.size() > 64) { m_imagesShown.erase(m_imagesShown.begin()); } - relayout(); - update(); + finishPanelUpdate(); }); } +QString ComicWidget::formatMessageTime(const QHash &tags) +{ + QString raw = tags.value(QStringLiteral("server-time")); + if (raw.isEmpty()) { + raw = tags.value(QStringLiteral("time")); + } + if (raw.isEmpty()) { + return QDateTime::currentDateTime().toString(QStringLiteral("MMM d, h:mm AP")); + } + QString normalized = raw.trimmed(); + if (normalized.endsWith(QLatin1Char('Z'), Qt::CaseInsensitive)) { + normalized.chop(1); + QDateTime dt = QDateTime::fromString(normalized, Qt::ISODateWithMs); + if (!dt.isValid()) { + dt = QDateTime::fromString(normalized, Qt::ISODate); + } + if (dt.isValid()) { + dt.setTimeSpec(Qt::UTC); + return dt.toLocalTime().toString(QStringLiteral("MMM d, h:mm AP")); + } + } + QDateTime dt = QDateTime::fromString(raw, Qt::ISODateWithMs); + if (!dt.isValid()) { + dt = QDateTime::fromString(raw, Qt::ISODate); + } + if (!dt.isValid()) { + return raw; + } + return dt.toLocalTime().toString(QStringLiteral("MMM d, h:mm AP")); +} + QString ComicWidget::messageId(const QHash &tags) { // freeq / IRCv3: server-assigned msgid (sometimes Message-ID style). @@ -556,6 +594,17 @@ void ComicWidget::rememberIrcMessage(const QString &text, const QString &nick, (void)stamped; } +void ComicWidget::cacheMessageFromTags(const QString &text, const QString &nick, + const QHash &tags) +{ + const QString who = nick.isEmpty() ? QStringLiteral("you") : nick; + cacheMessage(messageId(tags), who, text); + const QString accountDid = tags.value(QStringLiteral("account")); + if (!accountDid.isEmpty() && accountDid.startsWith(QLatin1String("did:"))) { + m_rpg.rememberDidForNick(who, accountDid); + } +} + void ComicWidget::handlePossiblyMedia(const QString &text, const QString &nick, const QHash &tags, bool fastJoin) { @@ -572,7 +621,9 @@ void ComicWidget::handlePossiblyMedia(const QString &text, const QString &nick, m_rpg.rememberDidForNick(who, accountDid); } // History join: never block on HTTP. Live: async upgrade (cache hit is instant). - ensureRpgSprite(who, /*blocking=*/false); + if (!fastJoin) { + ensureRpgSprite(who, /*blocking=*/false); + } // freeq: remember every line by msgid so later +reply can re-stage the original. const QString msgid = messageId(tags); @@ -599,20 +650,22 @@ void ComicWidget::handlePossiblyMedia(const QString &text, const QString &nick, origNick = QStringLiteral("?"); origText = QStringLiteral("(original not in buffer)"); } - if (origNick != QLatin1String("?")) { + if (origNick != QLatin1String("?") && !fastJoin) { ensureRpgSprite(origNick, /*blocking=*/false); } - ensureRpgSprite(who, /*blocking=*/false); + if (!fastJoin) { + ensureRpgSprite(who, /*blocking=*/false); + } m_scene.addReplyExchange(origNick.toStdString(), origText.toStdString(), - who.toStdString(), text.toStdString(), SM_SAY); + who.toStdString(), text.toStdString(), SM_SAY, + formatMessageTime(tags).toStdString()); // Stamp msgid onto the reply balloon itself — reacts target this id. if (!msgid.isEmpty()) { m_scene.setMsgIdForLastBalloon(who.toStdString(), msgid.toStdString()); } // Also stamp origin-to-parent mapping? Keep parent lookup. m_scene.trimToMaxPanels(kMaxComicPanels); - relayout(); - update(); + finishPanelUpdate(); // Image replies: always fetch (async QNetworkReply — non-blocking). QString mediaUrl = tags.value(QStringLiteral("media-url")); @@ -634,7 +687,7 @@ void ComicWidget::handlePossiblyMedia(const QString &text, const QString &nick, if (alt.isEmpty()) { alt = stripUrls(text); } - fetchAndShowImage(QUrl(mediaUrl), alt, who, msgid); + fetchAndShowImage(QUrl(mediaUrl), alt, who, msgid, formatMessageTime(tags)); } return; } @@ -675,18 +728,47 @@ void ComicWidget::handlePossiblyMedia(const QString &text, const QString &nick, // Ensure speaker is on stage with a temporary text panel only if no image // yet — fetchAndShowImage adds the photo panel when ready. For join, still // kick the download so history media appears shortly after load. - fetchAndShowImage(QUrl(mediaUrl), caption, who, msgid); + fetchAndShowImage(QUrl(mediaUrl), caption, who, msgid, formatMessageTime(tags)); return; } // Normal text - m_scene.addLine(text.toStdString(), SM_SAY, who.toStdString()); + m_scene.addLine(text.toStdString(), SM_SAY, who.toStdString(), + formatMessageTime(tags).toStdString()); if (!msgid.isEmpty()) { m_scene.setMsgIdForLastBalloon(who.toStdString(), msgid.toStdString()); } m_scene.trimToMaxPanels(kMaxComicPanels); - relayout(); - update(); + finishPanelUpdate(); +} + +void ComicWidget::finishPanelUpdate() +{ + if (m_panelBatchDepth == 0) { + relayout(); + update(); + } +} + +void ComicWidget::beginPanelBatch() +{ + ++m_panelBatchDepth; +} + +void ComicWidget::endPanelBatch() +{ + if (m_panelBatchDepth > 0) { + --m_panelBatchDepth; + } + if (m_panelBatchDepth == 0) { + const QList pending = std::move(m_deferredImageFetches); + m_deferredImageFetches.clear(); + relayout(); + update(); + for (const PendingImageFetch &p : pending) { + fetchAndShowImage(p.url, p.caption, p.nick, p.msgid, p.timestamp); + } + } } void ComicWidget::applyReact(const QString &parentMsgid, const QString &emoji, @@ -698,7 +780,7 @@ void ComicWidget::applyReact(const QString &parentMsgid, const QString &emoji, const QString who = reactorNick.isEmpty() ? QStringLiteral("you") : reactorNick; const bool hit = m_scene.applyReact(parentMsgid.toStdString(), emoji.toStdString(), who.toStdString(), remove); - if (hit) { + if (hit && m_panelBatchDepth == 0) { relayout(); update(); } @@ -725,8 +807,7 @@ void ComicWidget::clearPanels() void ComicWidget::trimToRecentPanels(int maxPanels) { m_scene.trimToMaxPanels(maxPanels); - relayout(); - update(); + finishPanelUpdate(); } QStringList ComicWidget::availableRooms() const diff --git a/qt-port/app/ComicWidget.h b/qt-port/app/ComicWidget.h index f5cbca54..943311af 100644 --- a/qt-port/app/ComicWidget.h +++ b/qt-port/app/ComicWidget.h @@ -31,6 +31,9 @@ class ComicWidget : public QWidget { // Cache only (self echo / join history) — no new comic panel. void rememberIrcMessage(const QString &text, const QString &nick, const QHash &tags); + // History join: msgid cache for +reply without scanning comic balloons. + void cacheMessageFromTags(const QString &text, const QString &nick, + const QHash &tags); // Local send before server msgid: bind later via rememberIrcMessage/echo. void noteOutgoingMessage(const QString &text, const QString &nick); // freeq react: stamp emoji badge on the balloon for parentMsgid (comic strip). @@ -44,6 +47,9 @@ class ComicWidget : public QWidget { void clearPanels(); // Keep only the newest N panels in the strip (default 10). void trimToRecentPanels(int maxPanels = kMaxComicPanels); + // Suppress per-line relayout during history flush / bulk import. + void beginPanelBatch(); + void endPanelBatch(); int maxComicPanels() const { return kMaxComicPanels; } QString statusLine() const; @@ -103,11 +109,14 @@ class ComicWidget : public QWidget { void handlePossiblyMedia(const QString &text, const QString &nick, const QHash &tags, bool fastJoin = false); void fetchAndShowImage(const QUrl &url, const QString &caption, const QString &nick, - const QString &msgid = {}); + const QString &msgid = {}, const QString ×tamp = {}); void cacheMessage(const QString &msgid, const QString &nick, const QString &text); + void finishPanelUpdate(); // freeq: +reply / draft/reply → parent msgid. static QString replyParentId(const QHash &tags); static QString messageId(const QHash &tags); + // IRCv3 server-time → short local display string for image cards. + static QString formatMessageTime(const QHash &tags); // freeq react tag: +react / draft/react (emoji or shortname). Empty if none. static QString reactEmoji(const QHash &tags); static bool isReactRemove(const QHash &tags); @@ -146,6 +155,16 @@ class ComicWidget : public QWidget { QString m_characterName; int m_margin = 12; int m_viewportH = 400; + int m_panelBatchDepth = 0; + + struct PendingImageFetch { + QUrl url; + QString caption; + QString nick; + QString msgid; + QString timestamp; + }; + QList m_deferredImageFetches; // Hit-test targets for inline image previews rebuilt each paintEvent. struct ClickableImage { diff --git a/qt-port/app/MainWindow.cpp b/qt-port/app/MainWindow.cpp index c26cc11e..b1c7e12f 100644 --- a/qt-port/app/MainWindow.cpp +++ b/qt-port/app/MainWindow.cpp @@ -4,6 +4,7 @@ #include "app/MainWindow.h" #include "app/ComicWidget.h" #include "net/IrcClient.h" +#include "platform/BrowserLaunch.h" #include #include @@ -11,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -110,6 +112,7 @@ MainWindow::MainWindow(QWidget *parent) m_auth = new FreeqAuth(this); connect(m_auth, &FreeqAuth::statusMessage, this, &MainWindow::onAuthStatus); + connect(m_auth, &FreeqAuth::loginUrlReady, this, &MainWindow::onLoginUrlReady); connect(m_auth, &FreeqAuth::loginSucceeded, this, &MainWindow::onLoginSucceeded); connect(m_auth, &FreeqAuth::loginFailed, this, &MainWindow::onLoginFailed); connect(m_auth, &FreeqAuth::sessionRefreshed, this, &MainWindow::onSessionRefreshed); @@ -544,6 +547,36 @@ void MainWindow::appendChatLog(const QString &displayLine, const QString &nick, m_log->scrollToBottom(); } +void MainWindow::queueHistoryLog(const QString &displayLine, const QString &nick, + const QString &text, const QString &msgid) +{ + m_historyLogQueue.append(HistoryLogLine{displayLine, nick, text, msgid}); +} + +void MainWindow::flushHistoryLog() +{ + if (!m_log || m_historyLogQueue.isEmpty()) { + m_historyLogQueue.clear(); + return; + } + m_log->setUpdatesEnabled(false); + for (const HistoryLogLine &h : m_historyLogQueue) { + auto *item = new QListWidgetItem(h.displayLine); + item->setData(kRoleBaseLine, h.displayLine); + if (!h.msgid.isEmpty()) { + item->setData(kRoleMsgId, h.msgid); + item->setData(kRoleNick, h.nick); + item->setData(kRoleText, h.text); + item->setToolTip( + QStringLiteral("Right-click to reply/react · msgid %1").arg(h.msgid)); + } + m_log->addItem(item); + } + m_historyLogQueue.clear(); + m_log->setUpdatesEnabled(true); + m_log->scrollToBottom(); +} + void MainWindow::setReplyTarget(const QString &msgid, const QString &nick, const QString &text) { m_replyMsgId = msgid.trimmed(); @@ -749,13 +782,74 @@ void MainWindow::setConnectedUi(bool on) void MainWindow::onLogin() { - QString h = m_handle->text().trimmed(); + const QString h = m_handle->text().trimmed(); if (h.isEmpty()) { - h = m_nick->text().trimmed(); + appendLog(QStringLiteral("Enter your Bluesky / ATProto handle (e.g. you.bsky.social)")); + statusBar()->showMessage( + QStringLiteral("Enter your Bluesky handle before logging in"), 8000); + m_handle->setFocus(); + return; + } + if (h.contains(QLatin1Char(' '))) { + appendLog(QStringLiteral("Handle cannot contain spaces — use e.g. you.bsky.social")); + statusBar()->showMessage(QStringLiteral("Invalid handle — no spaces allowed"), 8000); + m_handle->setFocus(); + return; } m_auth->login(h); } +void MainWindow::onLoginUrlReady(const QString &url, bool browserOpened) +{ + appendLog(QStringLiteral("Login URL: %1").arg(url)); + + auto *dlg = new QDialog(this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->setWindowTitle(QStringLiteral("Bluesky login")); + dlg->setModal(false); + + auto *layout = new QVBoxLayout(dlg); + auto *intro = new QLabel( + browserOpened + ? QStringLiteral( + "A browser window should open for Bluesky sign-in. If it did not, " + "click Open in browser below or copy the URL.") + : QStringLiteral( + "Could not open your browser automatically. Click Open in browser " + "or copy the URL below, then sign in and return here."), + dlg); + intro->setWordWrap(true); + layout->addWidget(intro); + + auto *urlEdit = new QLineEdit(url, dlg); + urlEdit->setReadOnly(true); + layout->addWidget(urlEdit); + + auto *btnRow = new QHBoxLayout(); + auto *openBtn = new QPushButton(QStringLiteral("Open in browser"), dlg); + auto *copyBtn = new QPushButton(QStringLiteral("Copy URL"), dlg); + auto *closeBtn = new QPushButton(QStringLiteral("Close"), dlg); + btnRow->addWidget(openBtn); + btnRow->addWidget(copyBtn); + btnRow->addStretch(); + btnRow->addWidget(closeBtn); + layout->addLayout(btnRow); + + connect(openBtn, &QPushButton::clicked, dlg, [url]() { openUrlInBrowser(url); }); + connect(copyBtn, &QPushButton::clicked, dlg, [url]() { + QApplication::clipboard()->setText(url); + }); + connect(closeBtn, &QPushButton::clicked, dlg, &QDialog::close); + connect(m_auth, &FreeqAuth::loginSucceeded, dlg, &QDialog::close); + connect(m_auth, &FreeqAuth::loginFailed, dlg, &QDialog::close); + connect(m_auth, &FreeqAuth::loggedOut, dlg, &QDialog::close); + + dlg->resize(520, dlg->sizeHint().height() + 8); + dlg->show(); + dlg->raise(); + dlg->activateWindow(); +} + void MainWindow::onLogout() { m_auth->clearSession(); @@ -783,15 +877,18 @@ void MainWindow::onLoginSucceeded(const FreeqSession &session) return; } if (!sess.handle.isEmpty()) { - m_comic->rememberAtprotoIdentity(sess.handle, sess.did); + m_comic->rememberAtprotoIdentity(sess.handle, sess.did, + /*preloadSprite=*/false); } if (!sess.nick.isEmpty() && sess.nick != sess.handle) { - m_comic->rememberAtprotoIdentity(sess.nick, sess.did); + m_comic->rememberAtprotoIdentity(sess.nick, sess.did, + /*preloadSprite=*/false); } if (!sess.displayIdentity().isEmpty() && sess.displayIdentity() != sess.handle && sess.displayIdentity() != sess.nick) { - m_comic->rememberAtprotoIdentity(sess.displayIdentity(), sess.did); + m_comic->rememberAtprotoIdentity(sess.displayIdentity(), sess.did, + /*preloadSprite=*/false); } // Re-apply chosen character to ATProto identities now known. applyCurrentCharacterToLocalNicks(); @@ -851,12 +948,14 @@ void MainWindow::doIrcConnect(const FreeqSession &session) appendLog(QStringLiteral("Connecting as guest (no web-token)…")); } - // rpg.actor: index IRC nick + handle → DID before chat starts. + // rpg.actor: index IRC nick + handle → DID only (no sprite HTTP on connect — + // nested downloads freeze the UI while history floods in). if (m_comic && !session.did.isEmpty()) { - m_comic->rememberAtprotoIdentity(nick, session.did); + m_comic->rememberAtprotoIdentity(nick, session.did, /*preloadSprite=*/false); if (!session.handle.isEmpty() && session.handle.compare(nick, Qt::CaseInsensitive) != 0) { - m_comic->rememberAtprotoIdentity(session.handle, session.did); + m_comic->rememberAtprotoIdentity(session.handle, session.did, + /*preloadSprite=*/false); } } @@ -947,6 +1046,7 @@ void MainWindow::onChannelJoined(const QString &channel) appendLog(QStringLiteral("Joined %1 — loading history…").arg(channel)); m_historyComicQueue.clear(); m_historyReactQueue.clear(); + m_historyLogQueue.clear(); m_historyComicTotal = 0; } @@ -980,6 +1080,8 @@ void MainWindow::flushHistoryComic() m_log->scrollToBottom(); } + flushHistoryLog(); + if (!queue.isEmpty()) { // Comic strip: only the last N history messages (log already has the full set). // fastJoin=true: no blocking sprite/media HTTP — panels appear immediately. @@ -989,11 +1091,13 @@ void MainWindow::flushHistoryComic() appendLog(QStringLiteral("Comic strip: showing last %1 of %2 history messages") .arg(n) .arg(total)); + m_comic->beginPanelBatch(); for (int i = 0; i < n; ++i) { const HistoryComicLine &h = queue.at(i); m_comic->addChatLine(h.text, h.nick, h.tags, /*fastJoin=*/true); } m_comic->trimToRecentPanels(kMaxComicHistory); + m_comic->endPanelBatch(); // Async rpg.actor upgrade for unique speakers (after UI is responsive). QSet nicks; @@ -1022,10 +1126,16 @@ void MainWindow::flushHistoryComic() // Replay buffered history reacts — now that all log items and comic panels exist. if (!reactQueue.isEmpty()) { appendLog(QStringLiteral("Applying %1 react(s) from history").arg(reactQueue.size())); + if (m_comic) { + m_comic->beginPanelBatch(); + } for (const HistoryReact &hr : reactQueue) { // Apply without re-queuing as history onIrcReact(hr.parentId, hr.emoji, hr.nick, hr.remove, /*history=*/false); } + if (m_comic) { + m_comic->endPanelBatch(); + } } m_flushingHistoryComic = false; @@ -1048,13 +1158,22 @@ void MainWindow::onIrcMessage(const QString &nick, const QString &text, m_comic && m_comic->lookupCachedMessage(replyTo, &origNick, &origText); if (haveParent) { // Parent line is right-clickable (reply to original). - appendChatLog(QStringLiteral(" ↩ %1: %2").arg(origNick, origText), origNick, - origText, replyTo); + const QString parentLine = + QStringLiteral(" ↩ %1: %2").arg(origNick, origText); + if (history) { + queueHistoryLog(parentLine, origNick, origText, replyTo); + } else { + appendChatLog(parentLine, origNick, origText, replyTo); + } } else { appendLog(QStringLiteral(" ↩ (original not in buffer)")); } - appendChatLog(QStringLiteral("%1 (reply): %2").arg(speaker, text), speaker, text, - msgid); + const QString replyLine = QStringLiteral("%1 (reply): %2").arg(speaker, text); + if (history) { + queueHistoryLog(replyLine, speaker, text, msgid); + } else { + appendChatLog(replyLine, speaker, text, msgid); + } }; auto bindAccountDid = [&](bool preloadSprite) { @@ -1101,21 +1220,36 @@ void MainWindow::onIrcMessage(const QString &nick, const QString &text, } // Cache + identity for every line (history and live). - bindAccountDid(/*preloadSprite=*/!history); - - // Batch log widget updates during history flood (huge win on join). - if (history && m_log && m_log->updatesEnabled()) { - m_log->setUpdatesEnabled(false); + if (history) { + // Comic panels are not built yet — only cache msgids for +reply parents. + if (m_comic) { + m_comic->cacheMessageFromTags(text, nick, tags); + const QString did = tags.value(QStringLiteral("account")); + if (!did.isEmpty() && did.startsWith(QLatin1String("did:"))) { + m_comic->rememberAtprotoIdentity(nick, did, /*preloadSprite=*/false); + } + } + } else { + bindAccountDid(/*preloadSprite=*/true); } const QString mediaUrl = tags.value(QStringLiteral("media-url")); if (isReply) { appendReplyLog(nick); } else if (!mediaUrl.isEmpty()) { - appendChatLog(QStringLiteral("%1: [image] %2").arg(nick, mediaUrl), nick, text, - msgid); + const QString line = QStringLiteral("%1: [image] %2").arg(nick, mediaUrl); + if (history) { + queueHistoryLog(line, nick, text, msgid); + } else { + appendChatLog(line, nick, text, msgid); + } } else { - appendChatLog(QStringLiteral("%1: %2").arg(nick, text), nick, text, msgid); + const QString line = QStringLiteral("%1: %2").arg(nick, text); + if (history) { + queueHistoryLog(line, nick, text, msgid); + } else { + appendChatLog(line, nick, text, msgid); + } } // History: full log above; comic only gets last kMaxComicHistory (flush at batch end). diff --git a/qt-port/app/MainWindow.h b/qt-port/app/MainWindow.h index 0d24e24c..d3843565 100644 --- a/qt-port/app/MainWindow.h +++ b/qt-port/app/MainWindow.h @@ -48,6 +48,7 @@ private slots: void onHistoryBatchEnded(); void flushHistoryComic(); void onAuthStatus(const QString &msg); + void onLoginUrlReady(const QString &url, bool browserOpened); void onLoginSucceeded(const FreeqSession &session); void onLoginFailed(const QString &reason); void onSessionRefreshed(const FreeqSession &session); @@ -62,6 +63,9 @@ private slots: // Chat line in the log with freeq msgid for right-click → Reply. void appendChatLog(const QString &displayLine, const QString &nick, const QString &text, const QString &msgid); + void queueHistoryLog(const QString &displayLine, const QString &nick, const QString &text, + const QString &msgid); + void flushHistoryLog(); void setConnectedUi(bool on); void updateAuthUi(); void doIrcConnect(const FreeqSession &session); @@ -111,6 +115,13 @@ private slots: QString m_replyText; // History lines: log shows all; comic only flushes the last kMaxComicHistory. + struct HistoryLogLine { + QString displayLine; + QString nick; + QString text; + QString msgid; + }; + QList m_historyLogQueue; struct HistoryComicLine { QString nick; QString text; diff --git a/qt-port/engine/pose.cpp b/qt-port/engine/pose.cpp index bc845f51..c62861f0 100644 --- a/qt-port/engine/pose.cpp +++ b/qt-port/engine/pose.cpp @@ -43,7 +43,7 @@ void CPose::drawMasked(ICanvas *canvas, int x, int y, int w, int h, bool flipH) } if (flipH && !tmp.isNull()) { // Horizontal mirror — classic Comic Chat m_flip / StretchBlt negative width. - tmp.qimage() = tmp.qimage().flipped(Qt::Horizontal); + tmp.qimage() = tmp.qimage().mirrored(true, false); } tmp.draw(canvas, x, y, w, h); } diff --git a/qt-port/engine/scene.cpp b/qt-port/engine/scene.cpp index cdf24369..2ad0cf2e 100644 --- a/qt-port/engine/scene.cpp +++ b/qt-port/engine/scene.cpp @@ -2,10 +2,12 @@ // Licensed under the MIT license. #include "engine/scene.h" +#include "engine/bbox.h" #include "engine/spline.h" #include #include +#include #include #include @@ -20,6 +22,75 @@ QFont balloonFont(int point) return f; } +constexpr int kBalloonSeparation = 140; + +static void shiftBalloonRects(SceneBalloon &b, int dx, int dy) +{ + b.cloudBox.left += dx; + b.cloudBox.right += dx; + b.cloudBox.top += dy; + b.cloudBox.bottom += dy; + b.textBox.left += dx; + b.textBox.right += dx; + b.textBox.top += dy; + b.textBox.bottom += dy; + if (!b.timestamp.empty()) { + b.timeBox.left += dx; + b.timeBox.right += dx; + b.timeBox.top += dy; + b.timeBox.bottom += dy; + } + if (b.hasImage()) { + b.imageBox.left += dx; + b.imageBox.right += dx; + b.imageBox.top += dy; + b.imageBox.bottom += dy; + } +} + +static RECT inflateRect(const RECT &r, int margin) +{ + RECT out = r; + out.left -= margin; + out.right += margin; + out.top += margin; + out.bottom -= margin; + return out; +} + +static bool rectsOverlap(const RECT &a, const RECT &b, int margin = 0) +{ + RECT aa = inflateRect(a, margin); + RECT bb = inflateRect(b, margin); + return bbox_overlap(&aa, &bb); +} + +// Panel Y grows down (top > bottom). Shift a upward (positive dy) until it sits +// above obstacle b with separation. +static int overlapShiftUp(const RECT &a, const RECT &b, int margin) +{ + if (!rectsOverlap(a, b, 0)) { + return 0; + } + return b.top + margin - a.bottom; +} + +static int overlapShiftRight(const RECT &a, const RECT &b, int margin) +{ + if (!rectsOverlap(a, b, 0)) { + return 0; + } + return b.right + margin - a.left; +} + +static int overlapShiftLeft(const RECT &a, const RECT &b, int margin) +{ + if (!rectsOverlap(a, b, 0)) { + return 0; + } + return b.left - margin - a.right; +} + int logicalLineHeight(int fontPoint, double pxPerTwip) { QFontMetrics fm(balloonFont(fontPoint)); @@ -485,8 +556,9 @@ std::vector ComicScene::wrapText(const std::string &text, int maxWi return out; } -void ComicScene::layoutBalloon(SceneBalloon &b, const SceneBody &body, int balloonIndex, - int balloonCount) +void ComicScene::layoutBalloon(SceneBalloon &b, const SceneBody &body, int /*balloonIndex*/, + int balloonCount, int bodyCount, int sameSpeakerStack, + int bodyRank) { // Panel space: y=0 at top, y=-UNIT_PANEL_H at bottom (top > bottom). const int lineH = logicalLineHeight(m_fontPoint, m_layoutPxPerTwip); @@ -527,7 +599,9 @@ void ComicScene::layoutBalloon(SceneBalloon &b, const SceneBody &body, int ballo (b.nick.empty() ? 0 : 1) + static_cast(b.lines.size()); const int captionH = captionLines > 0 ? captionLines * lineH + padY : padY / 2; - const int chromeH = 2 * kFramePad + captionH; + const int timestampH = + b.timestamp.empty() ? 0 : lineH + padY; // footer band under photo + const int chromeH = 2 * kFramePad + captionH + timestampH; const int maxImgW = std::max(800, std::min(wantImgW, roomW - 2 * kFramePad)); const int maxImgH = std::max(800, std::min(wantImgH, roomH - chromeH)); @@ -537,17 +611,28 @@ void ComicScene::layoutBalloon(SceneBalloon &b, const SceneBody &body, int ballo int imgH = std::max(1, int(std::lround(ih * scale))); imgW = std::min(imgW, maxImgW); imgH = std::min(imgH, maxImgH); - b.lines = wrapText(b.text, imgW); - - const int totalW = imgW + 2 * kFramePad; - const int totalH = imgH + 2 * kFramePad + captionH; + // Card must be wide enough for the timestamp (portrait photos were + // clipping "Aug 4, 4:48 AM" entirely out of the frame). + int minCardInnerW = imgW; + if (!b.timestamp.empty()) { + minCardInnerW = std::max(minCardInnerW, measureLogical(b.timestamp) + padX); + } + if (!b.nick.empty()) { + minCardInnerW = + std::max(minCardInnerW, measureLogical(b.nick + ":") + padX); + } + int cardInnerW = std::min(minCardInnerW, roomW - 2 * kFramePad); + // Wrap caption to the card width (not the possibly-narrow bitmap width). + b.lines = wrapText(b.text, std::max(200, cardInnerW - padX / 2)); + const int captionLinesFinal = + (b.nick.empty() ? 0 : 1) + static_cast(b.lines.size()); + const int captionHFinal = + captionLinesFinal > 0 ? captionLinesFinal * lineH + padY : padY / 2; + const int chromeHFinal = 2 * kFramePad + captionHFinal + timestampH; + int totalW = cardInnerW + 2 * kFramePad; + int totalH = imgH + chromeHFinal; int cx = body.arrowX; - if (balloonCount > 1) { - const int spread = UNIT_PANEL_W * 8 / 100; - cx += (balloonIndex - (balloonCount - 1) / 2) * - (spread / std::max(1, balloonCount - 1)); - } cx = std::max(totalW / 2 + kSideMargin, std::min(UNIT_PANEL_W - totalW / 2 - kSideMargin, cx)); @@ -561,39 +646,58 @@ void ComicScene::layoutBalloon(SceneBalloon &b, const SceneBody &body, int ballo if (top > cardTopLimit) { // Still too tall: shrink image to remaining height (keep aspect). top = cardTopLimit; - const int fitH = std::max(400, top - bot - chromeH); + const int fitH = std::max(400, top - bot - chromeHFinal); if (imgH > fitH) { imgW = std::max(1, imgW * fitH / imgH); imgH = fitH; } - bot = top - (imgH + chromeH); + bot = top - (imgH + chromeHFinal); } } + // Keep width ≥ timestamp even after image shrink. + cardInnerW = std::max(imgW, minCardInnerW); + cardInnerW = std::min(cardInnerW, roomW - 2 * kFramePad); + totalW = cardInnerW + 2 * kFramePad; + cx = std::max(totalW / 2 + kSideMargin, + std::min(UNIT_PANEL_W - totalW / 2 - kSideMargin, cx)); + b.cloudBox.left = cx - totalW / 2; b.cloudBox.right = cx + totalW / 2; - // Recompute totalW if imgW shrank above. - const int finalW = imgW + 2 * kFramePad; - b.cloudBox.left = cx - finalW / 2; - b.cloudBox.right = cx + finalW / 2; b.cloudBox.top = top; b.cloudBox.bottom = bot; - b.imageBox.left = b.cloudBox.left + kFramePad; - b.imageBox.right = b.cloudBox.right - kFramePad; + // Center the bitmap in the (possibly wider) card. + b.imageBox.left = cx - imgW / 2; + b.imageBox.right = cx + imgW / 2; b.imageBox.top = b.cloudBox.top - kFramePad; b.imageBox.bottom = b.imageBox.top - imgH; - b.textBox.left = b.imageBox.left; - b.textBox.right = b.imageBox.right; - b.textBox.top = b.imageBox.bottom - padY / 3; - b.textBox.bottom = b.cloudBox.bottom + kFramePad / 2; + // Stack: image → timestamp → caption (nick + lines). Panel Y: top > bottom. + if (!b.timestamp.empty()) { + b.timeBox.left = b.cloudBox.left + kFramePad / 2; + b.timeBox.right = b.cloudBox.right - kFramePad / 2; + b.timeBox.top = b.imageBox.bottom - padY / 4; + b.timeBox.bottom = b.timeBox.top - timestampH; + b.textBox.left = b.timeBox.left; + b.textBox.right = b.timeBox.right; + b.textBox.top = b.timeBox.bottom; + b.textBox.bottom = b.cloudBox.bottom + kFramePad / 2; + } else { + b.timeBox = {}; + b.textBox.left = b.cloudBox.left + kFramePad / 2; + b.textBox.right = b.cloudBox.right - kFramePad / 2; + b.textBox.bottom = b.cloudBox.bottom + kFramePad / 2; + b.textBox.top = b.textBox.bottom + captionHFinal; + } return; } // ── Text speech balloon ───────────────────────────────────────────── - const int widthCapPct = balloonCount > 2 ? 42 : (balloonCount > 1 ? 48 : 55); - const int maxTextW = UNIT_PANEL_W * widthCapPct / 100; + const int crowd = std::max(balloonCount, std::max(bodyCount, 1)); + const int slotW = UNIT_PANEL_W / crowd; + const int widthCapPct = crowd > 2 ? 36 : (crowd > 1 ? 40 : 55); + const int maxTextW = std::min(UNIT_PANEL_W * widthCapPct / 100, slotW * 88 / 100); b.lines = wrapText(b.text, maxTextW); int maxW = 0; @@ -609,20 +713,19 @@ void ComicScene::layoutBalloon(SceneBalloon &b, const SceneBody &body, int ballo std::max(1, static_cast(b.lines.size())) + (b.nick.empty() ? 0 : 1); int boxW = maxW + 2 * padX; int boxH = nTextLines * lineH + 2 * padY; - const int maxBoxW = UNIT_PANEL_W * (balloonCount > 1 ? 48 : 78) / 100; - const int maxBoxH = UNIT_PANEL_H * (balloonCount > 2 ? 22 : 32) / 100; + const int maxBoxW = + std::min(UNIT_PANEL_W * (crowd > 1 ? 40 : 78) / 100, slotW * 90 / 100); + const int maxBoxH = UNIT_PANEL_H * (crowd > 2 ? 22 : 32) / 100; boxW = std::min(std::max(boxW, padX * 2 + 200), maxBoxW); boxH = std::min(std::max(boxH, lineH * 2 + padY), maxBoxH); int cx = body.arrowX; - if (balloonCount > 1) { - const int spread = UNIT_PANEL_W * 6 / 100; - cx += (balloonIndex - (balloonCount - 1) / 2) * (spread / std::max(1, balloonCount - 1)); - } cx = std::max(boxW / 2 + 120, std::min(UNIT_PANEL_W - boxW / 2 - 120, cx)); - const int stackLift = balloonIndex * (boxH / 3 + lineH / 2); - int bot = body.box.top + kTailGap + stackLift; + // Stack only repeated lines from the same speaker; stagger left→right bodies. + const int stackLift = sameSpeakerStack * (boxH + kBalloonSeparation); + const int bodyStagger = bodyRank * (boxH / 3 + lineH); + int bot = body.box.top + kTailGap + stackLift + bodyStagger; int top = bot + boxH; if (top + kCloudExtra > -kTopMargin) { @@ -856,9 +959,77 @@ void ComicScene::assignFacing(ScenePanel &panel) const } } +void ComicScene::resolveBalloonOverlaps(ScenePanel &panel) +{ + constexpr int kTopMargin = 160; + constexpr int kCloudExtra = 140; + constexpr int kSideMargin = 120; + const int cloudTopLimit = -kTopMargin - kCloudExtra; + + const int n = static_cast(panel.balloons.size()); + if (n <= 1) { + return; + } + + for (int pass = 0; pass < n * 4; ++pass) { + bool changed = false; + for (int i = 0; i < n; ++i) { + SceneBalloon &bal = panel.balloons[static_cast(i)]; + + for (const auto &body : panel.bodies) { + RECT obstacle = body.box; + obstacle.top = body.box.top + 80; + if (rectsOverlap(bal.cloudBox, obstacle, kBalloonSeparation / 2)) { + const int dy = overlapShiftUp(bal.cloudBox, obstacle, kBalloonSeparation); + if (dy > 0) { + shiftBalloonRects(bal, 0, dy); + changed = true; + } + } + } + + for (int j = 0; j < i; ++j) { + const SceneBalloon &prev = panel.balloons[static_cast(j)]; + if (!rectsOverlap(bal.cloudBox, prev.cloudBox, kBalloonSeparation / 2)) { + continue; + } + const int dy = overlapShiftUp(bal.cloudBox, prev.cloudBox, kBalloonSeparation); + if (dy > 0) { + shiftBalloonRects(bal, 0, dy); + changed = true; + continue; + } + const int dxR = + overlapShiftRight(bal.cloudBox, prev.cloudBox, kBalloonSeparation); + if (dxR > 0 && bal.cloudBox.right + dxR <= UNIT_PANEL_W - kSideMargin) { + shiftBalloonRects(bal, dxR, 0); + changed = true; + continue; + } + const int dxL = + overlapShiftLeft(bal.cloudBox, prev.cloudBox, kBalloonSeparation); + if (dxL < 0 && bal.cloudBox.left + dxL >= kSideMargin) { + shiftBalloonRects(bal, dxL, 0); + changed = true; + } + } + + if (bal.cloudBox.top > cloudTopLimit) { + const int clip = bal.cloudBox.top - cloudTopLimit; + shiftBalloonRects(bal, 0, -clip); + changed = true; + } + } + if (!changed) { + break; + } + } +} + void ComicScene::layoutBalloons(ScenePanel &panel) { const int nBal = static_cast(panel.balloons.size()); + const int bodyCount = static_cast(panel.bodies.size()); for (int i = 0; i < nBal; ++i) { SceneBalloon &bal = panel.balloons[static_cast(i)]; int bi = findBodyIndex(panel, bal.nick); @@ -868,8 +1039,23 @@ void ComicScene::layoutBalloons(ScenePanel &panel) if (bi < 0) { continue; } - layoutBalloon(bal, panel.bodies[static_cast(bi)], i, nBal); + int sameSpeakerStack = 0; + for (int j = 0; j < i; ++j) { + if (nickKey(panel.balloons[static_cast(j)].nick) == nickKey(bal.nick)) { + ++sameSpeakerStack; + } + } + int bodyRank = 0; + const int speakerLeft = panel.bodies[static_cast(bi)].box.left; + for (int k = 0; k < bodyCount; ++k) { + if (panel.bodies[static_cast(k)].box.left < speakerLeft) { + ++bodyRank; + } + } + layoutBalloon(bal, panel.bodies[static_cast(bi)], i, nBal, bodyCount, + sameSpeakerStack, bodyRank); } + resolveBalloonOverlaps(panel); } void ComicScene::layoutPanel(ScenePanel &panel) @@ -912,16 +1098,17 @@ void ComicScene::layoutPanel(ScenePanel &panel) layoutBalloons(panel); } -void ComicScene::addLine(const std::string &text, UCHAR mode, const std::string &nick) +void ComicScene::addLine(const std::string &text, UCHAR mode, const std::string &nick, + const std::string ×tamp) { if (text.empty()) { return; } - addImageLine(ComicImage{}, text, mode, nick); + addImageLine(ComicImage{}, text, mode, nick, timestamp); } void ComicScene::addImageLine(const ComicImage &image, const std::string &caption, UCHAR mode, - const std::string &nick) + const std::string &nick, const std::string ×tamp) { if (image.isNull() && caption.empty()) { return; @@ -943,6 +1130,13 @@ void ComicScene::addImageLine(const ComicImage &image, const std::string &captio bal.text = caption; bal.nick = who; bal.mode = mode; + bal.timestamp = timestamp; + // Every message gets a readable time (panel footer + photo cards). + if (bal.timestamp.empty()) { + bal.timestamp = QDateTime::currentDateTime() + .toString(QStringLiteral("MMM d, h:mm AP")) + .toStdString(); + } if (!image.isNull()) { bal.image = image; } @@ -1000,7 +1194,7 @@ static std::string trimCopy(const std::string &s) void ComicScene::addReplyExchange(const std::string &origNick, const std::string &origText, const std::string &replyNick, const std::string &replyText, - UCHAR replyMode) + UCHAR replyMode, const std::string ×tamp) { if (replyText.empty() && origText.empty()) { return; @@ -1039,6 +1233,12 @@ void ComicScene::addReplyExchange(const std::string &origNick, const std::string return; } + const std::string when = + timestamp.empty() ? QDateTime::currentDateTime() + .toString(QStringLiteral("MMM d, h:mm AP")) + .toStdString() + : timestamp; + if (!origText.empty()) { SceneBalloon orig; // Parent is plain speech context; reply balloon is marked SM_REPLY. @@ -1047,6 +1247,7 @@ void ComicScene::addReplyExchange(const std::string &origNick, const std::string orig.text = origText; orig.nick = whoOrig; orig.mode = SM_SAY; + orig.timestamp = when; for (auto it = m_panels.rbegin(); it != m_panels.rend() && orig.msgid.empty(); ++it) { for (auto bit = it->balloons.rbegin(); bit != it->balloons.rend(); ++bit) { if (nickKey(bit->nick) == nickKey(whoOrig) && @@ -1064,6 +1265,7 @@ void ComicScene::addReplyExchange(const std::string &origNick, const std::string rep.nick = whoReply; (void)replyMode; rep.mode = SM_REPLY; // mark reply bubble (not the original) + rep.timestamp = when; panel.balloons.push_back(std::move(rep)); layoutPanel(panel); @@ -1226,7 +1428,7 @@ void ComicScene::drawBody(ICanvas *canvas, const SceneBody &body) const // Sheet directions are real art — do not mirror. } else if (body.flip && !frame.isNull()) { // Single-frame custom art: mirror like classic cast. - frame.qimage() = frame.qimage().flipped(Qt::Horizontal); + frame.qimage() = frame.qimage().mirrored(true, false); } frame.draw(canvas, body.box.left, body.box.bottom, w, h); return; @@ -1297,7 +1499,8 @@ void ComicScene::drawBalloon(ICanvas *canvas, const SceneBalloon &b) const // White photo card + border; trust layout boxes (already fitted). RECT frame{L, T, R, Btm}; canvas->save(); - canvas->setClipRect(frame); + // Clip only the bitmap so timestamp/caption cannot be clipped away when + // the string is wider than a portrait photo. canvas->setBrush(CanvasColor::rgb(255, 255, 255)); canvas->setPen(CanvasColor::rgb(20, 20, 20), 40); canvas->fillRect(frame); @@ -1310,22 +1513,37 @@ void ComicScene::drawBalloon(ICanvas *canvas, const SceneBalloon &b) const const int imgBottom = b.imageBox.bottom; if (!b.image.isNull() && diw > 0 && dih > 0) { + canvas->save(); + canvas->setClipRect(RECT{imgLeft - 20, imgTop + 20, imgLeft + diw + 20, + imgBottom - 20}); RECT ir{imgLeft - 10, imgTop + 10, imgLeft + diw + 10, imgBottom - 10}; - ir.left = std::max(ir.left, L + 16); - ir.right = std::min(ir.right, R - 16); - ir.top = std::min(ir.top, T - 16); - ir.bottom = std::max(ir.bottom, Btm + 16); canvas->setPen(CanvasColor::rgb(40, 40, 40), 20); canvas->setNoBrush(); canvas->drawRect(ir); b.image.draw(canvas, imgLeft, imgBottom, diw, dih); + canvas->restore(); } - // Caption under the image, inside the card. + // Timestamp footer directly under the image bitmap. + if (!b.timestamp.empty()) { + const int footerTop = b.timeBox.top; + const int footerBot = b.timeBox.bottom; + RECT footer{b.timeBox.left, footerTop, b.timeBox.right, footerBot}; + canvas->setBrush(CanvasColor::rgb(245, 245, 248)); + canvas->setPen(CanvasColor::rgb(210, 210, 218), 12); + canvas->fillRect(footer); + canvas->drawRect(footer); + canvas->setFont("Sans Serif", m_fontPoint, false); + canvas->setPen(CanvasColor::rgb(30, 30, 36), 1); + const int tw = measureLogical(b.timestamp); + const int ty = footerTop - lineH - (footerTop - footerBot - lineH) / 4; + canvas->drawText((L + R - tw) / 2, ty, b.timestamp); + } + // Caption under the timestamp (or under the image when no timestamp). canvas->setFont("Sans Serif", m_fontPoint, false); canvas->setPen(CanvasColor::rgb(0, 0, 0), 1); - int y = imgBottom - lineH; - const int yMin = Btm + lineH; + int y = (b.timestamp.empty() ? b.imageBox.bottom : b.timeBox.bottom) - lineH / 2; + const int yMin = Btm + lineH / 2; if (!b.nick.empty()) { canvas->setFont("Sans Serif", std::max(8, m_fontPoint - 1), true); canvas->setPen(mode == SM_REPLY ? CanvasColor::rgb(30, 80, 160) @@ -1569,7 +1787,9 @@ int ComicScene::contentWidthForHeight(int contentHeight) const int ComicScene::contentHeightForHeight(int contentHeight) const { - return panelSideForHeight(contentHeight); + // Extra strip under each panel for the post-time label. + constexpr int kTimestampStrip = 22; + return panelSideForHeight(contentHeight) + kTimestampStrip; } void ComicScene::draw(ICanvas *canvas, const RECT &dest) const @@ -1579,12 +1799,14 @@ void ComicScene::draw(ICanvas *canvas, const RECT &dest) const } constexpr int kGap = 14; + constexpr int kTimestampStrip = 22; const int contentH = std::max(1, dest.bottom - dest.top); const int side = panelSideForHeight(contentH); const int panelW = side; const int panelH = side; - // Vertically center the strip in dest if dest is taller than the panel. - const int y0 = dest.top + std::max(0, (contentH - side) / 2); + // Leave a strip under the panels for post-time labels, then center. + const int y0 = + dest.top + std::max(0, (contentH - side - kTimestampStrip) / 2); auto *self = const_cast(this); self->m_layoutPxPerTwip = double(panelW) / UNIT_PANEL_W; @@ -1637,6 +1859,29 @@ void ComicScene::draw(ICanvas *canvas, const RECT &dest) const for (const auto &p : m_panels) { RECT pr{x, y0, x + panelW, y0 + panelH}; drawPanel(canvas, p, pr); + + // Post time under the panel (beige strip below the black border). + std::string when; + for (auto it = p.balloons.rbegin(); it != p.balloons.rend(); ++it) { + if (!it->timestamp.empty()) { + when = it->timestamp; + break; + } + } + if (!when.empty()) { + canvas->save(); + canvas->setLogicalOrigin(0, 0); + canvas->setLogicalScale(1.0, 1.0); + canvas->resetClip(); + canvas->setFont("Sans Serif", 10, false); + canvas->setPen(CanvasColor::rgb(55, 55, 62), 1); + const int tw = canvas->measureTextWidth(when); + const int tx = pr.left + std::max(0, (panelW - tw) / 2); + const int ty = pr.bottom + 15; + canvas->drawText(tx, ty, when); + canvas->restore(); + } + x += panelW + kGap; } } diff --git a/qt-port/engine/scene.h b/qt-port/engine/scene.h index ddec0514..88ffcaba 100644 --- a/qt-port/engine/scene.h +++ b/qt-port/engine/scene.h @@ -39,6 +39,9 @@ struct SceneBalloon { ComicImage image; RECT imageBox{}; // dest rect for photo inside/near balloon bool hasImage() const { return !image.isNull(); } + // Human-readable post time shown under the image (e.g. "Aug 4, 4:11 AM"). + std::string timestamp; + RECT timeBox{}; // freeq message id this balloon represents (for react targeting). std::string msgid; @@ -115,19 +118,22 @@ class ComicScene { // Add a spoken line. Nick is mapped to a stable character from the cast. // If setRpgSpriteForNick() was called for this nick, that sprite is used. + // timestamp: human-readable post time (IRCv3 server-time); drawn under the panel. void addLine(const std::string &text, UCHAR mode = SM_SAY, - const std::string &nick = "you"); + const std::string &nick = "you", const std::string ×tamp = {}); // Spoken line with an inline image (chat photo / freeq media upload). // Caption may be empty; image must be non-null. void addImageLine(const ComicImage &image, const std::string &caption = {}, - UCHAR mode = SM_SAY, const std::string &nick = "you"); + UCHAR mode = SM_SAY, const std::string &nick = "you", + const std::string ×tamp = {}); // freeq-style reply: always a new panel with original line + reply (two balloons). // origText may be empty if the parent msgid was not in the local cache. void addReplyExchange(const std::string &origNick, const std::string &origText, const std::string &replyNick, const std::string &replyText, - UCHAR replyMode = SM_SAY); + UCHAR replyMode = SM_SAY, + const std::string ×tamp = {}); // Stamp the server-assigned msgid onto the balloon that just spoke. // Prefers the newest balloon for nick with an empty msgid (never overwrites @@ -194,8 +200,10 @@ class ComicScene { void assignFacing(ScenePanel &panel) const; void applyBodyFlip(SceneBody &body) const; void layoutBalloon(SceneBalloon &b, const SceneBody &body, int balloonIndex, - int balloonCount); + int balloonCount, int bodyCount, int sameSpeakerStack, + int bodyRank); void layoutBalloons(ScenePanel &panel); + void resolveBalloonOverlaps(ScenePanel &panel); std::vector wrapText(const std::string &text, int maxWidthLogical) const; int measureLogical(const std::string &s) const; void drawPanel(ICanvas *canvas, const ScenePanel &panel, const RECT &pixelRect) const; diff --git a/qt-port/net/FreeqAuth.cpp b/qt-port/net/FreeqAuth.cpp index 5a05d749..b09ea6ba 100644 --- a/qt-port/net/FreeqAuth.cpp +++ b/qt-port/net/FreeqAuth.cpp @@ -3,7 +3,8 @@ #include "net/FreeqAuth.h" -#include +#include "platform/BrowserLaunch.h" + #include #include #include @@ -166,10 +167,12 @@ void FreeqAuth::login(const QString &handle) m_loginInProgress = true; emit statusMessage(QStringLiteral("Opening browser to sign in as %1…").arg(h)); - if (!QDesktopServices::openUrl(QUrl(url))) { - // Keep the loopback listener up so the user can paste the URL manually. + const bool browserOpened = openUrlInBrowser(url); + emit loginUrlReady(url, browserOpened); + if (!browserOpened) { emit statusMessage( - QStringLiteral("Could not open browser — open this URL:\n%1").arg(url)); + QStringLiteral("Could not open browser automatically — use Open in browser " + "in the login dialog, or copy the URL from the log.")); } emit statusMessage(QStringLiteral("Waiting for browser login (loopback :%1)…").arg(port)); } diff --git a/qt-port/net/FreeqAuth.h b/qt-port/net/FreeqAuth.h index eaeddecc..179465e8 100644 --- a/qt-port/net/FreeqAuth.h +++ b/qt-port/net/FreeqAuth.h @@ -86,6 +86,9 @@ public slots: signals: void statusMessage(const QString &msg); + // Emitted when the broker login URL is ready; browserOpened is false when + // automatic launch failed (show loginUrlDialog). + void loginUrlReady(const QString &url, bool browserOpened); void loginSucceeded(const FreeqSession &session); void loginFailed(const QString &reason); void sessionRefreshed(const FreeqSession &session); diff --git a/qt-port/net/IrcClient.cpp b/qt-port/net/IrcClient.cpp index 398b6e24..82acaad4 100644 --- a/qt-port/net/IrcClient.cpp +++ b/qt-port/net/IrcClient.cpp @@ -28,6 +28,14 @@ IrcClient::IrcClient(QObject *parent) { m_keepAliveTimer.setInterval(kKeepAliveIntervalMs); connect(&m_keepAliveTimer, &QTimer::timeout, this, &IrcClient::onKeepAliveTick); + m_joinHistoryTimer.setSingleShot(true); + connect(&m_joinHistoryTimer, &QTimer::timeout, this, [this]() { + m_joinHistoryUntilMs = 0; + // Flush any queued history if the server never closed a BATCH. + if (!m_inHistoryBatch) { + emit historyBatchEnded(); + } + }); } IrcClient::~IrcClient() @@ -112,6 +120,8 @@ void IrcClient::connectToServer(const QString &host, quint16 port, const QString m_ackedCaps.clear(); m_historyBatchId.clear(); m_inHistoryBatch = false; + m_joinHistoryUntilMs = 0; + m_joinHistoryTimer.stop(); // Keep m_webToken / m_wantSasl as set by caller. if (m_useTls) { @@ -798,6 +808,8 @@ void IrcClient::processLine(const QString &line) if (id == m_historyBatchId || m_inHistoryBatch) { m_historyBatchId.clear(); m_inHistoryBatch = false; + m_joinHistoryUntilMs = 0; + m_joinHistoryTimer.stop(); emit historyBatchEnded(); } } @@ -810,6 +822,9 @@ void IrcClient::processLine(const QString &line) emit statusMessage(QStringLiteral("%1 joined %2").arg(nick, chan)); if (nick.compare(m_nick, Qt::CaseInsensitive) == 0) { emit channelJoined(chan); + // Join-replay may omit BATCH= tags — treat traffic as history briefly. + m_joinHistoryUntilMs = QDateTime::currentMSecsSinceEpoch() + 8000; + m_joinHistoryTimer.start(8500); // freeq also join-replays history; CHATHISTORY fills DB history too. if (m_ackedCaps.contains(QStringLiteral("draft/chathistory")) || m_capLsAccum.contains(QLatin1String("draft/chathistory"), Qt::CaseInsensitive)) { @@ -831,7 +846,9 @@ void IrcClient::processLine(const QString &line) m_inHistoryBatch || (!batchId.isEmpty() && (batchId == m_historyBatchId || batchId.startsWith(QLatin1String("hist")) || - batchId.startsWith(QLatin1String("ch")))); + batchId.startsWith(QLatin1String("ch")))) || + (m_joinHistoryUntilMs > 0 && + QDateTime::currentMSecsSinceEpoch() < m_joinHistoryUntilMs); // Reacts piggyback on +reply/+react tags. They don't get a log line; // route to channelReact and let the badge attach to the parent msgid. @@ -880,7 +897,9 @@ void IrcClient::processLine(const QString &line) m_inHistoryBatch || (!batchId.isEmpty() && (batchId == m_historyBatchId || batchId.startsWith(QLatin1String("hist")) || - batchId.startsWith(QLatin1String("ch")))); + batchId.startsWith(QLatin1String("ch")))) || + (m_joinHistoryUntilMs > 0 && + QDateTime::currentMSecsSinceEpoch() < m_joinHistoryUntilMs); // TAGMSG carries only tags; body is empty. QString parent, emoji; diff --git a/qt-port/net/IrcClient.h b/qt-port/net/IrcClient.h index 071a8930..5cfe33d7 100644 --- a/qt-port/net/IrcClient.h +++ b/qt-port/net/IrcClient.h @@ -116,6 +116,10 @@ private slots: // Join-history / CHATHISTORY batch tracking QString m_historyBatchId; bool m_inHistoryBatch = false; + // freeq join-replay sometimes omits BATCH tags — treat PRIVMSG as history + // for a short window after our JOIN, then flush. + qint64 m_joinHistoryUntilMs = 0; + QTimer m_joinHistoryTimer; QTimer m_keepAliveTimer; qint64 m_lastServerActivityMs = 0; diff --git a/qt-port/net/RpgActorClient.cpp b/qt-port/net/RpgActorClient.cpp index c01f2143..7ae18716 100644 --- a/qt-port/net/RpgActorClient.cpp +++ b/qt-port/net/RpgActorClient.cpp @@ -545,6 +545,288 @@ std::optional RpgActorClient::cachedSheetForNick(const QString & return std::nullopt; } +void RpgActorClient::getBytesAsync(const QUrl &url, int timeoutMs, + const std::function &done) +{ + if (!url.isValid()) { + done({}); + return; + } + QNetworkRequest req{url}; + req.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("comic-chat-qt/0.1 (+rpg.actor)")); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + + QNetworkReply *reply = m_nam.get(req); + QTimer *timer = new QTimer(reply); + timer->setSingleShot(true); + QObject::connect(timer, &QTimer::timeout, reply, [reply]() { reply->abort(); }); + timer->start(std::max(500, timeoutMs)); + QObject::connect(reply, &QNetworkReply::finished, this, [reply, done]() { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + done({}); + return; + } + done(reply->readAll()); + }); +} + +void RpgActorClient::finishAsyncSheet(const QString &key, const QString &emitNick, + const RpgActorRef &ref, const QByteArray &bytes) +{ + auto clearFlight = [this, key]() { m_asyncInFlight.remove(key); }; + if (bytes.isEmpty()) { + clearFlight(); + return; + } + QImage img; + if (!img.loadFromData(bytes)) { + clearFlight(); + return; + } + RpgSpriteSheet asset; + asset.sheet.setQImage(img); + asset.columns = ref.columns > 0 ? ref.columns : kDefaultCols; + asset.rows = ref.rows > 0 ? ref.rows : kDefaultRows; + m_sheetCache.insert(key, asset); + if (!ref.spriteUrl.isEmpty()) { + m_sheetCache.insert(ref.spriteUrl, asset); + } + if (!ref.handle.isEmpty()) { + m_sheetCache.insert(nickKey(ref.handle), asset); + } + if (!ref.did.isEmpty()) { + m_sheetCache.insert(nickKey(ref.did), asset); + } + clearFlight(); + emit spriteReady(emitNick); +} + +void RpgActorClient::asyncDownloadSheet(const QString &key, const QString &emitNick, + const RpgActorRef &ref) +{ + if (ref.spriteUrl.isEmpty()) { + m_asyncInFlight.remove(key); + return; + } + getBytesAsync(QUrl(ref.spriteUrl), 6000, [this, key, emitNick, ref](const QByteArray &bytes) { + if (!bytes.isEmpty()) { + finishAsyncSheet(key, emitNick, ref, bytes); + return; + } + if (ref.did.isEmpty()) { + m_asyncInFlight.remove(key); + return; + } + const QString norm = QStringLiteral("https://rpg.actor/api/sprite/normalized?did=%1") + .arg(QString::fromUtf8(QUrl::toPercentEncoding(ref.did))); + getBytesAsync(QUrl(norm), 6000, [this, key, emitNick, ref](const QByteArray &normBytes) { + finishAsyncSheet(key, emitNick, ref, normBytes); + }); + }); +} + +void RpgActorClient::asyncFetchPdsSprite(const QString &key, const QString &emitNick, + const QString &did, const QString &handle) +{ + if (!did.startsWith(QLatin1String("did:plc:"))) { + m_liveMiss.insert(key, true); + m_asyncInFlight.remove(key); + return; + } + const QUrl plc(QStringLiteral("https://plc.directory/%1").arg(did)); + getBytesAsync(plc, 4000, [this, key, emitNick, did, handle](const QByteArray &body) { + if (body.isEmpty()) { + m_liveMiss.insert(key, true); + m_asyncInFlight.remove(key); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(body); + const QJsonArray services = doc.object().value(QStringLiteral("service")).toArray(); + QString pds; + for (const QJsonValue &v : services) { + const QJsonObject s = v.toObject(); + if (s.value(QStringLiteral("id")).toString() == QLatin1String("#atproto_pds") || + s.value(QStringLiteral("type")).toString() == + QLatin1String("AtprotoPersonalDataServer")) { + pds = s.value(QStringLiteral("serviceEndpoint")).toString(); + break; + } + } + if (pds.isEmpty()) { + m_liveMiss.insert(key, true); + m_asyncInFlight.remove(key); + return; + } + while (pds.endsWith(QLatin1Char('/'))) { + pds.chop(1); + } + const QUrl listUrl( + QStringLiteral("%1/xrpc/com.atproto.repo.listRecords?repo=%2&collection=" + "actor.rpg.sprite&limit=5") + .arg(pds, QString::fromUtf8(QUrl::toPercentEncoding(did)))); + getBytesAsync(listUrl, 4000, [this, key, emitNick, did, handle, pds](const QByteArray &listBody) { + if (listBody.isEmpty()) { + m_liveMiss.insert(key, true); + m_asyncInFlight.remove(key); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(listBody); + const QJsonArray records = doc.object().value(QStringLiteral("records")).toArray(); + if (records.isEmpty()) { + m_liveMiss.insert(key, true); + m_asyncInFlight.remove(key); + return; + } + QJsonObject value; + for (const QJsonValue &v : records) { + const QJsonObject rec = v.toObject(); + const QString uri = rec.value(QStringLiteral("uri")).toString(); + if (uri.endsWith(QLatin1String("/self"))) { + value = rec.value(QStringLiteral("value")).toObject(); + break; + } + if (value.isEmpty()) { + value = rec.value(QStringLiteral("value")).toObject(); + } + } + const QJsonObject sheet = value.value(QStringLiteral("spriteSheet")).toObject(); + const QString cid = + sheet.value(QStringLiteral("ref")).toObject().value(QStringLiteral("$link")).toString(); + if (cid.isEmpty()) { + m_liveMiss.insert(key, true); + m_asyncInFlight.remove(key); + return; + } + RpgActorRef ref; + ref.did = did; + ref.handle = handle; + ref.sheetW = value.value(QStringLiteral("width")).toInt(144); + ref.sheetH = value.value(QStringLiteral("height")).toInt(192); + ref.columns = value.value(QStringLiteral("columns")).toInt(kDefaultCols); + ref.rows = value.value(QStringLiteral("rows")).toInt(kDefaultRows); + ref.spriteUrl = QStringLiteral("%1/xrpc/com.atproto.sync.getBlob?did=%2&cid=%3") + .arg(pds, QString::fromUtf8(QUrl::toPercentEncoding(did)), + QString::fromUtf8(QUrl::toPercentEncoding(cid))); + ref.hasSprite = true; + cacheRef(ref); + m_byHandle.insert(key, ref); + asyncDownloadSheet(key, emitNick, ref); + }); + }); +} + +void RpgActorClient::asyncFetchApiActor(const QString &key, const QString &emitNick, + const QString &did, const QString &handle) +{ + const QUrl apiUrl(QStringLiteral("https://rpg.actor/api/actor/%1") + .arg(QString::fromUtf8(QUrl::toPercentEncoding(did)))); + getBytesAsync(apiUrl, 4000, [this, key, emitNick, did, handle](const QByteArray &body) { + if (!body.isEmpty()) { + const QJsonDocument doc = QJsonDocument::fromJson(body); + if (doc.isObject() && !doc.object().contains(QStringLiteral("error"))) { + const QJsonObject o = doc.object(); + RpgActorRef ref; + ref.did = o.value(QStringLiteral("did")).toString(did); + ref.handle = o.value(QStringLiteral("handle")).toString(handle); + ref.displayName = o.value(QStringLiteral("displayName")).toString(); + const QJsonObject sprite = o.value(QStringLiteral("sprite")).toObject(); + if (!sprite.isEmpty()) { + ref.sheetW = sprite.value(QStringLiteral("width")).toInt(144); + ref.sheetH = sprite.value(QStringLiteral("height")).toInt(192); + ref.columns = sprite.value(QStringLiteral("columns")).toInt(kDefaultCols); + ref.rows = sprite.value(QStringLiteral("rows")).toInt(kDefaultRows); + ref.spriteUrl = sprite.value(QStringLiteral("displayUrl")).toString(); + if (ref.spriteUrl.isEmpty()) { + ref.spriteUrl = sprite.value(QStringLiteral("url")).toString(); + } + ref.hasSprite = !ref.spriteUrl.isEmpty(); + } + if (ref.hasSprite) { + if (ref.handle.isEmpty()) { + ref.handle = handle; + } + cacheRef(ref); + m_byHandle.insert(key, ref); + asyncDownloadSheet(key, emitNick, ref); + return; + } + } + } + asyncFetchPdsSprite(key, emitNick, did, handle); + }); +} + +void RpgActorClient::requestSpriteAsync(const QString &nick) +{ + const QString emitNick = nick.trimmed(); + const QString key = nickKey(emitNick); + if (key.isEmpty()) { + return; + } + if (auto hit = cachedSheetForNick(emitNick)) { + emit spriteReady(emitNick); + return; + } + if (m_asyncInFlight.contains(key) || m_liveMiss.value(key, false)) { + return; + } + + auto ref = lookupKey(key); + if (ref && ref->hasSprite && !ref->spriteUrl.isEmpty()) { + m_asyncInFlight.insert(key); + asyncDownloadSheet(key, emitNick, *ref); + return; + } + + // Resolve DID (from freeq account tag, or treat key as DID/handle). + QString did; + QString handle; + if (key.startsWith(QLatin1String("did:"))) { + did = emitNick.trimmed(); + } else if (m_nickToDid.contains(key)) { + did = m_nickToDid.value(key); + handle = key; + } else if (key.contains(QLatin1Char('.'))) { + // Likely a handle — resolve via Bluesky, then continue. + handle = key; + m_asyncInFlight.insert(key); + QUrl url(QString::fromUtf8(kBskyResolve)); + QUrlQuery q; + q.addQueryItem(QStringLiteral("handle"), handle); + url.setQuery(q); + getBytesAsync(url, 4000, [this, key, emitNick, handle](const QByteArray &body) { + if (body.isEmpty()) { + m_liveMiss.insert(key, true); + m_asyncInFlight.remove(key); + return; + } + const QString did = QJsonDocument::fromJson(body) + .object() + .value(QStringLiteral("did")) + .toString(); + if (did.isEmpty()) { + m_liveMiss.insert(key, true); + m_asyncInFlight.remove(key); + return; + } + m_nickToDid.insert(key, did); + asyncFetchApiActor(key, emitNick, did, handle); + }); + return; + } else { + // Bare IRC nick with no DID — cannot live-resolve. + return; + } + + if (did.isEmpty()) { + return; + } + m_asyncInFlight.insert(key); + asyncFetchApiActor(key, emitNick, did, handle.isEmpty() ? key : handle); +} + std::optional RpgActorClient::spriteSheetForNick(const QString &nick, int timeoutMs, bool allowLiveFetch) diff --git a/qt-port/net/RpgActorClient.h b/qt-port/net/RpgActorClient.h index 1eee6dde..8f208bd3 100644 --- a/qt-port/net/RpgActorClient.h +++ b/qt-port/net/RpgActorClient.h @@ -19,8 +19,10 @@ #include #include #include +#include #include +#include #include #include @@ -59,12 +61,17 @@ class RpgActorClient : public QObject { // Full walk sheet for directional facing (preferred for multi-speaker panels). // allowLiveFetch: if false, only registry + already-cached sheets (no PDS hop). + // Prefer requestSpriteAsync — this path may nest a QEventLoop (legacy). std::optional spriteSheetForNick(const QString &nick, int timeoutMs = 5000, bool allowLiveFetch = true); // Memory cache only — never hits the network (fast path for join/history). std::optional cachedSheetForNick(const QString &nick) const; + // Non-blocking: cache/registry hit applies via spriteReady; otherwise queues + // async QNetworkReply chain (no nested QEventLoop — safe on the UI thread). + void requestSpriteAsync(const QString &nick); + // Idle *down* frame only (compat). Prefer spriteSheetForNick for facing. std::optional spriteForNick(const QString &nick, int timeoutMs = 5000); @@ -93,6 +100,16 @@ class RpgActorClient : public QObject { std::optional loadSheetForRef(const RpgActorRef &ref, const QString &cacheKey, int timeoutMs); + void getBytesAsync(const QUrl &url, int timeoutMs, + const std::function &done); + void finishAsyncSheet(const QString &key, const QString &emitNick, const RpgActorRef &ref, + const QByteArray &bytes); + void asyncFetchApiActor(const QString &key, const QString &emitNick, const QString &did, + const QString &handle); + void asyncFetchPdsSprite(const QString &key, const QString &emitNick, const QString &did, + const QString &handle); + void asyncDownloadSheet(const QString &key, const QString &emitNick, const RpgActorRef &ref); + QNetworkAccessManager m_nam; bool m_registryReady = false; bool m_registryLoading = false; @@ -106,4 +123,5 @@ class RpgActorClient : public QObject { QHash m_sheetCache; // nick/url → full sheet QHash m_spriteCache; // nick → down idle frame QHash m_liveMiss; // nick keys that already failed live fetch this session + QSet m_asyncInFlight; // nick keys with requestSpriteAsync in progress }; diff --git a/qt-port/platform/BrowserLaunch.cpp b/qt-port/platform/BrowserLaunch.cpp new file mode 100644 index 00000000..9c9ea639 --- /dev/null +++ b/qt-port/platform/BrowserLaunch.cpp @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#include "platform/BrowserLaunch.h" + +#include +#include +#include +#include + +namespace { + +bool tryStartDetached(const QString &program, const QStringList &args) +{ + if (program.isEmpty()) { + return false; + } + return QProcess::startDetached(program, args); +} + +} // namespace + +bool openUrlInBrowser(const QString &urlString) +{ + const QUrl url(urlString); + if (!url.isValid()) { + return false; + } + + const QString encoded = url.toString(QUrl::FullyEncoded); + + struct BrowserAttempt { + const char *program; + const char *windowFlag; + const char *profileFlag; + const char *profilePath; + }; + + static const BrowserAttempt kAttempts[] = { + // Dedicated profile so login always gets a visible window (existing Chrome + // sessions often swallow URLs without opening a tab). + {"google-chrome", "--new-window", "--user-data-dir=/tmp/comic-chat-chrome", + nullptr}, + {"google-chrome-stable", "--new-window", "--user-data-dir=/tmp/comic-chat-chrome", + nullptr}, + {"chromium", "--new-window", "--user-data-dir=/tmp/comic-chat-chrome", nullptr}, + {"chromium-browser", "--new-window", "--user-data-dir=/tmp/comic-chat-chrome", + nullptr}, + {"google-chrome", "--new-window", nullptr, nullptr}, + {"google-chrome-stable", "--new-window", nullptr, nullptr}, + {"chromium", "--new-window", nullptr, nullptr}, + {"chromium-browser", "--new-window", nullptr, nullptr}, + {"firefox", "-new-window", nullptr, nullptr}, + {"xdg-open", nullptr, nullptr, nullptr}, + }; + + for (const BrowserAttempt &attempt : kAttempts) { + const QString path = + QStandardPaths::findExecutable(QString::fromUtf8(attempt.program)); + if (path.isEmpty()) { + continue; + } + QStringList args; + if (attempt.windowFlag) { + args << QString::fromUtf8(attempt.windowFlag); + } + if (attempt.profileFlag && attempt.profilePath) { + args << QString::fromUtf8(attempt.profileFlag) + << QString::fromUtf8(attempt.profilePath); + } + args << encoded; + if (tryStartDetached(path, args)) { + return true; + } + } + + return QDesktopServices::openUrl(url); +} diff --git a/qt-port/platform/BrowserLaunch.h b/qt-port/platform/BrowserLaunch.h new file mode 100644 index 00000000..bbf81cb4 --- /dev/null +++ b/qt-port/platform/BrowserLaunch.h @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#pragma once + +#include + +// Open a URL in the user's browser. Uses explicit browser binaries on Linux +// (Chrome --new-window, xdg-open, …) because QDesktopServices::openUrl often +// fails silently or only hands off to an existing session without a new window. +bool openUrlInBrowser(const QString &url);