[Game] Add an invite button to non-started and not full games (#7143)

* [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 <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-08-17 21:37:15 +02:00 committed by GitHub
parent 08d6b51db9
commit 7c550ee505
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 449 additions and 76 deletions

View file

@ -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 <QLabel>
#include <QMenu>
#include <QMessageBox>
#include <QPushButton>
#include <QRegularExpression>
#include <QStackedWidget>
#include <QStringListModel>
#include <QTimer>
#include <QVBoxLayout>
#include <QWidget>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
@ -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);
}

View file

@ -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<QAction *> 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();

View file

@ -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)

View file

@ -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);

View file

@ -993,8 +993,8 @@ QList<GameInviteOption> 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);
}
}