forked from OpenGamers/abaddon
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bd5c89266 | ||
|
|
a0599ab812 | ||
|
|
6c54296ba3 | ||
|
|
7ed415040a | ||
|
|
011cb159cf | ||
|
|
25fd2c3840 | ||
|
|
7e3976785f | ||
|
|
75213fcede | ||
|
|
179ff980e9 | ||
|
|
f784550964 | ||
|
|
ce238d08e9 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -5,7 +5,7 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
windows:
|
||||
name: windows-${{ matrix.buildtype }}
|
||||
runs-on: windows-latest
|
||||
runs-on: windows-2019
|
||||
strategy:
|
||||
matrix:
|
||||
buildtype: [Debug, RelWithDebInfo, MinSizeRel]
|
||||
|
||||
@@ -43,9 +43,10 @@ Or, do steps 1 and 2, and open CMakeLists.txt in Visual Studio if `vcpkg integra
|
||||
#### Mac:
|
||||
1. `git clone https://github.com/uowuo/abaddon && cd abaddon`
|
||||
2. `brew install gtkmm3 nlohmann-json`
|
||||
3. `mkdir build && cd build`
|
||||
4. `cmake ..`
|
||||
5. `make`
|
||||
3. `git submodule update --init subprojects`
|
||||
4. `mkdir build && cd build`
|
||||
5. `cmake ..`
|
||||
6. `make`
|
||||
|
||||
#### Linux:
|
||||
1. Install dependencies: `libgtkmm-3.0-dev`, `libcurl4-gnutls-dev`, and [nlohmann-json](https://github.com/nlohmann/json)
|
||||
@@ -213,6 +214,7 @@ For example, memory_db would be set by adding `memory_db = true` under the line
|
||||
* channelcolor (string) - color to use for SFW channels in the channel list
|
||||
* mentionbadgecolor (string) - background color for mention badges
|
||||
* mentionbadgetextcolor (string) - color to use for number displayed on mention badges
|
||||
* unreadcolor (string) - color to use for the unread indicator
|
||||
|
||||
### Environment variables
|
||||
|
||||
|
||||
@@ -752,7 +752,13 @@ void ChannelList::AddPrivateChannels() {
|
||||
else if (dm->Type == ChannelType::GROUP_DM)
|
||||
row[m_columns.m_name] = std::to_string(recipients.size()) + " members";
|
||||
|
||||
if (top_recipient.has_value()) {
|
||||
if (dm->HasIcon()) {
|
||||
const auto cb = [this, iter](const Glib::RefPtr<Gdk::Pixbuf> &pb) {
|
||||
if (iter)
|
||||
(*iter)[m_columns.m_icon] = pb->scale_simple(DMIconSize, DMIconSize, Gdk::INTERP_BILINEAR);
|
||||
};
|
||||
img.LoadFromURL(dm->GetIconURL(), sigc::track_obj(cb, *this));
|
||||
} else if (top_recipient.has_value()) {
|
||||
const auto cb = [this, iter](const Glib::RefPtr<Gdk::Pixbuf> &pb) {
|
||||
if (iter)
|
||||
(*iter)[m_columns.m_icon] = pb->scale_simple(DMIconSize, DMIconSize, Gdk::INTERP_BILINEAR);
|
||||
|
||||
@@ -258,7 +258,8 @@ void CellRendererChannels::render_vfunc_guild(const Cairo::RefPtr<Cairo::Context
|
||||
const auto has_unread = discord.GetUnreadStateForGuild(id, total_mentions);
|
||||
|
||||
if (has_unread && !discord.IsGuildMuted(id)) {
|
||||
cr->set_source_rgb(1.0, 1.0, 1.0);
|
||||
static const auto color = Gdk::RGBA(Abaddon::Get().GetSettings().UnreadIndicatorColor);
|
||||
cr->set_source_rgb(color.get_red(), color.get_green(), color.get_blue());
|
||||
const auto x = background_area.get_x();
|
||||
const auto y = background_area.get_y();
|
||||
const auto w = background_area.get_width();
|
||||
@@ -403,7 +404,8 @@ void CellRendererChannels::render_vfunc_channel(const Cairo::RefPtr<Cairo::Conte
|
||||
if (unread_state < 0) return;
|
||||
|
||||
if (!is_muted) {
|
||||
cr->set_source_rgb(1.0, 1.0, 1.0);
|
||||
static const auto color = Gdk::RGBA(Abaddon::Get().GetSettings().UnreadIndicatorColor);
|
||||
cr->set_source_rgb(color.get_red(), color.get_green(), color.get_blue());
|
||||
const auto x = background_area.get_x();
|
||||
const auto y = background_area.get_y();
|
||||
const auto w = background_area.get_width();
|
||||
@@ -474,7 +476,8 @@ void CellRendererChannels::render_vfunc_thread(const Cairo::RefPtr<Cairo::Contex
|
||||
if (unread_state < 0) return;
|
||||
|
||||
if (!is_muted) {
|
||||
cr->set_source_rgb(1.0, 1.0, 1.0);
|
||||
static const auto color = Gdk::RGBA(Abaddon::Get().GetSettings().UnreadIndicatorColor);
|
||||
cr->set_source_rgb(color.get_red(), color.get_green(), color.get_blue());
|
||||
const auto x = background_area.get_x();
|
||||
const auto y = background_area.get_y();
|
||||
const auto w = background_area.get_width();
|
||||
@@ -614,7 +617,8 @@ void CellRendererChannels::render_vfunc_dm(const Cairo::RefPtr<Cairo::Context> &
|
||||
if (unread_state < 0) return;
|
||||
|
||||
if (!is_muted) {
|
||||
cr->set_source_rgb(1.0, 1.0, 1.0);
|
||||
static const auto color = Gdk::RGBA(Abaddon::Get().GetSettings().UnreadIndicatorColor);
|
||||
cr->set_source_rgb(color.get_red(), color.get_green(), color.get_blue());
|
||||
const auto x = background_area.get_x();
|
||||
const auto y = background_area.get_y();
|
||||
const auto w = background_area.get_width();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "chatmessage.hpp"
|
||||
#include "abaddon.hpp"
|
||||
#include "util.hpp"
|
||||
#include "chatmessage.hpp"
|
||||
#include "lazyimage.hpp"
|
||||
#include "util.hpp"
|
||||
#include <unordered_map>
|
||||
|
||||
constexpr static int EmojiSize = 24; // settings eventually
|
||||
@@ -44,18 +44,9 @@ ChatMessageItemContainer *ChatMessageItemContainer::FromMessage(const Message &d
|
||||
}
|
||||
}
|
||||
|
||||
// there should only ever be 1 embed (i think?)
|
||||
if (data.Embeds.size() == 1) {
|
||||
const auto &embed = data.Embeds[0];
|
||||
if (IsEmbedImageOnly(embed)) {
|
||||
auto *widget = container->CreateImageComponent(*embed.Thumbnail->ProxyURL, *embed.Thumbnail->URL, *embed.Thumbnail->Width, *embed.Thumbnail->Height);
|
||||
container->AttachEventHandlers(*widget);
|
||||
container->m_main.add(*widget);
|
||||
} else {
|
||||
container->m_embed_component = container->CreateEmbedComponent(embed);
|
||||
container->AttachEventHandlers(*container->m_embed_component);
|
||||
container->m_main.add(*container->m_embed_component);
|
||||
}
|
||||
if (!data.Embeds.empty()) {
|
||||
container->m_embed_component = container->CreateEmbedsComponent(data.Embeds);
|
||||
container->m_main.add(*container->m_embed_component);
|
||||
}
|
||||
|
||||
// i dont think attachments can be edited
|
||||
@@ -108,10 +99,11 @@ void ChatMessageItemContainer::UpdateContent() {
|
||||
m_embed_component = nullptr;
|
||||
}
|
||||
|
||||
if (data->Embeds.size() == 1) {
|
||||
m_embed_component = CreateEmbedComponent(data->Embeds[0]);
|
||||
if (!data->Embeds.empty()) {
|
||||
m_embed_component = CreateEmbedsComponent(data->Embeds);
|
||||
AttachEventHandlers(*m_embed_component);
|
||||
m_main.add(*m_embed_component);
|
||||
m_embed_component->show_all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +191,7 @@ void ChatMessageItemContainer::UpdateTextComponent(Gtk::TextView *tv) {
|
||||
case MessageType::DEFAULT:
|
||||
case MessageType::INLINE_REPLY:
|
||||
b->insert(s, data->Content);
|
||||
HandleRoleMentions(b);
|
||||
HandleUserMentions(b);
|
||||
HandleLinks(*tv);
|
||||
HandleChannelMentions(tv);
|
||||
@@ -298,6 +291,24 @@ void ChatMessageItemContainer::UpdateTextComponent(Gtk::TextView *tv) {
|
||||
}
|
||||
}
|
||||
|
||||
Gtk::Widget *ChatMessageItemContainer::CreateEmbedsComponent(const std::vector<EmbedData> &embeds) {
|
||||
auto *box = Gtk::manage(new Gtk::Box(Gtk::ORIENTATION_VERTICAL));
|
||||
for (const auto &embed : embeds) {
|
||||
if (IsEmbedImageOnly(embed)) {
|
||||
auto *widget = CreateImageComponent(*embed.Thumbnail->ProxyURL, *embed.Thumbnail->URL, *embed.Thumbnail->Width, *embed.Thumbnail->Height);
|
||||
widget->show();
|
||||
AttachEventHandlers(*widget);
|
||||
box->add(*widget);
|
||||
} else {
|
||||
auto *widget = CreateEmbedComponent(embed);
|
||||
widget->show();
|
||||
AttachEventHandlers(*widget);
|
||||
box->add(*widget);
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
Gtk::Widget *ChatMessageItemContainer::CreateEmbedComponent(const EmbedData &embed) {
|
||||
Gtk::EventBox *ev = Gtk::manage(new Gtk::EventBox);
|
||||
ev->set_can_focus(true);
|
||||
@@ -732,7 +743,47 @@ bool ChatMessageItemContainer::IsEmbedImageOnly(const EmbedData &data) {
|
||||
return data.Thumbnail->ProxyURL.has_value() && data.Thumbnail->URL.has_value() && data.Thumbnail->Width.has_value() && data.Thumbnail->Height.has_value();
|
||||
}
|
||||
|
||||
void ChatMessageItemContainer::HandleUserMentions(Glib::RefPtr<Gtk::TextBuffer> buf) {
|
||||
void ChatMessageItemContainer::HandleRoleMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf) {
|
||||
constexpr static const auto mentions_regex = R"(<@&(\d+)>)";
|
||||
|
||||
static auto rgx = Glib::Regex::create(mentions_regex);
|
||||
|
||||
Glib::ustring text = GetText(buf);
|
||||
const auto &discord = Abaddon::Get().GetDiscordClient();
|
||||
|
||||
int startpos = 0;
|
||||
Glib::MatchInfo match;
|
||||
while (rgx->match(text, startpos, match)) {
|
||||
int mstart, mend;
|
||||
if (!match.fetch_pos(0, mstart, mend)) break;
|
||||
const Glib::ustring role_id = match.fetch(1);
|
||||
const auto role = discord.GetRole(role_id);
|
||||
if (!role.has_value()) {
|
||||
startpos = mend;
|
||||
continue;
|
||||
}
|
||||
|
||||
Glib::ustring replacement;
|
||||
if (role->HasColor()) {
|
||||
replacement = "<b><span color=\"#" + IntToCSSColor(role->Color) + "\">@" + role->GetEscapedName() + "</span></b>";
|
||||
} else {
|
||||
replacement = "<b>@" + role->GetEscapedName() + "</b>";
|
||||
}
|
||||
|
||||
const auto chars_start = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mstart);
|
||||
const auto chars_end = g_utf8_pointer_to_offset(text.c_str(), text.c_str() + mend);
|
||||
const auto start_it = buf->get_iter_at_offset(chars_start);
|
||||
const auto end_it = buf->get_iter_at_offset(chars_end);
|
||||
|
||||
auto it = buf->erase(start_it, end_it);
|
||||
buf->insert_markup(it, replacement);
|
||||
|
||||
text = GetText(buf);
|
||||
startpos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void ChatMessageItemContainer::HandleUserMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf) {
|
||||
constexpr static const auto mentions_regex = R"(<@!?(\d+)>)";
|
||||
|
||||
static auto rgx = Glib::Regex::create(mentions_regex);
|
||||
@@ -1064,11 +1115,11 @@ ChatMessageHeader::ChatMessageHeader(const Message &data)
|
||||
};
|
||||
img.LoadFromURL(author->GetAvatarURL(data.GuildID), sigc::track_obj(cb, *this));
|
||||
|
||||
if (author->HasAnimatedAvatar()) {
|
||||
if (author->HasAnimatedAvatar(data.GuildID)) {
|
||||
auto cb = [this](const Glib::RefPtr<Gdk::PixbufAnimation> &pb) {
|
||||
m_anim_avatar = pb;
|
||||
};
|
||||
img.LoadAnimationFromURL(author->GetAvatarURL("gif"), AvatarSize, AvatarSize, sigc::track_obj(cb, *this));
|
||||
img.LoadAnimationFromURL(author->GetAvatarURL(data.GuildID, "gif"), AvatarSize, AvatarSize, sigc::track_obj(cb, *this));
|
||||
}
|
||||
|
||||
get_style_context()->add_class("message-container");
|
||||
|
||||
@@ -22,6 +22,7 @@ protected:
|
||||
void AddClickHandler(Gtk::Widget *widget, std::string);
|
||||
Gtk::TextView *CreateTextComponent(const Message &data); // Message.Content
|
||||
void UpdateTextComponent(Gtk::TextView *tv);
|
||||
Gtk::Widget *CreateEmbedsComponent(const std::vector<EmbedData> &embeds);
|
||||
Gtk::Widget *CreateEmbedComponent(const EmbedData &data); // Message.Embeds[0]
|
||||
Gtk::Widget *CreateImageComponent(const std::string &proxy_url, const std::string &url, int inw, int inh);
|
||||
Gtk::Widget *CreateAttachmentComponent(const AttachmentData &data); // non-image attachments
|
||||
@@ -34,7 +35,8 @@ protected:
|
||||
|
||||
static bool IsEmbedImageOnly(const EmbedData &data);
|
||||
|
||||
void HandleUserMentions(Glib::RefPtr<Gtk::TextBuffer> buf);
|
||||
void HandleRoleMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf);
|
||||
void HandleUserMentions(const Glib::RefPtr<Gtk::TextBuffer> &buf);
|
||||
void HandleStockEmojis(Gtk::TextView &tv);
|
||||
void HandleCustomEmojis(Gtk::TextView &tv);
|
||||
void HandleEmojis(Gtk::TextView &tv);
|
||||
|
||||
@@ -84,6 +84,14 @@ bool ChannelData::IsCategory() const noexcept {
|
||||
return Type == ChannelType::GUILD_CATEGORY;
|
||||
}
|
||||
|
||||
bool ChannelData::HasIcon() const noexcept {
|
||||
return Icon.has_value();
|
||||
}
|
||||
|
||||
std::string ChannelData::GetIconURL() const {
|
||||
return "https://cdn.discordapp.com/channel-icons/" + std::to_string(ID) + "/" + *Icon + ".png";
|
||||
}
|
||||
|
||||
std::vector<Snowflake> ChannelData::GetChildIDs() const {
|
||||
return Abaddon::Get().GetDiscordClient().GetChildChannelIDs(ID);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ struct ChannelData {
|
||||
bool IsThread() const noexcept;
|
||||
bool IsJoinedThread() const;
|
||||
bool IsCategory() const noexcept;
|
||||
bool HasIcon() const noexcept;
|
||||
std::string GetIconURL() const;
|
||||
std::vector<Snowflake> GetChildIDs() const;
|
||||
std::optional<PermissionOverwrite> GetOverwrite(Snowflake id) const;
|
||||
std::vector<UserData> GetDMRecipients() const;
|
||||
|
||||
@@ -154,7 +154,7 @@ void to_json(nlohmann::json &j, const UserGuildSettingsChannelOverride &m) {
|
||||
|
||||
void from_json(const nlohmann::json &j, MuteConfigData &m) {
|
||||
JS_ON("end_time", m.EndTime);
|
||||
JS_D("selected_time_window", m.SelectedTimeWindow);
|
||||
JS_ON("selected_time_window", m.SelectedTimeWindow);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json &j, const MuteConfigData &m) {
|
||||
|
||||
@@ -12,3 +12,11 @@ void from_json(const nlohmann::json &j, RoleData &m) {
|
||||
JS_D("managed", m.IsManaged);
|
||||
JS_D("mentionable", m.IsMentionable);
|
||||
}
|
||||
|
||||
bool RoleData::HasColor() const noexcept {
|
||||
return Color != 0;
|
||||
}
|
||||
|
||||
Glib::ustring RoleData::GetEscapedName() const {
|
||||
return Glib::Markup::escape_text(Name);
|
||||
}
|
||||
|
||||
@@ -16,5 +16,8 @@ struct RoleData {
|
||||
bool IsManaged;
|
||||
bool IsMentionable;
|
||||
|
||||
bool HasColor() const noexcept;
|
||||
Glib::ustring GetEscapedName() const;
|
||||
|
||||
friend void from_json(const nlohmann::json &j, RoleData &m);
|
||||
};
|
||||
|
||||
@@ -13,18 +13,6 @@ Store::Store(bool mem_store)
|
||||
return;
|
||||
}
|
||||
|
||||
m_db.Execute(R"(
|
||||
PRAGMA writable_schema = 1;
|
||||
DELETE FROM sqlite_master WHERE TYPE IN ("view", "table", "index", "trigger");
|
||||
PRAGMA writable_schema = 0;
|
||||
VACUUM;
|
||||
PRAGMA integrity_check;
|
||||
)");
|
||||
if (!m_db.OK()) {
|
||||
fprintf(stderr, "failed to clear database: %s\n", m_db.ErrStr());
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_db.Execute("PRAGMA journal_mode = WAL") != SQLITE_OK) {
|
||||
fprintf(stderr, "enabling write-ahead-log failed: %s\n", m_db.ErrStr());
|
||||
return;
|
||||
@@ -646,6 +634,7 @@ std::optional<ChannelData> Store::GetChannel(Snowflake id) const {
|
||||
s->Get(6, r.IsNSFW);
|
||||
s->Get(7, r.LastMessageID);
|
||||
s->Get(10, r.RateLimitPerUser);
|
||||
s->Get(11, r.Icon);
|
||||
s->Get(12, r.OwnerID);
|
||||
s->Get(14, r.ParentID);
|
||||
if (!s->IsNull(16)) {
|
||||
@@ -2149,6 +2138,13 @@ bool Store::CreateStatements() {
|
||||
}
|
||||
|
||||
Store::Database::Database(const char *path) {
|
||||
if (path != ":memory:"s) {
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(path, ec) && !std::filesystem::remove(path, ec)) {
|
||||
fprintf(stderr, "the database could not be removed. the database may be corrupted as a result\n");
|
||||
}
|
||||
}
|
||||
|
||||
m_err = sqlite3_open(path, &m_db);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,22 +6,41 @@ bool UserData::IsDeleted() const {
|
||||
}
|
||||
|
||||
bool UserData::HasAvatar() const {
|
||||
return Avatar.size() > 0;
|
||||
return !Avatar.empty();
|
||||
}
|
||||
|
||||
bool UserData::HasAnimatedAvatar() const {
|
||||
return Avatar.size() > 0 && Avatar[0] == 'a' && Avatar[1] == '_';
|
||||
bool UserData::HasAnimatedAvatar() const noexcept {
|
||||
return !Avatar.empty() && Avatar[0] == 'a' && Avatar[1] == '_';
|
||||
}
|
||||
|
||||
bool UserData::HasAnimatedAvatar(Snowflake guild_id) const {
|
||||
const auto member = Abaddon::Get().GetDiscordClient().GetMember(ID, guild_id);
|
||||
if (member.has_value() && member->Avatar.has_value() && member->Avatar.value()[0] == 'a' && member->Avatar.value()[1] == '_')
|
||||
return true;
|
||||
else if (!member->Avatar.has_value())
|
||||
return HasAnimatedAvatar();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool UserData::HasAnimatedAvatar(const std::optional<Snowflake> &guild_id) const {
|
||||
if (guild_id.has_value())
|
||||
return HasAnimatedAvatar(*guild_id);
|
||||
else
|
||||
return HasAnimatedAvatar();
|
||||
}
|
||||
|
||||
std::string UserData::GetAvatarURL(Snowflake guild_id, std::string ext, std::string size) const {
|
||||
const auto member = Abaddon::Get().GetDiscordClient().GetMember(ID, guild_id);
|
||||
if (member.has_value() && member->Avatar.has_value())
|
||||
if (member.has_value() && member->Avatar.has_value()) {
|
||||
if (ext == "gif" && !(member->Avatar.value()[0] == 'a' && member->Avatar.value()[1] == '_'))
|
||||
return GetAvatarURL(ext, size);
|
||||
return "https://cdn.discordapp.com/guilds/" +
|
||||
std::to_string(guild_id) + "/users/" + std::to_string(ID) +
|
||||
"/avatars/" + *member->Avatar + "." +
|
||||
ext + "?" + "size=" + size;
|
||||
else
|
||||
} else {
|
||||
return GetAvatarURL(ext, size);
|
||||
}
|
||||
}
|
||||
|
||||
std::string UserData::GetAvatarURL(const std::optional<Snowflake> &guild_id, std::string ext, std::string size) const {
|
||||
|
||||
@@ -62,7 +62,9 @@ struct UserData {
|
||||
|
||||
bool IsDeleted() const;
|
||||
bool HasAvatar() const;
|
||||
bool HasAnimatedAvatar() const;
|
||||
bool HasAnimatedAvatar() const noexcept;
|
||||
bool HasAnimatedAvatar(Snowflake guild_id) const;
|
||||
bool HasAnimatedAvatar(const std::optional<Snowflake> &guild_id) const;
|
||||
std::string GetAvatarURL(Snowflake guild_id, std::string ext = "png", std::string size = "32") const;
|
||||
std::string GetAvatarURL(const std::optional<Snowflake> &guild_id, std::string ext = "png", std::string size = "32") const;
|
||||
std::string GetAvatarURL(std::string ext = "png", std::string size = "32") const;
|
||||
|
||||
@@ -56,6 +56,7 @@ void SettingsManager::ReadSettings() {
|
||||
SMSTR("style", "channelcolor", ChannelColor);
|
||||
SMSTR("style", "mentionbadgecolor", MentionBadgeColor);
|
||||
SMSTR("style", "mentionbadgetextcolor", MentionBadgeTextColor);
|
||||
SMSTR("style", "unreadcolor", UnreadIndicatorColor);
|
||||
|
||||
#undef SMBOOL
|
||||
#undef SMSTR
|
||||
@@ -108,6 +109,7 @@ void SettingsManager::Close() {
|
||||
SMSTR("style", "channelcolor", ChannelColor);
|
||||
SMSTR("style", "mentionbadgecolor", MentionBadgeColor);
|
||||
SMSTR("style", "mentionbadgetextcolor", MentionBadgeTextColor);
|
||||
SMSTR("style", "unreadcolor", UnreadIndicatorColor);
|
||||
|
||||
#undef SMSTR
|
||||
#undef SMBOOL
|
||||
|
||||
@@ -40,6 +40,7 @@ public:
|
||||
std::string ChannelColor { "#fbfbfb" };
|
||||
std::string MentionBadgeColor { "#b82525" };
|
||||
std::string MentionBadgeTextColor { "#fbfbfb" };
|
||||
std::string UnreadIndicatorColor { "#ffffff" };
|
||||
};
|
||||
|
||||
SettingsManager(const std::string &filename);
|
||||
|
||||
Reference in New Issue
Block a user