[UserList] Context menu invite (#7138)

* [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 <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-08-17 10:49:19 +02:00 committed by GitHub
parent 776f917ffc
commit 765ebf8fb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 179 additions and 14 deletions

View file

@ -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<QWidget *>(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<GameInviteOption> 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<GameInviteOption> UserContextMenu::inviteOptionsForUser(const QString &userName) const
{
if (!gameInviteLinkProvider) {
return {};
}
const QList<GameInviteOption> options = gameInviteLinkProvider();
QList<GameInviteOption> 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<GameInviteOption> 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<QWidget *>(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<QWidget *>(parent()),

View file

@ -7,9 +7,12 @@
#ifndef USER_CONTEXT_MENU_H
#define USER_CONTEXT_MENU_H
#include <QObject>
#include <libcockatrice/network/server/remote/user_level.h>
#include "../../interface/widgets/server/game_link.h"
#include <QList>
#include <QObject>
#include <functional>
#include <libcockatrice/network/server/remote/user_level.h>
class AbstractGame;
class UserListProxy;
class AbstractClient;
@ -43,6 +46,7 @@ private:
QAction *aPromoteToJudge, *aDemoteFromJudge;
QAction *aWarnUser, *aWarnHistory;
QAction *aGetAdminNotes;
std::function<QList<GameInviteOption>()> gameInviteLinkProvider;
signals:
void openMessageDialog(const QString &userName, bool focus);
private slots:
@ -80,9 +84,28 @@ public:
return userListProxy;
}
void setGameInviteLinkProvider(std::function<QList<GameInviteOption>()> 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<GameInviteOption> 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

View file

@ -1838,3 +1838,8 @@ void UserListWidget::finishSectionedMutation()
applyFilter();
userTree->viewport()->update();
}
void UserListWidget::setGameInviteLinkProvider(std::function<QList<GameInviteOption>()> provider)
{
userContextMenu->setGameInviteLinkProvider(std::move(provider));
}

View file

@ -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 <QStyledItemDelegate>
#include <QTextEdit>
#include <QTreeWidgetItem>
#include <functional>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
@ -271,6 +273,7 @@ public:
}
void showContextMenu(const QPoint &pos, const QModelIndex &index);
void sortItems();
void setGameInviteLinkProvider(std::function<QList<GameInviteOption>()> provider);
protected:
void hideEvent(QHideEvent *e) override;