Add input completion test suite (#4644)
Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
@@ -245,6 +245,11 @@ IUserDataController *Application::getUserData()
|
||||
return this->userData;
|
||||
}
|
||||
|
||||
ITwitchIrcServer *Application::getTwitch()
|
||||
{
|
||||
return this->twitch;
|
||||
}
|
||||
|
||||
void Application::save()
|
||||
{
|
||||
for (auto &singleton : this->singletons_)
|
||||
|
||||
+3
-5
@@ -10,6 +10,7 @@
|
||||
namespace chatterino {
|
||||
|
||||
class TwitchIrcServer;
|
||||
class ITwitchIrcServer;
|
||||
class PubSub;
|
||||
|
||||
class CommandController;
|
||||
@@ -55,7 +56,7 @@ public:
|
||||
virtual CommandController *getCommands() = 0;
|
||||
virtual HighlightController *getHighlights() = 0;
|
||||
virtual NotificationController *getNotifications() = 0;
|
||||
virtual TwitchIrcServer *getTwitch() = 0;
|
||||
virtual ITwitchIrcServer *getTwitch() = 0;
|
||||
virtual ChatterinoBadges *getChatterinoBadges() = 0;
|
||||
virtual FfzBadges *getFfzBadges() = 0;
|
||||
virtual IUserDataController *getUserData() = 0;
|
||||
@@ -141,10 +142,7 @@ public:
|
||||
{
|
||||
return this->highlights;
|
||||
}
|
||||
TwitchIrcServer *getTwitch() override
|
||||
{
|
||||
return this->twitch;
|
||||
}
|
||||
ITwitchIrcServer *getTwitch() override;
|
||||
ChatterinoBadges *getChatterinoBadges() override
|
||||
{
|
||||
return this->chatterinoBadges;
|
||||
|
||||
@@ -92,6 +92,7 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord)
|
||||
return;
|
||||
}
|
||||
|
||||
auto *app = getIApp();
|
||||
// Twitch channel
|
||||
auto *tc = dynamic_cast<TwitchChannel *>(&this->channel_);
|
||||
|
||||
@@ -130,7 +131,7 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord)
|
||||
}
|
||||
};
|
||||
|
||||
if (auto account = getApp()->accounts->twitch.getCurrent())
|
||||
if (auto account = app->getAccounts()->twitch.getCurrent())
|
||||
{
|
||||
// Twitch Emotes available globally
|
||||
for (const auto &emote : account->accessEmotes()->emotes)
|
||||
@@ -153,18 +154,18 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord)
|
||||
|
||||
// 7TV Global
|
||||
for (const auto &emote :
|
||||
*getApp()->twitch->getSeventvEmotes().globalEmotes())
|
||||
*app->getTwitch()->getSeventvEmotes().globalEmotes())
|
||||
{
|
||||
addString(emote.first.string, TaggedString::Type::SeventvGlobalEmote);
|
||||
}
|
||||
// Bttv Global
|
||||
for (const auto &emote : *getApp()->twitch->getBttvEmotes().emotes())
|
||||
for (const auto &emote : *app->getTwitch()->getBttvEmotes().emotes())
|
||||
{
|
||||
addString(emote.first.string, TaggedString::Type::BTTVChannelEmote);
|
||||
}
|
||||
|
||||
// Ffz Global
|
||||
for (const auto &emote : *getApp()->twitch->getFfzEmotes().emotes())
|
||||
for (const auto &emote : *app->getTwitch()->getFfzEmotes().emotes())
|
||||
{
|
||||
addString(emote.first.string, TaggedString::Type::FFZChannelEmote);
|
||||
}
|
||||
@@ -172,7 +173,8 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord)
|
||||
// Emojis
|
||||
if (prefix.startsWith(":"))
|
||||
{
|
||||
const auto &emojiShortCodes = getApp()->emotes->emojis.shortCodes;
|
||||
const auto &emojiShortCodes =
|
||||
app->getEmotes()->getEmojis()->getShortCodes();
|
||||
for (const auto &m : emojiShortCodes)
|
||||
{
|
||||
addString(QString(":%1:").arg(m), TaggedString::Type::Emoji);
|
||||
@@ -231,20 +233,20 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord)
|
||||
addString(emote.first.string, TaggedString::Type::BTTVGlobalEmote);
|
||||
}
|
||||
#ifdef CHATTERINO_HAVE_PLUGINS
|
||||
for (const auto &command : getApp()->commands->pluginCommands())
|
||||
for (const auto &command : app->getCommands()->pluginCommands())
|
||||
{
|
||||
addString(command, TaggedString::PluginCommand);
|
||||
}
|
||||
#endif
|
||||
// Custom Chatterino commands
|
||||
for (const auto &command : getApp()->commands->items)
|
||||
for (const auto &command : app->getCommands()->items)
|
||||
{
|
||||
addString(command.name, TaggedString::CustomCommand);
|
||||
}
|
||||
|
||||
// Default Chatterino commands
|
||||
for (const auto &command :
|
||||
getApp()->commands->getDefaultChatterinoCommandList())
|
||||
app->getCommands()->getDefaultChatterinoCommandList())
|
||||
{
|
||||
addString(command, TaggedString::ChatterinoCommand);
|
||||
}
|
||||
@@ -256,6 +258,19 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord)
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<QString> CompletionModel::allItems() const
|
||||
{
|
||||
std::shared_lock lock(this->itemsMutex_);
|
||||
|
||||
std::vector<QString> results;
|
||||
results.reserve(this->items_.size());
|
||||
for (const auto &item : this->items_)
|
||||
{
|
||||
results.push_back(item.string);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
bool CompletionModel::compareStrings(const QString &a, const QString &b)
|
||||
{
|
||||
// try comparing insensitively, if they are the same then senstively
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <set>
|
||||
#include <shared_mutex>
|
||||
|
||||
class InputCompletionTest;
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
class Channel;
|
||||
@@ -60,10 +62,14 @@ public:
|
||||
static bool compareStrings(const QString &a, const QString &b);
|
||||
|
||||
private:
|
||||
std::vector<QString> allItems() const;
|
||||
|
||||
mutable std::shared_mutex itemsMutex_;
|
||||
std::set<TaggedString> items_;
|
||||
|
||||
Channel &channel_;
|
||||
|
||||
friend class ::InputCompletionTest;
|
||||
};
|
||||
|
||||
} // namespace chatterino
|
||||
|
||||
@@ -193,7 +193,7 @@ void BttvEmotes::loadEmotes()
|
||||
{
|
||||
if (!Settings::instance().enableBTTVGlobalEmotes)
|
||||
{
|
||||
this->global_.set(EMPTY_EMOTE_MAP);
|
||||
this->setEmotes(EMPTY_EMOTE_MAP);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -203,13 +203,18 @@ void BttvEmotes::loadEmotes()
|
||||
auto emotes = this->global_.get();
|
||||
auto pair = parseGlobalEmotes(result.parseJsonArray(), *emotes);
|
||||
if (pair.first)
|
||||
this->global_.set(
|
||||
this->setEmotes(
|
||||
std::make_shared<EmoteMap>(std::move(pair.second)));
|
||||
return pair.first;
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
void BttvEmotes::setEmotes(std::shared_ptr<const EmoteMap> emotes)
|
||||
{
|
||||
this->global_.set(std::move(emotes));
|
||||
}
|
||||
|
||||
void BttvEmotes::loadChannel(std::weak_ptr<Channel> channel,
|
||||
const QString &channelId,
|
||||
const QString &channelDisplayName,
|
||||
|
||||
@@ -29,6 +29,7 @@ public:
|
||||
std::shared_ptr<const EmoteMap> emotes() const;
|
||||
boost::optional<EmotePtr> emote(const EmoteName &name) const;
|
||||
void loadEmotes();
|
||||
void setEmotes(std::shared_ptr<const EmoteMap> emotes);
|
||||
static void loadChannel(std::weak_ptr<Channel> channel,
|
||||
const QString &channelId,
|
||||
const QString &channelDisplayName,
|
||||
|
||||
@@ -265,7 +265,7 @@ void Emojis::loadEmojiSet()
|
||||
}
|
||||
|
||||
std::vector<boost::variant<EmotePtr, QString>> Emojis::parse(
|
||||
const QString &text)
|
||||
const QString &text) const
|
||||
{
|
||||
auto result = std::vector<boost::variant<EmotePtr, QString>>();
|
||||
int lastParsedEmojiEndIndex = 0;
|
||||
@@ -359,7 +359,7 @@ std::vector<boost::variant<EmotePtr, QString>> Emojis::parse(
|
||||
return result;
|
||||
}
|
||||
|
||||
QString Emojis::replaceShortCodes(const QString &text)
|
||||
QString Emojis::replaceShortCodes(const QString &text) const
|
||||
{
|
||||
QString ret(text);
|
||||
auto it = this->findShortCodesRegex_.globalMatch(text);
|
||||
@@ -393,4 +393,14 @@ QString Emojis::replaceShortCodes(const QString &text)
|
||||
return ret;
|
||||
}
|
||||
|
||||
const EmojiMap &Emojis::getEmojis() const
|
||||
{
|
||||
return this->emojis;
|
||||
}
|
||||
|
||||
const std::vector<QString> &Emojis::getShortCodes() const
|
||||
{
|
||||
return this->shortCodes;
|
||||
}
|
||||
|
||||
} // namespace chatterino
|
||||
|
||||
@@ -37,16 +37,32 @@ struct EmojiData {
|
||||
|
||||
using EmojiMap = ConcurrentMap<QString, std::shared_ptr<EmojiData>>;
|
||||
|
||||
class Emojis
|
||||
class IEmojis
|
||||
{
|
||||
public:
|
||||
virtual ~IEmojis() = default;
|
||||
|
||||
virtual std::vector<boost::variant<EmotePtr, QString>> parse(
|
||||
const QString &text) const = 0;
|
||||
virtual const EmojiMap &getEmojis() const = 0;
|
||||
virtual const std::vector<QString> &getShortCodes() const = 0;
|
||||
virtual QString replaceShortCodes(const QString &text) const = 0;
|
||||
};
|
||||
|
||||
class Emojis : public IEmojis
|
||||
{
|
||||
public:
|
||||
void initialize();
|
||||
void load();
|
||||
std::vector<boost::variant<EmotePtr, QString>> parse(const QString &text);
|
||||
std::vector<boost::variant<EmotePtr, QString>> parse(
|
||||
const QString &text) const override;
|
||||
|
||||
EmojiMap emojis;
|
||||
std::vector<QString> shortCodes;
|
||||
QString replaceShortCodes(const QString &text);
|
||||
QString replaceShortCodes(const QString &text) const override;
|
||||
|
||||
const EmojiMap &getEmojis() const override;
|
||||
const std::vector<QString> &getShortCodes() const override;
|
||||
|
||||
private:
|
||||
void loadEmojis();
|
||||
|
||||
@@ -188,7 +188,7 @@ void FfzEmotes::loadEmotes()
|
||||
{
|
||||
if (!Settings::instance().enableFFZGlobalEmotes)
|
||||
{
|
||||
this->global_.set(EMPTY_EMOTE_MAP);
|
||||
this->setEmotes(EMPTY_EMOTE_MAP);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -199,13 +199,18 @@ void FfzEmotes::loadEmotes()
|
||||
.timeout(30000)
|
||||
.onSuccess([this](auto result) -> Outcome {
|
||||
auto parsedSet = parseGlobalEmotes(result.parseJson());
|
||||
this->global_.set(std::make_shared<EmoteMap>(std::move(parsedSet)));
|
||||
this->setEmotes(std::make_shared<EmoteMap>(std::move(parsedSet)));
|
||||
|
||||
return Success;
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
void FfzEmotes::setEmotes(std::shared_ptr<const EmoteMap> emotes)
|
||||
{
|
||||
this->global_.set(std::move(emotes));
|
||||
}
|
||||
|
||||
void FfzEmotes::loadChannel(
|
||||
std::weak_ptr<Channel> channel, const QString &channelID,
|
||||
std::function<void(EmoteMap &&)> emoteCallback,
|
||||
|
||||
@@ -22,6 +22,7 @@ public:
|
||||
std::shared_ptr<const EmoteMap> emotes() const;
|
||||
boost::optional<EmotePtr> emote(const EmoteName &name) const;
|
||||
void loadEmotes();
|
||||
void setEmotes(std::shared_ptr<const EmoteMap> emotes);
|
||||
static void loadChannel(
|
||||
std::weak_ptr<Channel> channel, const QString &channelId,
|
||||
std::function<void(EmoteMap &&)> emoteCallback,
|
||||
|
||||
@@ -275,7 +275,7 @@ void SeventvEmotes::loadGlobalEmotes()
|
||||
{
|
||||
if (!Settings::instance().enableSevenTVGlobalEmotes)
|
||||
{
|
||||
this->global_.set(EMPTY_EMOTE_MAP);
|
||||
this->setGlobalEmotes(EMPTY_EMOTE_MAP);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -289,7 +289,8 @@ void SeventvEmotes::loadGlobalEmotes()
|
||||
auto emoteMap = parseEmotes(parsedEmotes, true);
|
||||
qCDebug(chatterinoSeventv)
|
||||
<< "Loaded" << emoteMap.size() << "7TV Global Emotes";
|
||||
this->global_.set(std::make_shared<EmoteMap>(std::move(emoteMap)));
|
||||
this->setGlobalEmotes(
|
||||
std::make_shared<EmoteMap>(std::move(emoteMap)));
|
||||
|
||||
return Success;
|
||||
})
|
||||
@@ -300,6 +301,11 @@ void SeventvEmotes::loadGlobalEmotes()
|
||||
.execute();
|
||||
}
|
||||
|
||||
void SeventvEmotes::setGlobalEmotes(std::shared_ptr<const EmoteMap> emotes)
|
||||
{
|
||||
this->global_.set(std::move(emotes));
|
||||
}
|
||||
|
||||
void SeventvEmotes::loadChannelEmotes(
|
||||
const std::weak_ptr<Channel> &channel, const QString &channelId,
|
||||
std::function<void(EmoteMap &&, ChannelInfo)> callback, bool manualRefresh)
|
||||
|
||||
@@ -75,6 +75,7 @@ public:
|
||||
std::shared_ptr<const EmoteMap> globalEmotes() const;
|
||||
boost::optional<EmotePtr> globalEmote(const EmoteName &name) const;
|
||||
void loadGlobalEmotes();
|
||||
void setGlobalEmotes(std::shared_ptr<const EmoteMap> emotes);
|
||||
static void loadChannelEmotes(
|
||||
const std::weak_ptr<Channel> &channel, const QString &channelId,
|
||||
std::function<void(EmoteMap &&, ChannelInfo)> callback,
|
||||
|
||||
@@ -23,7 +23,21 @@ class TwitchChannel;
|
||||
class BttvLiveUpdates;
|
||||
class SeventvEventAPI;
|
||||
|
||||
class TwitchIrcServer final : public AbstractIrcServer, public Singleton
|
||||
class ITwitchIrcServer
|
||||
{
|
||||
public:
|
||||
virtual ~ITwitchIrcServer() = default;
|
||||
|
||||
virtual const BttvEmotes &getBttvEmotes() const = 0;
|
||||
virtual const FfzEmotes &getFfzEmotes() const = 0;
|
||||
virtual const SeventvEmotes &getSeventvEmotes() const = 0;
|
||||
|
||||
// Update this interface with TwitchIrcServer methods as needed
|
||||
};
|
||||
|
||||
class TwitchIrcServer final : public AbstractIrcServer,
|
||||
public Singleton,
|
||||
public ITwitchIrcServer
|
||||
{
|
||||
public:
|
||||
TwitchIrcServer();
|
||||
@@ -70,9 +84,9 @@ public:
|
||||
std::unique_ptr<BttvLiveUpdates> bttvLiveUpdates;
|
||||
std::unique_ptr<SeventvEventAPI> seventvEventAPI;
|
||||
|
||||
const BttvEmotes &getBttvEmotes() const;
|
||||
const FfzEmotes &getFfzEmotes() const;
|
||||
const SeventvEmotes &getSeventvEmotes() const;
|
||||
const BttvEmotes &getBttvEmotes() const override;
|
||||
const FfzEmotes &getFfzEmotes() const override;
|
||||
const SeventvEmotes &getSeventvEmotes() const override;
|
||||
|
||||
protected:
|
||||
virtual void initializeConnection(IrcConnection *connection,
|
||||
|
||||
@@ -12,7 +12,7 @@ If you're adding support for a new endpoint, these are the things you should kno
|
||||
|
||||
1. Add a virtual function in the `IHelix` class. Naming should reflect the API name as best as possible.
|
||||
1. Override the virtual function in the `Helix` class.
|
||||
1. Mock the function in the `MockHelix` class in the `tests/src/HighlightController.cpp` file.
|
||||
1. Mock the function in the `mock::Helix` class in the `mocks/include/mocks/Helix.hpp` file.
|
||||
1. (Optional) Make a new error enum for the failure callback.
|
||||
|
||||
For a simple example, see the `updateUserChatColor` function and its error enum `HelixUpdateUserChatColorError`.
|
||||
|
||||
@@ -16,6 +16,7 @@ public:
|
||||
virtual ~IEmotes() = default;
|
||||
|
||||
virtual ITwitchEmotes *getTwitchEmotes() = 0;
|
||||
virtual IEmojis *getEmojis() = 0;
|
||||
};
|
||||
|
||||
class Emotes final : public IEmotes, public Singleton
|
||||
@@ -32,6 +33,11 @@ public:
|
||||
return &this->twitch;
|
||||
}
|
||||
|
||||
IEmojis *getEmojis() final
|
||||
{
|
||||
return &this->emojis;
|
||||
}
|
||||
|
||||
TwitchEmotes twitch;
|
||||
Emojis emojis;
|
||||
|
||||
|
||||
@@ -17,12 +17,7 @@
|
||||
namespace {
|
||||
|
||||
using namespace chatterino;
|
||||
|
||||
struct CompletionEmote {
|
||||
EmotePtr emote;
|
||||
QString displayName;
|
||||
QString providerName;
|
||||
};
|
||||
using namespace chatterino::detail;
|
||||
|
||||
void addEmotes(std::vector<CompletionEmote> &out, const EmoteMap &map,
|
||||
const QString &text, const QString &providerName)
|
||||
@@ -53,33 +48,18 @@ void addEmojis(std::vector<CompletionEmote> &out, const EmojiMap &map,
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace chatterino {
|
||||
namespace chatterino::detail {
|
||||
|
||||
InputCompletionPopup::InputCompletionPopup(QWidget *parent)
|
||||
: BasePopup({BasePopup::EnableCustomFrame, BasePopup::Frameless,
|
||||
BasePopup::DontFocus, BaseWindow::DisableLayoutSave},
|
||||
parent)
|
||||
, model_(this)
|
||||
{
|
||||
this->initLayout();
|
||||
|
||||
QObject::connect(&this->redrawTimer_, &QTimer::timeout, this, [this] {
|
||||
if (this->isVisible())
|
||||
{
|
||||
this->ui_.listView->doItemsLayout();
|
||||
}
|
||||
});
|
||||
this->redrawTimer_.setInterval(33);
|
||||
}
|
||||
|
||||
void InputCompletionPopup::updateEmotes(const QString &text, ChannelPtr channel)
|
||||
std::vector<CompletionEmote> buildCompletionEmoteList(const QString &text,
|
||||
ChannelPtr channel)
|
||||
{
|
||||
std::vector<CompletionEmote> emotes;
|
||||
auto *app = getIApp();
|
||||
auto *tc = dynamic_cast<TwitchChannel *>(channel.get());
|
||||
// returns true also for special Twitch channels (/live, /mentions, /whispers, etc.)
|
||||
if (channel->isTwitchChannel())
|
||||
{
|
||||
if (auto user = getApp()->accounts->twitch.getCurrent())
|
||||
if (auto user = app->getAccounts()->twitch.getCurrent())
|
||||
{
|
||||
// Twitch Emotes available globally
|
||||
auto emoteData = user->accessEmotes();
|
||||
@@ -115,21 +95,21 @@ void InputCompletionPopup::updateEmotes(const QString &text, ChannelPtr channel)
|
||||
}
|
||||
}
|
||||
|
||||
if (auto bttvG = getApp()->twitch->getBttvEmotes().emotes())
|
||||
if (auto bttvG = app->getTwitch()->getBttvEmotes().emotes())
|
||||
{
|
||||
addEmotes(emotes, *bttvG, text, "Global BetterTTV");
|
||||
}
|
||||
if (auto ffzG = getApp()->twitch->getFfzEmotes().emotes())
|
||||
if (auto ffzG = app->getTwitch()->getFfzEmotes().emotes())
|
||||
{
|
||||
addEmotes(emotes, *ffzG, text, "Global FrankerFaceZ");
|
||||
}
|
||||
if (auto seventvG = getApp()->twitch->getSeventvEmotes().globalEmotes())
|
||||
if (auto seventvG = app->getTwitch()->getSeventvEmotes().globalEmotes())
|
||||
{
|
||||
addEmotes(emotes, *seventvG, text, "Global 7TV");
|
||||
}
|
||||
}
|
||||
|
||||
addEmojis(emotes, getApp()->emotes->emojis.emojis, text);
|
||||
addEmojis(emotes, app->getEmotes()->getEmojis()->getEmojis(), text);
|
||||
|
||||
// if there is an exact match, put that emote first
|
||||
for (size_t i = 1; i < emotes.size(); i++)
|
||||
@@ -147,6 +127,34 @@ void InputCompletionPopup::updateEmotes(const QString &text, ChannelPtr channel)
|
||||
}
|
||||
}
|
||||
|
||||
return emotes;
|
||||
}
|
||||
|
||||
} // namespace chatterino::detail
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
InputCompletionPopup::InputCompletionPopup(QWidget *parent)
|
||||
: BasePopup({BasePopup::EnableCustomFrame, BasePopup::Frameless,
|
||||
BasePopup::DontFocus, BaseWindow::DisableLayoutSave},
|
||||
parent)
|
||||
, model_(this)
|
||||
{
|
||||
this->initLayout();
|
||||
|
||||
QObject::connect(&this->redrawTimer_, &QTimer::timeout, this, [this] {
|
||||
if (this->isVisible())
|
||||
{
|
||||
this->ui_.listView->doItemsLayout();
|
||||
}
|
||||
});
|
||||
this->redrawTimer_.setInterval(33);
|
||||
}
|
||||
|
||||
void InputCompletionPopup::updateEmotes(const QString &text, ChannelPtr channel)
|
||||
{
|
||||
auto emotes = detail::buildCompletionEmoteList(text, std::move(channel));
|
||||
|
||||
this->model_.clear();
|
||||
|
||||
int count = 0;
|
||||
|
||||
@@ -5,12 +5,29 @@
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
class Channel;
|
||||
using ChannelPtr = std::shared_ptr<Channel>;
|
||||
|
||||
struct Emote;
|
||||
using EmotePtr = std::shared_ptr<const Emote>;
|
||||
|
||||
namespace detail {
|
||||
|
||||
struct CompletionEmote {
|
||||
EmotePtr emote;
|
||||
QString displayName;
|
||||
QString providerName;
|
||||
};
|
||||
|
||||
std::vector<CompletionEmote> buildCompletionEmoteList(const QString &text,
|
||||
ChannelPtr channel);
|
||||
|
||||
} // namespace detail
|
||||
|
||||
class GenericListView;
|
||||
|
||||
class InputCompletionPopup : public BasePopup
|
||||
|
||||
Reference in New Issue
Block a user