From 765ebf8fb1be894194343ae2f257a9ac471bd55b Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:49:19 +0200 Subject: [PATCH] [UserList] Context menu invite (#7138) 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 50 seconds Took 3 minutes * [Client] Extract sendPrivateMessage() to fix invite message draft overwrite sendInviteMessage() was calling sayEdit->setText(text) then sendMessage(), which overwrites any text the user had typed. Extract the command-building and sending logic into a new sendPrivateMessage(const QString &text) method that takes the text directly. sendMessage() now calls it after its guards and clears sayEdit; sendInviteMessage() calls it directly without touching the input field at all. Took 33 minutes * Rename method, address comments. Took 5 minutes --------- Co-authored-by: Lukas Brübach --- .../widgets/server/user/user_context_menu.cpp | 67 ++++++++++++++++++- .../widgets/server/user/user_context_menu.h | 30 ++++++++- .../widgets/server/user/user_list_widget.cpp | 5 ++ .../widgets/server/user/user_list_widget.h | 3 + .../interface/widgets/tabs/tab_message.cpp | 29 +++++--- .../src/interface/widgets/tabs/tab_message.h | 3 + .../src/interface/widgets/tabs/tab_room.cpp | 5 ++ .../interface/widgets/tabs/tab_supervisor.cpp | 48 +++++++++++++ .../interface/widgets/tabs/tab_supervisor.h | 3 + 9 files changed, 179 insertions(+), 14 deletions(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 5c4a88974..372dbfc19 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -355,6 +355,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, { QAction *aCopyToClipBoard = nullptr, *aRemoveMessages = nullptr; aUserName->setText(userName); + const bool anotherUser = userName != userListProxy->getOwnUsername(); auto *menu = new QMenu(static_cast(parent())); menu->addAction(aUserName); @@ -366,6 +367,17 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addAction(aDetails); menu->addAction(aShowGames); menu->addAction(aChat); + const QList inviteOptions = inviteOptionsForUser(userName); + if (!inviteOptions.isEmpty()) { + auto *inviteMenu = new QMenu(tr("&Invite to Game"), menu); + for (const GameInviteOption &option : inviteOptions) { + QAction *inviteAction = inviteMenu->addAction(option.label); + inviteAction->setEnabled(anotherUser && online); + connect(inviteAction, &QAction::triggered, this, + [this, userName, option] { execInvite(userName, option); }); + } + menu->addMenu(inviteMenu); + } if (userLevel.testFlag(ServerInfo_User::IsRegistered) && userListProxy->isOwnUserRegistered()) { menu->addSeparator(); if (userListProxy->isUserBuddy(userName)) { @@ -416,7 +428,6 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addAction(aPromoteToJudge); } } - bool anotherUser = userName != userListProxy->getOwnUsername(); aDetails->setEnabled(true); aChat->setEnabled(anotherUser && online); aShowGames->setEnabled(online); @@ -480,6 +491,60 @@ void UserContextMenu::execChat(const QString &userName) emit openMessageDialog(userName, true); } +QList UserContextMenu::inviteOptionsForUser(const QString &userName) const +{ + if (!gameInviteLinkProvider) { + return {}; + } + const QList options = gameInviteLinkProvider(); + QList result; + for (const GameInviteOption &option : options) { + // Buddy-only games accept invites only from their creator, and only to + // users on the creator's buddy list. + if (option.onlyBuddies && + (option.creatorName != userListProxy->getOwnUsername() || !userListProxy->isUserBuddy(userName))) { + continue; + } + result.append(option); + } + return result; +} + +void UserContextMenu::execInvite(const QString &userName) +{ + const QList options = inviteOptionsForUser(userName); + if (options.isEmpty()) { + return; + } + + if (options.size() == 1) { + execInvite(userName, options.first()); + return; + } + + // More than one game in the room — let the user pick which one to invite to. + auto *menu = new QMenu(static_cast(parent())); + for (const GameInviteOption &option : options) { + QAction *action = menu->addAction(option.label); + connect(action, &QAction::triggered, this, [this, userName, option] { execInvite(userName, option); }); + } + menu->setAttribute(Qt::WA_DeleteOnClose); + menu->popup(QCursor::pos()); +} + +void UserContextMenu::execInvite(const QString &userName, const GameInviteOption &option) +{ + // Name the game by description first, then its id — "Join my game 'Magic' + // (#123)" — so a description-less fallback still identifies the game. + // The multi-arg .arg() overloads replace in a single pass, so a description + // containing "%…" cannot corrupt later placeholders. + const QString prefix = + option.description.isEmpty() + ? tr("Join my game (#%1):").arg(option.gameId) + : tr("Join my game \"%1\" (#%2):").arg(option.description, QString::number(option.gameId)); + tabSupervisor->sendInviteToUser(userName, prefix + " " + option.url); +} + void UserContextMenu::execDetails(const QString &userName) { auto *w = new UserInfoBox(client, false, static_cast(parent()), diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index 00fdc51fe..70bbff977 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -7,9 +7,12 @@ #ifndef USER_CONTEXT_MENU_H #define USER_CONTEXT_MENU_H -#include -#include +#include "../../interface/widgets/server/game_link.h" +#include +#include +#include +#include class AbstractGame; class UserListProxy; class AbstractClient; @@ -43,6 +46,7 @@ private: QAction *aPromoteToJudge, *aDemoteFromJudge; QAction *aWarnUser, *aWarnHistory; QAction *aGetAdminNotes; + std::function()> gameInviteLinkProvider; signals: void openMessageDialog(const QString &userName, bool focus); private slots: @@ -80,9 +84,28 @@ public: return userListProxy; } + void setGameInviteLinkProvider(std::function()> provider) + { + gameInviteLinkProvider = std::move(provider); + } + + /** + * The games currently inviteable for @p userName, honoring the room's + * buddy-only setting (the inviter must be the game's creator and the + * target a buddy of theirs). Empty when there is no live provider. + */ + QList inviteOptionsForUser(const QString &userName) const; + + /** Whether at least one invite link is currently available for @p userName. */ + bool hasGameInviteLink(const QString &userName) const + { + return !inviteOptionsForUser(userName).isEmpty(); + } + // Individual action entry points — used by UserInfoPopup to trigger // actions without re-running the full context menu flow. void execChat(const QString &userName); + void execInvite(const QString &userName); void execDetails(const QString &userName); void execShowGames(const QString &userName); void execAddToBuddy(const QString &userName); @@ -97,6 +120,9 @@ public: void execAdminNotes(const QString &userName); void execAdjustMod(const QString &userName, bool shouldBeMod); void execAdjustJudge(const QString &userName, bool shouldBeJudge); + +private: + void execInvite(const QString &userName, const GameInviteOption &option); }; #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 be52b9871..b63457169 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1838,3 +1838,8 @@ void UserListWidget::finishSectionedMutation() applyFilter(); userTree->viewport()->update(); } + +void UserListWidget::setGameInviteLinkProvider(std::function()> provider) +{ + userContextMenu->setGameInviteLinkProvider(std::move(provider)); +} 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 e048c7fb7..d97843264 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -8,6 +8,7 @@ #define USERLIST_H #include "../../cards/card_info_picture_art_crop_widget.h" +#include "../../interface/widgets/server/game_link.h" #include "user_avatar_provider.h" #include "user_card_art_provider.h" #include "user_info_popup.h" @@ -22,6 +23,7 @@ #include #include #include +#include #include #include @@ -271,6 +273,7 @@ public: } void showContextMenu(const QPoint &pos, const QModelIndex &index); void sortItems(); + void setGameInviteLinkProvider(std::function()> provider); protected: void hideEvent(QHideEvent *e) override; diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp index 9eccea7a2..d482d3dd7 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp @@ -96,6 +96,18 @@ void TabMessage::closeEvent(QCloseEvent *event) event->accept(); } +void TabMessage::sendPrivateMessage(const QString &text) +{ + Command_Message cmd; + cmd.set_user_name(otherUserInfo->name()); + cmd.set_message(text.toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + pend->setExtraData(text); + connect(pend, &PendingCommand::finished, this, &TabMessage::messageSent); + client->sendCommand(pend); +} + void TabMessage::sendMessage() { if (sayEdit->text().isEmpty()) { @@ -103,24 +115,19 @@ void TabMessage::sendMessage() } 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; } - Command_Message cmd; - cmd.set_user_name(otherUserInfo->name()); - 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); - + sendPrivateMessage(sayEdit->text()); sayEdit->clear(); } +bool TabMessage::isUserOnline() const +{ + return userOnline; +} + 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 f7d15b4f6..e9b987ce2 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.h +++ b/cockatrice/src/interface/widgets/tabs/tab_message.h @@ -64,6 +64,9 @@ public: void processUserLeft(); void processUserJoined(const ServerInfo_User &_userInfo); + [[nodiscard]] bool isUserOnline() const; + void sendPrivateMessage(const QString &text); + private: bool shouldShowSystemPopup(const Event_UserMessage &event); void showSystemPopup(const Event_UserMessage &event); diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 508d5a048..6245b5301 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -4,6 +4,7 @@ #include "../../../client/settings/shortcuts_settings.h" #include "../interface/widgets/dialogs/dlg_settings.h" #include "../interface/widgets/server/chat_view/chat_view.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/game_selector.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/server/user/user_list_panel_widget.h" @@ -30,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -66,6 +68,9 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, userList = userListPanel->getUserList(); connect(userListPanel, &UserListPanelWidget::openMessageDialog, this, &TabRoom::openMessageDialog); + const auto gameInviteLinkProvider = [this]() { return tabSupervisor->getGameInviteLinksForRoom(roomId); }; + userList->setGameInviteLinkProvider(gameInviteLinkProvider); + chatView = new ChatView(tabSupervisor, nullptr, true, this); connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 1ab812c54..77b93802a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -3,6 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" #include "../interface/pixel_map_generator.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/server/user/user_list_widget.h" #include "../main.h" @@ -950,6 +951,53 @@ void TabSupervisor::talkLeft(TabMessage *tab) removeTab(indexOf(tab)); } +QList TabSupervisor::getGameInviteLinksForRoom(int roomId) const +{ + QList options; + if (isLocalGame) { + return options; + } + + // The inviter may be in several games of the same room (hosting one and + // spectating another, for example). Return every game so the caller can + // let the user choose which one to invite to. + for (TabGame *tab : gameTabs) { + GameMetaInfo *metaInfo = tab->getGame()->getGameMetaInfo(); + if (metaInfo->proto().room_id() != roomId) { + continue; + } + // A closed game is a dead end — drop it. Started/full games stay + // listed: an invite to them is a legitimate "come spectate" offer. + if (metaInfo->proto().closed()) { + continue; + } + + const int gameId = metaInfo->gameId(); + const QString description = QString::fromStdString(metaInfo->proto().description()); + + GameInviteOption option{ + .gameId = gameId, + .label = + description.isEmpty() ? tr("Game #%1").arg(gameId) : tr("Game #%1 — %2").arg(gameId).arg(description), + .url = makeGameJoinLink(client->serverName(), client->serverPort(), roomId, gameId, description), + .description = description, + .onlyBuddies = metaInfo->proto().only_buddies(), + .creatorName = QString::fromStdString(metaInfo->proto().creator_info().name()), + }; + options.append(option); + } + + return options; +} + +void TabSupervisor::sendInviteToUser(const QString &userName, const QString &inviteText) +{ + TabMessage *tab = addMessageTab(userName, true); + if (tab && tab->isUserOnline()) { + tab->sendPrivateMessage(inviteText); + } +} + /** * Creates a new deck editor tab and loads the deck into it. * Creates either a classic or visual deck editor tab depending on settings diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 5ac3eb365..81ad22f54 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -9,6 +9,7 @@ #define TAB_SUPERVISOR_H #include "../../deck_loader/deck_loader.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_proxy.h" #include "abstract_tab_deck_editor.h" #include "api/archidekt/tab_archidekt.h" @@ -160,6 +161,8 @@ public: { return deckEditorTabs; } + [[nodiscard]] QList getGameInviteLinksForRoom(int roomId) const; + void sendInviteToUser(const QString &userName, const QString &inviteText); [[nodiscard]] bool getAdminLocked() const; void closeEvent(QCloseEvent *event) override; bool switchToGameTabIfAlreadyExists(const int gameId);