diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp index 869df4cf3..e62195c2f 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp @@ -14,6 +14,8 @@ #include #include #include +#include +#include #include #include #include @@ -49,7 +51,7 @@ ChatView::ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _sho viewport()->setCursor(Qt::IBeamCursor); setReadOnly(true); - setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::LinksAccessibleByMouse); + setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard); setOpenLinks(false); connect(this, &ChatView::anchorClicked, this, &ChatView::openLink); @@ -219,6 +221,46 @@ void ChatView::appendUrlTag(QTextCursor &cursor, QString url) cursor.setCharFormat(oldFormat); } +void ChatView::appendGameLinkTag(QTextCursor &cursor, const QString &url) +{ + const QUrl gameUrl(url); + const QUrlQuery query(gameUrl); + const QString hostname = query.queryItemValue("hostname"); + // FullyDecoded undoes every %XX escape, so a description that itself + // contains "%" cannot end up displayed as "%25" in the label. + const QString description = query.queryItemValue("game", QUrl::FullyDecoded); + const int gameId = query.queryItemValue("gameid").toInt(); + + QString label; + if (gameId > 0 && !hostname.isEmpty()) { + // Links built before the description was embedded stay readable: the + // id + server fallback below is identical to the old anchor text. + if (!description.isEmpty()) { + // Multi-arg .arg() replaces all placeholders in a single pass, so a + // description containing "%…" cannot corrupt later placeholders. + label = tr("Join game \"%1\" (#%2) on %3").arg(description, QString::number(gameId), hostname); + } else { + label = tr("Join game #%1 on %2").arg(QString::number(gameId), hostname); + } + } else { + label = tr("Join game"); + } + + QTextCharFormat oldFormat = cursor.charFormat(); + QTextCharFormat gameLinkFormat = oldFormat; + gameLinkFormat.setForeground(linkColor); + gameLinkFormat.setFontWeight(QFont::Bold); + gameLinkFormat.setAnchor(true); + gameLinkFormat.setAnchorHref(url); + QColor background = palette().highlight().color(); + background.setAlpha(40); + gameLinkFormat.setBackground(background); + + cursor.setCharFormat(gameLinkFormat); + cursor.insertText(label); + cursor.setCharFormat(oldFormat); +} + void ChatView::appendMessage(QString message, RoomMessageTypeFlags messageType, const ServerInfo_User &userInfo, @@ -503,6 +545,17 @@ void ChatView::checkWord(QTextCursor &cursor, QString &message) } } + if (fullWordUpToSpaceOrEnd.startsWith("cockatrice://", Qt::CaseInsensitive)) { + // Only links to a game (cockatrice://joingame) become invite buttons; + // any other cockatrice:// scheme falls through to plain text below. + const QUrl gameLink(fullWordUpToSpaceOrEnd); + if (gameLink.host().compare("joingame", Qt::CaseInsensitive) == 0) { + appendGameLinkTag(cursor, fullWordUpToSpaceOrEnd); + cursor.insertText(rest, defaultFormat); + return; + } + } + // check word mentions for (const QString &word : highlightedWords) { if (fullWordUpToSpaceOrEnd.compare(word, Qt::CaseInsensitive) == 0) { @@ -724,6 +777,11 @@ void ChatView::mouseReleaseEvent(QMouseEvent *event) void ChatView::openLink(const QUrl &link) { + if (link.scheme() == "cockatrice") { + emit cockatriceLinkActivated(link.toString(QUrl::FullyEncoded)); + return; + } + if ((link.scheme() == "card") || (link.scheme() == "user")) { return; } diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h index 646aa6a80..c58efa2c6 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h @@ -71,6 +71,7 @@ private: void scrollToBottom(); void appendCardTag(QTextCursor &cursor, const QString &cardName); void appendUrlTag(QTextCursor &cursor, QString url); + void appendGameLinkTag(QTextCursor &cursor, const QString &url); static QColor getCustomMentionColor(); static QColor getCustomHighlightColor(); void showSystemPopup(const QString &userName); @@ -121,6 +122,7 @@ signals: void addMentionTag(QString mentionTag); void messageClickedSignal(); void showMentionPopup(const QString &userName); + void cockatriceLinkActivated(const QString &url); }; #endif diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index 7a82b0c76..be52b9871 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -336,11 +336,12 @@ constexpr int UserInfo = Qt::UserRole + 2; // rows (UserListTWI, which uses QTreeWidgetItem::Type) by this item type. constexpr int SectionItemType = QTreeWidgetItem::UserType + 1; -UserListItemDelegate::UserListItemDelegate(QTreeWidget *tree, +UserListItemDelegate::UserListItemDelegate(UserListWidget *owner, + QTreeWidget *tree, const QMap *avatarCache, const QMap *cardArtCache, const QMap *cardArtParamsMap) - : QStyledItemDelegate(tree), tree(tree), avatarCache(avatarCache), cardArtCache(cardArtCache), + : QStyledItemDelegate(tree), tree(tree), owner(owner), avatarCache(avatarCache), cardArtCache(cardArtCache), cardArtParamsMap(cardArtParamsMap) { } @@ -353,7 +354,7 @@ bool UserListItemDelegate::editorEvent(QEvent *event, if ((event->type() == QEvent::MouseButtonPress) && index.isValid()) { QMouseEvent *const mouseEvent = static_cast(event); if (mouseEvent->button() == Qt::RightButton) { - static_cast(parent())->showContextMenu(mouseEvent->globalPosition().toPoint(), index); + owner->showContextMenu(mouseEvent->globalPosition().toPoint(), index); return true; } } @@ -593,8 +594,8 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->setHeaderHidden(true); userTree->setRootIsDecorated(false); userTree->setIconSize(QSize(20, 18)); - itemDelegate = - new UserListItemDelegate(userTree, &avatarProvider->cache(), &cardArtProvider->cache(), &cardArtParamsMap); + itemDelegate = new UserListItemDelegate(this, userTree, &avatarProvider->cache(), &cardArtProvider->cache(), + &cardArtParamsMap); userTree->setItemDelegate(itemDelegate); userTree->setAlternatingRowColors(true); userTree->hideColumn(1); @@ -1638,15 +1639,27 @@ void UserListWidget::updateSectionDivider(Section section) return; } int visible = 0; + int online = 0; for (int i = 0; i < divider->childCount(); ++i) { - if (!divider->child(i)->isHidden()) { + QTreeWidgetItem *child = divider->child(i); + if (!child->isHidden()) { ++visible; + if (child->data(0, UserListRoles::Online).toBool()) { + ++online; + } } } // The tree draws no branches (rows are flush), so the divider carries its // own collapse arrow glyph. const QString arrow = divider->isExpanded() ? QStringLiteral("\u25BE") : QStringLiteral("\u25B8"); - divider->setText(0, tr("%1 %2 (%3)").arg(arrow, sectionTitle(section)).arg(visible)); + if (section == Section::Buddy) { + // The buddy divider reports how many of the shown buddies are online, + // mirroring the "Buddies online: %1 / %2" title of the non-sectioned + // buddy list. + divider->setText(0, tr("%1 %2 (%3/%4)").arg(arrow, sectionTitle(section)).arg(online).arg(visible)); + } else { + divider->setText(0, tr("%1 %2 (%3)").arg(arrow, sectionTitle(section)).arg(visible)); + } } void UserListWidget::handleSectionExpansion(QTreeWidgetItem *item, bool expanded) diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index 298a5f8d8..e048c7fb7 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -37,6 +37,7 @@ class QPlainTextEdit; class Response; class CommandContainer; class UserContextMenu; +class UserListWidget; class QShowEvent; class BanDialog : public QDialog @@ -105,12 +106,14 @@ public: class UserListItemDelegate : public QStyledItemDelegate { QTreeWidget *tree; + UserListWidget *owner; const QMap *avatarCache; const QMap *cardArtCache; const QMap *cardArtParamsMap; public: - explicit UserListItemDelegate(QTreeWidget *tree, + explicit UserListItemDelegate(UserListWidget *owner, + QTreeWidget *tree, const QMap *avatarCache, const QMap *cardArtCache, const QMap *cardArtParamsMap); diff --git a/cockatrice/src/interface/widgets/tabs/tab.h b/cockatrice/src/interface/widgets/tabs/tab.h index 6ea1f5077..bddf325e2 100644 --- a/cockatrice/src/interface/widgets/tabs/tab.h +++ b/cockatrice/src/interface/widgets/tabs/tab.h @@ -20,6 +20,7 @@ class Tab : public QMainWindow signals: void userEvent(bool globalEvent = true); void tabTextChanged(Tab *tab, const QString &newTabText); + void cockatriceLinkActivated(const QString &url); protected: TabSupervisor *tabSupervisor; diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 3f165c1d5..513b7c926 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -1292,6 +1292,7 @@ void TabGame::createMessageDock(bool bReplay) qOverload(&CardInfoFrameWidget::setCard)); connect(messageLog, &MessageLogWidget::showCardInfoPopup, this, &TabGame::showCardInfoPopup); connect(messageLog, &MessageLogWidget::deleteCardInfoPopup, this, &TabGame::deleteCardInfoPopup); + connect(messageLog, &MessageLogWidget::cockatriceLinkActivated, this, &TabGame::cockatriceLinkActivated); if (!bReplay) { connect(messageLog, &MessageLogWidget::openMessageDialog, this, &TabGame::openMessageDialog); diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp index 9e9dbce1c..9eccea7a2 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp @@ -23,14 +23,16 @@ TabMessage::TabMessage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User &_ownUserInfo, - const ServerInfo_User &_otherUserInfo) + const ServerInfo_User &_otherUserInfo, + bool _userOnline) : Tab(_tabSupervisor), client(_client), ownUserInfo(new ServerInfo_User(_ownUserInfo)), - otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(true) + otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(_userOnline) { chatView = new ChatView(tabSupervisor, 0, true); connect(chatView, &ChatView::showCardInfoPopup, this, &TabMessage::showCardInfoPopup); connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabMessage::deleteCardInfoPopup); connect(chatView, &ChatView::addMentionTag, this, &TabMessage::addMentionTag); + connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabMessage::cockatriceLinkActivated); sayEdit = new LineEditUnfocusable; sayEdit->setMaxLength(MAX_TEXT_LENGTH); connect(sayEdit, &LineEditUnfocusable::returnPressed, this, &TabMessage::sendMessage); @@ -96,7 +98,14 @@ void TabMessage::closeEvent(QCloseEvent *event) void TabMessage::sendMessage() { - if (sayEdit->text().isEmpty() || !userOnline) { + if (sayEdit->text().isEmpty()) { + return; + } + + if (!userOnline) { + // Keep the draft: the user may be back momentarily, and the typed text + // should not be lost to a transient offline spell. + notifyUserOffline(); return; } @@ -105,17 +114,27 @@ void TabMessage::sendMessage() cmd.set_message(sayEdit->text().toStdString()); PendingCommand *pend = client->prepareSessionCommand(cmd); + pend->setExtraData(sayEdit->text()); connect(pend, &PendingCommand::finished, this, &TabMessage::messageSent); client->sendCommand(pend); sayEdit->clear(); } -void TabMessage::messageSent(const Response &response) +void TabMessage::messageSent(const Response &response, + const CommandContainer & /*commandContainer*/, + const QVariant &extraData) { if (response.response_code() == Response::RespInIgnoreList) { chatView->appendMessage(tr( "This user is ignoring you, they cannot see your messages in main chat and you cannot join their games.")); + } else if (response.response_code() == Response::RespNameNotFound) { + // The recipient went offline before the command reached the server: restore the draft. + userOnline = false; + if (sayEdit->text().isEmpty()) { + sayEdit->setText(extraData.toString()); + } + notifyUserOffline(); } } @@ -175,3 +194,8 @@ void TabMessage::processUserJoined(const ServerInfo_User &_userInfo) userOnline = true; *otherUserInfo = _userInfo; } + +void TabMessage::notifyUserOffline() +{ + chatView->appendMessage(tr("Message not sent — %1 is offline.").arg(QString::fromStdString(otherUserInfo->name()))); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.h b/cockatrice/src/interface/widgets/tabs/tab_message.h index 0472bb061..f7d15b4f6 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.h +++ b/cockatrice/src/interface/widgets/tabs/tab_message.h @@ -19,6 +19,7 @@ class LineEditUnfocusable; class Event_UserMessage; class Response; class ServerInfo_User; +class CommandContainer; class TabMessage : public Tab { @@ -39,7 +40,7 @@ signals: void maximizeClient(); private slots: void sendMessage(); - void messageSent(const Response &response); + void messageSent(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData); void addMentionTag(QString mentionTag); void messageClicked(); @@ -50,7 +51,8 @@ public: TabMessage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User &_ownUserInfo, - const ServerInfo_User &_otherUserInfo); + const ServerInfo_User &_otherUserInfo, + bool _userOnline); ~TabMessage() override; void retranslateUi() override; void tabActivated() override; @@ -65,6 +67,7 @@ public: private: bool shouldShowSystemPopup(const Event_UserMessage &event); void showSystemPopup(const Event_UserMessage &event); + void notifyUserOffline(); }; #endif diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 9b09ba7bb..508d5a048 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -70,6 +70,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab); connect(chatView, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog); + connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated); connect(chatView, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup); connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup); connect(chatView, &ChatView::addMentionTag, this, &TabRoom::addMentionTag); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 4100e124a..1ab812c54 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -406,6 +406,7 @@ int TabSupervisor::myAddTab(Tab *tab, QAction *manager) { connect(tab, &TabGame::userEvent, this, &TabSupervisor::tabUserEvent); connect(tab, &TabGame::tabTextChanged, this, &TabSupervisor::updateTabText); + connect(tab, &TabGame::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated); QString tabText = tab->getTabText(); int idx = addTab(tab, sanitizeTabName(tabText)); @@ -851,6 +852,7 @@ void TabSupervisor::addRoomTab(const ServerInfo_Room &info, bool setCurrent) connect(tab, &TabRoom::maximizeClient, this, &TabSupervisor::maximizeMainWindow); connect(tab, &TabRoom::roomClosing, this, &TabSupervisor::roomLeft); connect(tab, &TabRoom::openMessageDialog, this, &TabSupervisor::addMessageTab); + connect(tab, &TabRoom::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated); myAddTab(tab); roomTabs.insert(info.room_id(), tab); if (setCurrent) { @@ -904,8 +906,10 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus } ServerInfo_User otherUser; + bool userOnline = false; if (auto user = userListManager->getOnlineUser(receiverName)) { otherUser = ServerInfo_User(*user); + userOnline = true; } else { otherUser.set_name(receiverName.toStdString()); } @@ -919,9 +923,10 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus return tab; } - tab = new TabMessage(this, client, *userInfo, otherUser); + tab = new TabMessage(this, client, *userInfo, otherUser, userOnline); connect(tab, &TabMessage::talkClosing, this, &TabSupervisor::talkLeft); connect(tab, &TabMessage::maximizeClient, this, &TabSupervisor::maximizeMainWindow); + connect(tab, &TabMessage::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated); myAddTab(tab); messageTabs.insert(receiverName, tab); if (focus) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 0c3542cf3..5ac3eb365 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -169,6 +169,7 @@ signals: void localGameEnded(); void adminLockChanged(bool lock); void showWindowIfHidden(); + void cockatriceLinkActivated(const QString &url); public slots: void openDeckInNewTab(const LoadedDeck &deckToOpen); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index c083dccf8..199a2d952 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -40,6 +40,7 @@ #include "intents/intent_connect_to_server.h" #include "intents/intent_login.h" #include "intents/intent_open_server_room_by_name.h" +#include "intents/url_parser.h" #include "logger.h" #include "version_string.h" #include "widgets/dialogs/dlg_connect.h" @@ -497,6 +498,7 @@ MainWindow::MainWindow(QWidget *parent) pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize()); connectionController = new ConnectionController(this, this); + urlParser = new IntentUrlParser(this, this); createActions(); createMenus(); @@ -508,6 +510,7 @@ MainWindow::MainWindow(QWidget *parent) connect(tabSupervisor, &TabSupervisor::setMenu, this, &MainWindow::updateTabMenu); connect(tabSupervisor, &TabSupervisor::localGameEnded, this, &MainWindow::localGameEnded); connect(tabSupervisor, &TabSupervisor::showWindowIfHidden, this, &MainWindow::showWindowIfHidden); + connect(tabSupervisor, &TabSupervisor::cockatriceLinkActivated, this, &MainWindow::handleCockatriceLink); connect(connectionController, &ConnectionController::tabSupervisorStartRequested, tabSupervisor, &TabSupervisor::start); connect(connectionController, &ConnectionController::tabSupervisorStopRequested, tabSupervisor, @@ -861,6 +864,11 @@ void MainWindow::showWindowIfHidden() show(); } +void MainWindow::handleCockatriceLink(const QString &url) +{ + urlParser->handle(url); +} + void MainWindow::cardDatabaseLoadingFailed() { if (askedForDbUpdater) { diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 73b7c42c5..fc0791832 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -56,6 +56,7 @@ class TabSupervisor; class WndSets; class DlgTipOfTheDay; struct ContextConnectToServer; +class IntentUrlParser; class MainWindow : public QMainWindow { @@ -84,6 +85,7 @@ private slots: void actOpenSettingsFolder(); void actShow(); void showWindowIfHidden(); + void handleCockatriceLink(const QString &url); void cardUpdateError(QProcess::ProcessError err); void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus); @@ -139,6 +141,7 @@ private: *aOpenSettingsFolder; TabSupervisor *tabSupervisor; + IntentUrlParser *urlParser; WndSets *wndSets; ConnectionController *connectionController; LocalServer *localServer;