From 7c550ee505c36cc7febc60feaaf0d420ab9fe0f2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:37:15 +0200 Subject: [PATCH] [Game] Add an invite button to non-started and not full games (#7143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Send game invites from the user context menu via a private message The user context menu gains an "Invite to Game" submenu listing the inviteable games in the room (the inviter's own games, honoring the buddy-only setting). Picking one opens a private message to the target user with a cockatrice://joingame link naming the game, so the target gets a clickable invite instead of a raw URL. Multi-game rooms offer a picker; a single inviteable game sends directly. Sending a message to an offline user no longer swallows the draft — it reports that the user is offline and keeps the typed text. Took 1 minute * [Client] Add invite-to-game dialog to the game window Took 15 seconds * [Client] Open the invite dialog taller by default without enforcing a minimum size * Move button to bottom Took 3 minutes * Address comments. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../widgets/dialogs/dlg_invite_to_game.cpp | 112 ++++++++ .../widgets/dialogs/dlg_invite_to_game.h | 46 ++++ .../widgets/server/user/user_list_widget.cpp | 252 +++++++++++++----- .../widgets/server/user/user_list_widget.h | 21 +- .../src/interface/widgets/tabs/tab_game.cpp | 77 +++++- .../src/interface/widgets/tabs/tab_game.h | 5 + .../interface/widgets/tabs/tab_message.cpp | 6 + .../src/interface/widgets/tabs/tab_message.h | 1 + .../interface/widgets/tabs/tab_supervisor.cpp | 4 +- 10 files changed, 449 insertions(+), 76 deletions(-) create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 1c2755f71..56f5b89f9 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -36,6 +36,7 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp src/interface/widgets/dialogs/dlg_forgot_password_request.cpp src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp + src/interface/widgets/dialogs/dlg_invite_to_game.cpp src/interface/widgets/dialogs/dlg_load_deck.cpp src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp new file mode 100644 index 000000000..b5451bb2c --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp @@ -0,0 +1,112 @@ +#include "dlg_invite_to_game.h" + +#include "../server/user/user_list_manager.h" +#include "../server/user/user_list_widget.h" +#include "../tabs/tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +DlgInviteToGame::DlgInviteToGame(TabSupervisor *_tabSupervisor, + const QString &_inviteUrl, + bool _onlyBuddies, + const QStringList &_excludeUserNames, + QWidget *parent) + : QDialog(parent), tabSupervisor(_tabSupervisor), inviteUrl(_inviteUrl), onlyBuddies(_onlyBuddies), + excludeUserNames(_excludeUserNames) +{ + setModal(true); + + searchEdit = new QLineEdit(this); + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::textChanged, this, &DlgInviteToGame::searchTextChanged); + + // The embedded list is the real room user list without the hover popup: + // same manager, same delegate/painter, same sections, live via manager + // signals while the modal loop runs. + UserListManager *manager = tabSupervisor->getUserListManager(); + userList = new UserListWidget(tabSupervisor, tabSupervisor->getClient(), UserListWidget::RoomList, this, + /*hasUserInfoPopup=*/false); + userList->setUserFilter([this, manager](const QString &name, bool online) { + return !excludeUserNames.contains(name) && online && !manager->isUserIgnored(name); + }); + if (onlyBuddies) { + userList->setSectioned({UserListWidget::Section::Buddy}); + } else { + userList->setSectioned({UserListWidget::Section::Buddy, UserListWidget::Section::Online}); + } + userList->bind(manager); + userList->rebuild(); + + connect(userList, &UserListWidget::userActivated, this, &DlgInviteToGame::inviteCurrentUser); + connect(userList, &UserListWidget::currentUserChanged, this, [this](const QString &userName) { + currentUserName = userName; + inviteButton->setEnabled(!userName.isEmpty()); + }); + + inviteButton = new QPushButton(this); + inviteButton->setEnabled(false); + inviteButton->setDefault(true); + connect(inviteButton, &QPushButton::clicked, this, [this] { inviteCurrentUser(currentUserName); }); + + cancelButton = new QPushButton(this); + connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject); + + auto *buttonRow = new QHBoxLayout; + buttonRow->addStretch(); + buttonRow->addWidget(inviteButton); + buttonRow->addWidget(cancelButton); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(searchEdit); + layout->addWidget(userList, 1); + layout->addLayout(buttonRow); + + retranslateUi(); + + // Default to a comfortably tall dialog so the list has room to breathe, + // capped by the available screen. No minimum is enforced: small screens + // and manual resizing can go shorter than this. + const QRect availableScreen = QGuiApplication::primaryScreen()->availableGeometry(); + resize(sizeHint().width(), qMin(sizeHint().height() * 3, availableScreen.height() * 4 / 5)); +} + +void DlgInviteToGame::searchTextChanged(const QString &text) +{ + userList->setFilterText(text); +} + +void DlgInviteToGame::inviteCurrentUser(const QString &userName) +{ + if (userName.isEmpty()) { + return; + } + // The invite link carries the game's id and, when the game has one, its + // description (makeGameJoinLink embeds both). Read them back so the prefix + // names the game by description first, then its id — identical to the + // context-menu invite so recipients see one consistent message style. + const QUrl inviteUrlObj(inviteUrl); + const QUrlQuery inviteQuery(inviteUrlObj); + const int gameId = inviteQuery.queryItemValue("gameid").toInt(); + const QString gameDescription = inviteQuery.queryItemValue("game"); + const QString prefix = gameDescription.isEmpty() + ? tr("Join my game (#%1):").arg(gameId) + : tr("Join my game \"%1\" (#%2):").arg(gameDescription).arg(gameId); + tabSupervisor->sendInviteToUser(userName, prefix + " " + inviteUrl); + accept(); +} + +void DlgInviteToGame::retranslateUi() +{ + setWindowTitle(tr("Invite to Game")); + searchEdit->setPlaceholderText(tr("Search users...")); + inviteButton->setText(tr("Invite")); + cancelButton->setText(tr("Cancel")); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h new file mode 100644 index 000000000..bbb589bdb --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h @@ -0,0 +1,46 @@ +/** + * @file dlg_invite_to_game.h + * @ingroup RoomDialogs + */ +//! \todo Document this file. + +#ifndef DLG_INVITE_TO_GAME_H +#define DLG_INVITE_TO_GAME_H + +#include +#include + +class QLineEdit; +class QPushButton; +class TabSupervisor; +class UserListWidget; + +class DlgInviteToGame : public QDialog +{ + Q_OBJECT +public: + DlgInviteToGame(TabSupervisor *_tabSupervisor, + const QString &_inviteUrl, + bool _onlyBuddies, + const QStringList &_excludeUserNames, + QWidget *parent = nullptr); + +private slots: + void searchTextChanged(const QString &text); + void inviteCurrentUser(const QString &userName); + +private: + TabSupervisor *tabSupervisor; + QString inviteUrl; + bool onlyBuddies; + QStringList excludeUserNames; + QString currentUserName; + QLineEdit *searchEdit; + UserListWidget *userList; + QPushButton *inviteButton; + QPushButton *cancelButton; + + void retranslateUi(); +}; + +#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 b63457169..685325f50 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -354,7 +354,10 @@ bool UserListItemDelegate::editorEvent(QEvent *event, if ((event->type() == QEvent::MouseButtonPress) && index.isValid()) { QMouseEvent *const mouseEvent = static_cast(event); if (mouseEvent->button() == Qt::RightButton) { - owner->showContextMenu(mouseEvent->globalPosition().toPoint(), index); + // Dialog mode has no context menu: consume the press, show nothing. + if (owner->getHasUserInfoPopup()) { + owner->showContextMenu(mouseEvent->globalPosition().toPoint(), index); + } return true; } } @@ -578,8 +581,10 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, UserListType _type, - QWidget *parent) - : QGroupBox(parent), tabSupervisor(_tabSupervisor), client(_client), type(_type), onlineCount(0) + QWidget *parent, + bool _hasUserInfoPopup) + : QGroupBox(parent), hasUserInfoPopup(_hasUserInfoPopup), tabSupervisor(_tabSupervisor), client(_client), + type(_type), onlineCount(0) { avatarProvider = new UserAvatarProvider(client, this); cardArtProvider = new UserCardArtProvider(this); @@ -605,15 +610,8 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); userTree->header()->setStretchLastSection(true); - // ── Hover popup ─────────────────────────────────────────────────────────── - userInfoPopup = new UserInfoPopup(tabSupervisor, tabSupervisor->getClient(), &avatarProvider->cache(), - &cardArtProvider->cache(), &cardArtParamsMap, - window()); // parented to main window so it floats above siblings - - userInfoPopup->hide(); - userInfoPopup->setWindowOpacity(0.0); - userInfoPopup->installEventFilter(this); - + // Always create timers so callers never segfault on a null deref; + // showPopupForUser / hidePopup already guard against a null userInfoPopup. showPopupTimer = new QTimer(this); showPopupTimer->setSingleShot(true); showPopupTimer->setInterval(280); @@ -639,65 +637,104 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, // The hover ends when the cursor leaves the user row. Empty list // space, a section divider and anything outside the tree all close // the popup, while the popup itself keeps it alive. - if (!popupPinned && !userInfoPopup->underMouse() && (hoveredUser.isEmpty() || !userTree->underMouse())) { + if (!popupPinned && userInfoPopup && !userInfoPopup->underMouse() && + (hoveredUser.isEmpty() || !userTree->underMouse())) { hidePopup(); } }); - connectPopupSignals(); + if (hasUserInfoPopup) { + // ── Hover popup ─────────────────────────────────────────────────────── + userInfoPopup = new UserInfoPopup(tabSupervisor, tabSupervisor->getClient(), &avatarProvider->cache(), + &cardArtProvider->cache(), &cardArtParamsMap, + window()); // parented to main window so it floats above siblings + + userInfoPopup->hide(); + userInfoPopup->setWindowOpacity(0.0); + userInfoPopup->installEventFilter(this); + + connectPopupSignals(); + } userTree->setMouseTracking(true); userTree->viewport()->setMouseTracking(true); userTree->viewport()->installEventFilter(this); userTree->installEventFilter(this); // keyboard handling for section dividers - // Clicking anywhere outside the list clears its selection and closes the - // popup. The filter watches all widgets because the press can land on any - // part of the window, on another list or on the popup itself. - qApp->installEventFilter(this); + if (hasUserInfoPopup) { + // Clicking anywhere outside the list clears its selection and closes the + // popup. The filter watches all widgets because the press can land on any + // part of the window, on another list or on the popup itself. + qApp->installEventFilter(this); - // Pin on item click - connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { - // Clicking a section divider toggles it - if (sectioned && item->type() == SectionItemType) { - setExpandedProgrammatically(item, !item->isExpanded()); - handleSectionExpansion(item, item->isExpanded()); - return; - } - if (!SettingsCache::instance().appearance().getStyleUserList()) { - return; - } - if (item->type() != QTreeWidgetItem::Type) { - return; // divider rows have no user popup - } - popupPinned = false; // reset so showPopupForUser can update - showPopupForUser(static_cast(item)); - popupPinned = true; // pin after showing - }); + // Pin on item click + connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { + // Clicking a section divider toggles it + if (sectioned && item->type() == SectionItemType) { + setExpandedProgrammatically(item, !item->isExpanded()); + handleSectionExpansion(item, item->isExpanded()); + return; + } + if (!SettingsCache::instance().appearance().getStyleUserList()) { + return; + } + if (item->type() != QTreeWidgetItem::Type) { + return; // divider rows have no user popup + } + popupPinned = false; // reset so showPopupForUser can update + showPopupForUser(static_cast(item)); + popupPinned = true; // pin after showing + }); - connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, - [this](const QItemSelection &sel, const QItemSelection &) { - if (sel.isEmpty() && popupPinned) { - popupPinned = false; - hidePopup(); - } - }); + connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, + [this](const QItemSelection &sel, const QItemSelection &) { + if (sel.isEmpty() && popupPinned) { + popupPinned = false; + hidePopup(); + } + }); - // Keyboard selection: show the popup for the current row and hide it when - // the focus moves to a section divider or leaves the list entirely. The - // popup therefore follows arrow key navigation exactly like mouse hover. - // When it was pinned by a click it stays open and follows the selection. - connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) { - if (!isVisible() || !SettingsCache::instance().appearance().getStyleUserList()) { - return; - } - if (current && current->type() == QTreeWidgetItem::Type) { - showPopupForUser(static_cast(current)); - } else { - popupPinned = false; - hidePopup(); - } - }); + // Keyboard selection: the popup is a mouse surface, so keyboard + // navigation shows no floating popup. A pinned (clicked) popup still + // follows the selection so it does not strand on a stale user while + // arrows move the cursor. + connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) { + if (!popupPinned) { + return; // keyboard navigation shows no popup + } + if (!isVisible() || !SettingsCache::instance().appearance().getStyleUserList()) { + return; + } + if (current && current->type() == QTreeWidgetItem::Type) { + showPopupForUser(static_cast(current)); + } else { + popupPinned = false; + hidePopup(); + } + }); + + // Hide popup when list scrolls (reference row has moved) + connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { + showPopupTimer->stop(); + hidePopup(true); + requestAvatarsForVisibleItems(); + }); + + // Forward join requests from popup upward + connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); + } else { + // Dialog mode: keyboard selection drives the Invite button. + connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) { + const QString userName = (current && current->type() == QTreeWidgetItem::Type) + ? current->data(2, Qt::UserRole).toString() + : QString(); + emit currentUserChanged(userName); + }); + + // Keep the popup-less scroll path alive for avatar prefetch. + connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, + [this] { requestAvatarsForVisibleItems(); }); + } // Section dividers can be collapsed/expanded by the user. Surface those // changes only from real user interaction. Programmatic expansion is @@ -707,16 +744,6 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, connect(userTree, &QTreeWidget::itemCollapsed, this, [this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); }); - // Hide popup when list scrolls (reference row has moved) - connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { - showPopupTimer->stop(); - hidePopup(true); - requestAvatarsForVisibleItems(); - }); - - // Forward join requests from popup upward - connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); - connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, &UserListWidget::refreshVisibleUserHeader); connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, &UserListWidget::refreshVisibleUserHeader); @@ -839,6 +866,9 @@ void UserListWidget::bind(UserListManager *mgr) void UserListWidget::refreshVisibleUserHeader(const QString &name) { userTree->viewport()->update(); + if (!userInfoPopup) { + return; + } if (userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == name) { userInfoPopup->refreshHeader(); } @@ -846,6 +876,9 @@ void UserListWidget::refreshVisibleUserHeader(const QString &name) void UserListWidget::refreshPopupButtons(const QString &userName) { + if (!userInfoPopup) { + return; + } UserListTWI *item = users.value(userName); if (!item) { return; @@ -863,6 +896,9 @@ void UserListWidget::refreshPopupButtons(const QString &userName) void UserListWidget::hideEvent(QHideEvent *e) { QGroupBox::hideEvent(e); + if (!userInfoPopup) { + return; + } showPopupTimer->stop(); hidePopupTimer->stop(); hidePopup(true); @@ -871,6 +907,9 @@ void UserListWidget::hideEvent(QHideEvent *e) void UserListWidget::showEvent(QShowEvent *e) { QGroupBox::showEvent(e); + if (!userInfoPopup) { + return; + } requestAvatarsForVisibleItems(); } @@ -943,6 +982,24 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event) } } + // Keyboard entry to the user context menu: the Menu key (or Shift+F10) + // pops the same menu the right-click shows, anchored to the focused row. + // Divider rows have no menu. Mouse-triggered context events are NOT handled + // here — the delegate's right-press path already pops the menu, and + // handling both would open two menus on one right-click. + if (hasUserInfoPopup && (obj == userTree || obj == userTree->viewport()) && event->type() == QEvent::ContextMenu) { + auto *contextEvent = static_cast(event); + if (contextEvent->reason() == QContextMenuEvent::Keyboard) { + QTreeWidgetItem *current = userTree->currentItem(); + if (current && current->type() == QTreeWidgetItem::Type) { + const QPoint globalPos = userTree->viewport()->mapToGlobal(userTree->visualItemRect(current).center()); + showContextMenu(globalPos, userTree->indexFromItem(current)); + return true; + } + return false; // divider rows: no menu + } + } + // Keyboard navigation of the section dividers. // The dividers are selectable so arrow keys land on them. When one is the // current item, Enter/Space toggle it (like a button) and Left/Right follow @@ -964,7 +1021,7 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event) } } - if (obj == userTree->viewport()) { + if (hasUserInfoPopup && obj == userTree->viewport()) { if (event->type() == QEvent::MouseMove) { if (!SettingsCache::instance().appearance().getStyleUserList()) { return QGroupBox::eventFilter(obj, event); @@ -1004,6 +1061,9 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event) void UserListWidget::showPopupForUser(UserListTWI *item) { + if (!userInfoPopup) { + return; + } if (!item) { return; } @@ -1062,6 +1122,9 @@ void UserListWidget::showPopupForUser(UserListTWI *item) void UserListWidget::positionPopup(UserListTWI *item) { + if (!userInfoPopup) { + return; + } if (!item) { return; } @@ -1116,6 +1179,9 @@ void UserListWidget::positionPopup(UserListTWI *item) void UserListWidget::hidePopup(bool immediate) { + if (!userInfoPopup) { + return; + } showPopupTimer->stop(); hidePopupTimer->stop(); if (!userInfoPopup->isVisible()) { @@ -1476,8 +1542,10 @@ void UserListWidget::applyFilter() int visible = 0; for (int i = 0; i < divider->childCount(); ++i) { auto *child = static_cast(divider->child(i)); - const bool match = - !searching || QString::fromStdString(child->getUserInfo().name()).toLower().contains(lower); + const QString name = QString::fromStdString(child->getUserInfo().name()); + const bool passesFilter = + !userFilter || userFilter(name, child->data(0, UserListRoles::Online).toBool()); + const bool match = passesFilter && (!searching || name.toLower().contains(lower)); child->setHidden(!match); if (match) { ++visible; @@ -1497,6 +1565,7 @@ void UserListWidget::applyFilter() } requestAvatarsForVisibleItems(); userTree->viewport()->update(); + emit userListChanged(); return; } @@ -1514,6 +1583,7 @@ void UserListWidget::applyFilter() requestAvatarsForVisibleItems(); userTree->viewport()->update(); + emit userListChanged(); } void UserListWidget::userClicked(QTreeWidgetItem *item, int /*column*/) @@ -1521,7 +1591,38 @@ void UserListWidget::userClicked(QTreeWidgetItem *item, int /*column*/) if (item->type() != QTreeWidgetItem::Type) { return; // divider rows open no chat } - emit openMessageDialog(item->data(2, Qt::UserRole).toString(), true); + const QString userName = item->data(2, Qt::UserRole).toString(); + if (hasUserInfoPopup) { + emit openMessageDialog(userName, true); + } else { + emit userActivated(userName); + } +} + +int UserListWidget::visibleUserRowCount() const +{ + int count = 0; + if (sectioned) { + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider || divider->isHidden()) { + continue; + } + for (int i = 0; i < divider->childCount(); ++i) { + if (!divider->child(i)->isHidden()) { + ++count; + } + } + } + return count; + } + for (int i = 0; i < userTree->topLevelItemCount(); ++i) { + QTreeWidgetItem *item = userTree->topLevelItem(i); + if (!item->isHidden() && item->type() == QTreeWidgetItem::Type) { + ++count; + } + } + return count; } void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index) @@ -1770,6 +1871,13 @@ UserListTWI *UserListWidget::ensureSectionMembership(Section section, const Serv updateCardArtParams(user, userName); + // Dialog mode: rows that fail the user filter never exist. applyFilter() + // re-checks the predicate on every pass so a live state change (e.g. the + // user being ignored mid-dialog) hides an already created row. + if (userFilter && !userFilter(userName, online)) { + return nullptr; + } + QTreeWidgetItem *divider = sectionItems.value(section); if (!divider) { return nullptr; 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 d97843264..0407ad8ca 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -173,6 +173,8 @@ private: QString hoveredUser; bool popupPinned = false; bool bulkLoading = false; + bool hasUserInfoPopup = true; + std::function userFilter; /** * Popup functions are anchored on the row, not the user name. In sectioned @@ -242,12 +244,19 @@ signals: void removeIgnore(const QString &userName); void joinGameRequested(int gameId, int roomId, bool asSpectator); void sectionExpanded(Section section, bool expanded); + /** Dialog mode: the user activated (Enter/double-click) the given row. */ + void userActivated(const QString &userName); + /** Dialog mode: the current row changed; empty string means no user row. */ + void currentUserChanged(const QString &userName); + /** The set of visible rows changed (filter, search or a live mutation). */ + void userListChanged(); public: UserListWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, UserListType _type, - QWidget *parent = nullptr); + QWidget *parent = nullptr, + bool hasUserInfoPopup = true); ~UserListWidget() override; void bind(UserListManager *mgr); void applyDisplayMode(); @@ -263,6 +272,16 @@ public: void setShowTitle(bool showTitle); void setSectioned(const QList
&ids); void setSectionExpanded(Section section, bool expanded); + /** Dialog mode: rows that fail the predicate are never shown. */ + void setUserFilter(std::function filter) + { + userFilter = std::move(filter); + } + [[nodiscard]] int visibleUserRowCount() const; + [[nodiscard]] bool getHasUserInfoPopup() const + { + return hasUserInfoPopup; + } [[nodiscard]] const QList
&getSectionIds() const { return sectionIds; diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 513b7c926..dbf4a5a4a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -20,6 +20,7 @@ #include "../interface/card_picture_loader/card_picture_loader.h" #include "../interface/widgets/cards/card_info_frame_widget.h" #include "../interface/widgets/dialogs/dlg_create_game.h" +#include "../interface/widgets/dialogs/dlg_invite_to_game.h" #include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/utility/completer_utils.h" @@ -43,10 +44,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -296,6 +299,9 @@ void TabGame::retranslateUi() QString tabText = " | " + type + " #" + QString::number(game->getGameMetaInfo()->gameId()); updatePlayerListDockTitle(); + if (inviteButton) { + inviteButton->setText(tr("Invite")); + } cardInfoDock->setWindowTitle(tr("Card Info") + (cardInfoDock->isWindow() ? tabText : QString())); messageLayoutDock->setWindowTitle(tr("Messages") + (messageLayoutDock->isWindow() ? tabText : QString())); if (replayDock) { @@ -337,6 +343,9 @@ void TabGame::retranslateUi() if (aCopyGameLink) { aCopyGameLink->setText(tr("Cop&y game link")); } + if (aInviteToGame) { + aInviteToGame->setText(tr("Invite to Game...")); + } if (aConcede) { if (game->getPlayerManager()->isMainPlayerConceded()) { aConcede->setText(tr("Un&concede")); @@ -513,6 +522,47 @@ void TabGame::actCopyGameLink() QApplication::clipboard()->setText(link); } +void TabGame::updateInviteButtonState() +{ + // The dock button stays conservative (pre-start, not full); the menu action + // additionally covers started/full games, which are legitimate spectate + // invites, so it only needs the server-linked + not-closed conditions. + const bool canInvite = !tabSupervisor->getIsLocalGame() && !game->getGameState()->isGameClosed() && + !game->getGameMetaInfo()->started() && + game->getPlayerManager()->getPlayerCount() < game->getGameMetaInfo()->maxPlayers(); + if (inviteButton) { + inviteButton->setVisible(canInvite); + } + if (aInviteToGame) { + aInviteToGame->setEnabled(!tabSupervisor->getIsLocalGame() && !game->getGameState()->isGameClosed()); + } +} + +void TabGame::actInviteToGame() +{ + if (!tabSupervisor || tabSupervisor->getIsLocalGame()) { + return; + } + + GameMetaInfo *metaInfo = game->getGameMetaInfo(); + const QString inviteUrl = makeGameJoinLink( + tabSupervisor->getClient()->serverName(), tabSupervisor->getClient()->serverPort(), metaInfo->proto().room_id(), + metaInfo->gameId(), QString::fromStdString(metaInfo->proto().description())); + + QStringList excludeUserNames; + excludeUserNames << tabSupervisor->getUserListManager()->getOwnUsername(); + for (auto player : game->getPlayerManager()->getPlayers()) { + excludeUserNames << player->getPlayerInfo()->getName(); + } + for (auto it = game->getPlayerManager()->getSpectators().cbegin(); + it != game->getPlayerManager()->getSpectators().cend(); ++it) { + excludeUserNames << QString::fromStdString(it.value().name()); + } + + DlgInviteToGame dlg(tabSupervisor, inviteUrl, metaInfo->proto().only_buddies(), excludeUserNames, this); + dlg.exec(); +} + void TabGame::actConcede() { PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer()); @@ -1004,6 +1054,8 @@ void TabGame::createMenuItems() aCopyGameLink = new QAction(this); aCopyGameLink->setEnabled(!tabSupervisor->getIsLocalGame() && !tabSupervisor->getClient()->serverName().isEmpty()); connect(aCopyGameLink, &QAction::triggered, this, &TabGame::actCopyGameLink); + aInviteToGame = new QAction(this); + connect(aInviteToGame, &QAction::triggered, this, &TabGame::actInviteToGame); aConcede = new QAction(this); connect(aConcede, &QAction::triggered, this, &TabGame::actConcede); if (!game->getGameMetaInfo()->started()) { @@ -1043,6 +1095,7 @@ void TabGame::createMenuItems() gameMenu->addSeparator(); gameMenu->addAction(aGameInfo); gameMenu->addAction(aCopyGameLink); + gameMenu->addAction(aInviteToGame); gameMenu->addAction(aConcede); gameMenu->addAction(aFocusChat); gameMenu->addAction(aLeaveGame); @@ -1051,6 +1104,9 @@ void TabGame::createMenuItems() aCardMenu = gameMenu->addMenu(new QMenu(this)); + // Sync the new action with the same state the dock button already shows. + updateInviteButtonState(); + addTabMenu(gameMenu); } @@ -1066,6 +1122,7 @@ void TabGame::createReplayMenuItems() aResetLayout = nullptr; aGameInfo = nullptr; aCopyGameLink = nullptr; + aInviteToGame = nullptr; aConcede = nullptr; aFocusChat = nullptr; aLeaveGame = new QAction(this); @@ -1264,11 +1321,29 @@ void TabGame::createPlayerListDock(bool bReplay) } playerListWidget->setFocusPolicy(Qt::NoFocus); + auto *playerListBox = new QWidget(this); + auto *vbox = new QVBoxLayout(playerListBox); + vbox->setContentsMargins(0, 0, 0, 0); + vbox->setSpacing(0); + + vbox->addWidget(playerListWidget); + + if (!bReplay) { + inviteButton = new QPushButton(tr("Invite"), playerListBox); + inviteButton->setVisible(false); + connect(inviteButton, &QPushButton::clicked, this, &TabGame::actInviteToGame); + vbox->addWidget(inviteButton); + + connect(game->getGameMetaInfo(), &GameMetaInfo::startedChanged, this, &TabGame::updateInviteButtonState); + connect(game->getPlayerManager(), &PlayerManager::playerCountChanged, this, &TabGame::updateInviteButtonState); + updateInviteButtonState(); + } + playerListDock = new QDockWidget(this); playerListDock->setObjectName("playerListDock"); playerListDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetMovable); - playerListDock->setWidget(playerListWidget); + playerListDock->setWidget(playerListBox); playerListDock->setFloating(false); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index b6555deef..a05b49a9f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -37,6 +37,7 @@ class CardInfoFrameWidget; class QTimer; class QSplitter; class QLabel; +class QPushButton; class QToolButton; class QMenu; class ZoneViewLayout; @@ -69,6 +70,7 @@ private: CardInfoFrameWidget *cardInfoFrameWidget; PlayerListWidget *playerListWidget; + QPushButton *inviteButton = nullptr; QLabel *timeElapsedLabel; MessageLogWidget *messageLog; QLabel *sayLabel; @@ -86,6 +88,7 @@ private: QAction *aGameInfo, *aConcede, *aCopyGameLink, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn, *aReverseTurn, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout; QAction *aFocusChat; + QAction *aInviteToGame = nullptr; QList phaseActions; QAction *aCardMenu; @@ -128,6 +131,7 @@ private: void createPlayAreaWidget(bool bReplay = false); void createDeckViewContainerWidget(bool bReplay = false); void createReplayDock(GameReplay *replay); + void updateInviteButtonState(); signals: void gameClosing(TabGame *tab); void containerProcessingStarted(const GameEventContext &context); @@ -147,6 +151,7 @@ private slots: void setCardMenu(CardMenu *menu); void actGameInfo(); + void actInviteToGame(); void actConcede(); void actCopyGameLink(); void actRemoveLocalArrows(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp index d482d3dd7..9506d96f3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp @@ -128,6 +128,12 @@ bool TabMessage::isUserOnline() const return userOnline; } +void TabMessage::sendInviteMessage(const QString &text) +{ + sayEdit->setText(text); + sendMessage(); +} + void TabMessage::messageSent(const Response &response, const CommandContainer & /*commandContainer*/, const QVariant &extraData) diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.h b/cockatrice/src/interface/widgets/tabs/tab_message.h index e9b987ce2..0e6d66d4c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.h +++ b/cockatrice/src/interface/widgets/tabs/tab_message.h @@ -66,6 +66,7 @@ public: [[nodiscard]] bool isUserOnline() const; void sendPrivateMessage(const QString &text); + void sendInviteMessage(const QString &text); private: bool shouldShowSystemPopup(const Event_UserMessage &event); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 77b93802a..016d96434 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -993,8 +993,8 @@ QList TabSupervisor::getGameInviteLinksForRoom(int roomId) con void TabSupervisor::sendInviteToUser(const QString &userName, const QString &inviteText) { TabMessage *tab = addMessageTab(userName, true); - if (tab && tab->isUserOnline()) { - tab->sendPrivateMessage(inviteText); + if (tab) { + tab->sendInviteMessage(inviteText); } }