mirror of
https://github.com/Chatterino/chatterino2.git
synced 2024-11-21 22:24:07 +01:00
credentials are now loaded when needed
This commit is contained in:
parent
d33a8b1b3a
commit
13d1fab303
15 changed files with 100 additions and 63 deletions
|
@ -79,6 +79,8 @@ void Application::initialize(Settings &settings, Paths &paths)
|
||||||
assert(isAppInitialized == false);
|
assert(isAppInitialized == false);
|
||||||
isAppInitialized = true;
|
isAppInitialized = true;
|
||||||
|
|
||||||
|
Irc::getInstance().load();
|
||||||
|
|
||||||
for (auto &singleton : this->singletons_)
|
for (auto &singleton : this->singletons_)
|
||||||
{
|
{
|
||||||
singleton->initialize(settings, paths);
|
singleton->initialize(settings, paths);
|
||||||
|
@ -98,7 +100,6 @@ int Application::run(QApplication &qtApp)
|
||||||
assert(isAppInitialized);
|
assert(isAppInitialized);
|
||||||
|
|
||||||
this->twitch.server->connect();
|
this->twitch.server->connect();
|
||||||
Irc::getInstance().load();
|
|
||||||
|
|
||||||
this->windows->getMainWindow().show();
|
this->windows->getMainWindow().show();
|
||||||
|
|
||||||
|
|
|
@ -86,6 +86,7 @@ Credentials::Credentials()
|
||||||
}
|
}
|
||||||
|
|
||||||
void Credentials::get(const QString &provider, const QString &name_,
|
void Credentials::get(const QString &provider, const QString &name_,
|
||||||
|
QObject *receiver,
|
||||||
std::function<void(const QString &)> &&onLoaded)
|
std::function<void(const QString &)> &&onLoaded)
|
||||||
{
|
{
|
||||||
assertInGuiThread();
|
assertInGuiThread();
|
||||||
|
@ -97,7 +98,7 @@ void Credentials::get(const QString &provider, const QString &name_,
|
||||||
auto job = new QKeychain::ReadPasswordJob("chatterino");
|
auto job = new QKeychain::ReadPasswordJob("chatterino");
|
||||||
job->setAutoDelete(true);
|
job->setAutoDelete(true);
|
||||||
job->setKey(name);
|
job->setKey(name);
|
||||||
QObject::connect(job, &QKeychain::Job::finished, qApp,
|
QObject::connect(job, &QKeychain::Job::finished, receiver,
|
||||||
[job, onLoaded = std::move(onLoaded)](auto) mutable {
|
[job, onLoaded = std::move(onLoaded)](auto) mutable {
|
||||||
onLoaded(job->textData());
|
onLoaded(job->textData());
|
||||||
},
|
},
|
||||||
|
|
|
@ -10,7 +10,7 @@ class Credentials
|
||||||
public:
|
public:
|
||||||
static Credentials &getInstance();
|
static Credentials &getInstance();
|
||||||
|
|
||||||
void get(const QString &provider, const QString &name,
|
void get(const QString &provider, const QString &name, QObject *receiver,
|
||||||
std::function<void(const QString &)> &&onLoaded);
|
std::function<void(const QString &)> &&onLoaded);
|
||||||
void set(const QString &provider, const QString &name,
|
void set(const QString &provider, const QString &name,
|
||||||
const QString &credential);
|
const QString &credential);
|
||||||
|
|
|
@ -78,19 +78,26 @@ void AbstractIrcServer::connect()
|
||||||
|
|
||||||
if (this->hasSeparateWriteConnection())
|
if (this->hasSeparateWriteConnection())
|
||||||
{
|
{
|
||||||
this->initializeConnection(this->writeConnection_.get(), false, true);
|
this->initializeConnection(this->writeConnection_.get(), Write);
|
||||||
this->initializeConnection(this->readConnection_.get(), true, false);
|
this->initializeConnection(this->readConnection_.get(), Read);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
this->initializeConnection(this->readConnection_.get(), true, true);
|
this->initializeConnection(this->readConnection_.get(), Both);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// fourtf: this should be asynchronous
|
void AbstractIrcServer::open(ConnectionType type)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock1(this->connectionMutex_);
|
||||||
|
std::lock_guard<std::mutex> lock2(this->channelMutex);
|
||||||
|
|
||||||
|
if (type == Write)
|
||||||
|
{
|
||||||
|
this->writeConnection_->open();
|
||||||
|
}
|
||||||
|
if (type & Read)
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock1(this->connectionMutex_);
|
|
||||||
std::lock_guard<std::mutex> lock2(this->channelMutex);
|
|
||||||
|
|
||||||
for (std::weak_ptr<Channel> &weak : this->channels.values())
|
for (std::weak_ptr<Channel> &weak : this->channels.values())
|
||||||
{
|
{
|
||||||
if (auto channel = weak.lock())
|
if (auto channel = weak.lock())
|
||||||
|
@ -98,11 +105,6 @@ void AbstractIrcServer::connect()
|
||||||
this->readConnection_->sendRaw("JOIN #" + channel->getName());
|
this->readConnection_->sendRaw("JOIN #" + channel->getName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this->hasSeparateWriteConnection())
|
|
||||||
{
|
|
||||||
this->writeConnection_->open();
|
|
||||||
}
|
|
||||||
this->readConnection_->open();
|
this->readConnection_->open();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -254,6 +256,16 @@ void AbstractIrcServer::onReadConnected(IrcConnection *connection)
|
||||||
|
|
||||||
std::lock_guard lock(this->channelMutex);
|
std::lock_guard lock(this->channelMutex);
|
||||||
|
|
||||||
|
// join channels
|
||||||
|
for (auto &&weak : this->channels)
|
||||||
|
{
|
||||||
|
if (auto channel = weak.lock())
|
||||||
|
{
|
||||||
|
connection->sendRaw("JOIN #" + channel->getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// connected/disconnected message
|
||||||
auto connectedMsg = makeSystemMessage("connected");
|
auto connectedMsg = makeSystemMessage("connected");
|
||||||
connectedMsg->flags.set(MessageFlag::ConnectedMessage);
|
connectedMsg->flags.set(MessageFlag::ConnectedMessage);
|
||||||
auto reconnected = makeSystemMessage("reconnected");
|
auto reconnected = makeSystemMessage("reconnected");
|
||||||
|
|
|
@ -17,6 +17,8 @@ using ChannelPtr = std::shared_ptr<Channel>;
|
||||||
class AbstractIrcServer : public QObject
|
class AbstractIrcServer : public QObject
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
enum ConnectionType { Read = 1, Write = 2, Both = 3 };
|
||||||
|
|
||||||
virtual ~AbstractIrcServer() = default;
|
virtual ~AbstractIrcServer() = default;
|
||||||
|
|
||||||
// connection
|
// connection
|
||||||
|
@ -43,8 +45,8 @@ public:
|
||||||
protected:
|
protected:
|
||||||
AbstractIrcServer();
|
AbstractIrcServer();
|
||||||
|
|
||||||
virtual void initializeConnection(IrcConnection *connection, bool isRead,
|
virtual void initializeConnection(IrcConnection *connection,
|
||||||
bool isWrite) = 0;
|
ConnectionType type) = 0;
|
||||||
virtual std::shared_ptr<Channel> createChannel(
|
virtual std::shared_ptr<Channel> createChannel(
|
||||||
const QString &channelName) = 0;
|
const QString &channelName) = 0;
|
||||||
|
|
||||||
|
@ -63,6 +65,8 @@ protected:
|
||||||
virtual bool hasSeparateWriteConnection() const = 0;
|
virtual bool hasSeparateWriteConnection() const = 0;
|
||||||
virtual QString cleanChannelName(const QString &dirtyChannelName);
|
virtual QString cleanChannelName(const QString &dirtyChannelName);
|
||||||
|
|
||||||
|
void open(ConnectionType type);
|
||||||
|
|
||||||
QMap<QString, std::weak_ptr<Channel>> channels;
|
QMap<QString, std::weak_ptr<Channel>> channels;
|
||||||
std::mutex channelMutex;
|
std::mutex channelMutex;
|
||||||
|
|
||||||
|
|
|
@ -38,7 +38,6 @@ namespace {
|
||||||
row[3]->data(Qt::EditRole).toString(), // user
|
row[3]->data(Qt::EditRole).toString(), // user
|
||||||
row[4]->data(Qt::EditRole).toString(), // nick
|
row[4]->data(Qt::EditRole).toString(), // nick
|
||||||
row[5]->data(Qt::EditRole).toString(), // real
|
row[5]->data(Qt::EditRole).toString(), // real
|
||||||
original.password, // password
|
|
||||||
original.connectCommands, // connectCommands
|
original.connectCommands, // connectCommands
|
||||||
original.id, // id
|
original.id, // id
|
||||||
};
|
};
|
||||||
|
@ -70,6 +69,18 @@ inline QString getCredentialName(const IrcServerData &data)
|
||||||
escape(data.host);
|
escape(data.host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void IrcServerData::getPassword(
|
||||||
|
QObject *receiver, std::function<void(const QString &)> &&onLoaded) const
|
||||||
|
{
|
||||||
|
Credentials::getInstance().get("irc", getCredentialName(*this), receiver,
|
||||||
|
std::move(onLoaded));
|
||||||
|
}
|
||||||
|
|
||||||
|
void IrcServerData::setPassword(const QString &password)
|
||||||
|
{
|
||||||
|
Credentials::getInstance().set("irc", getCredentialName(*this), password);
|
||||||
|
}
|
||||||
|
|
||||||
Irc::Irc()
|
Irc::Irc()
|
||||||
{
|
{
|
||||||
this->connections.itemInserted.connect([this](auto &&args) {
|
this->connections.itemInserted.connect([this](auto &&args) {
|
||||||
|
@ -99,10 +110,6 @@ Irc::Irc()
|
||||||
this->servers_.emplace(args.item.id,
|
this->servers_.emplace(args.item.id,
|
||||||
std::make_unique<IrcServer>(args.item));
|
std::make_unique<IrcServer>(args.item));
|
||||||
}
|
}
|
||||||
|
|
||||||
// store password
|
|
||||||
Credentials::getInstance().set("irc", getCredentialName(args.item),
|
|
||||||
args.item.password);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this->connections.itemRemoved.connect([this](auto &&args) {
|
this->connections.itemRemoved.connect([this](auto &&args) {
|
||||||
|
@ -217,6 +224,8 @@ void Irc::load()
|
||||||
file.open(QIODevice::ReadOnly);
|
file.open(QIODevice::ReadOnly);
|
||||||
auto object = QJsonDocument::fromJson(file.readAll()).object();
|
auto object = QJsonDocument::fromJson(file.readAll()).object();
|
||||||
|
|
||||||
|
std::unordered_set<int> ids;
|
||||||
|
|
||||||
// load servers
|
// load servers
|
||||||
for (auto server : object.value("servers").toArray())
|
for (auto server : object.value("servers").toArray())
|
||||||
{
|
{
|
||||||
|
@ -233,18 +242,11 @@ void Irc::load()
|
||||||
data.id = obj.value("id").toInt(data.id);
|
data.id = obj.value("id").toInt(data.id);
|
||||||
|
|
||||||
// duplicate id's are not allowed :(
|
// duplicate id's are not allowed :(
|
||||||
if (this->abandonedChannels_.find(data.id) ==
|
if (ids.find(data.id) == ids.end())
|
||||||
this->abandonedChannels_.end())
|
|
||||||
{
|
{
|
||||||
// insert element
|
ids.insert(data.id);
|
||||||
this->abandonedChannels_[data.id];
|
|
||||||
|
|
||||||
Credentials::getInstance().get(
|
this->connections.appendItem(data);
|
||||||
"irc", getCredentialName(data),
|
|
||||||
[=](const QString &password) mutable {
|
|
||||||
data.password = password;
|
|
||||||
this->connections.appendItem(data);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,10 @@ struct IrcServerData {
|
||||||
QString real;
|
QString real;
|
||||||
|
|
||||||
// IrcAuthType authType = Anonymous;
|
// IrcAuthType authType = Anonymous;
|
||||||
QString password;
|
void getPassword(QObject *receiver,
|
||||||
|
std::function<void(const QString &)> &&onLoaded) const;
|
||||||
|
void setPassword(const QString &password);
|
||||||
|
|
||||||
QStringList connectCommands;
|
QStringList connectCommands;
|
||||||
|
|
||||||
int id;
|
int id;
|
||||||
|
|
|
@ -20,6 +20,7 @@ void IrcChannel::sendMessage(const QString &message)
|
||||||
this->server()->sendMessage(this->getName(), message);
|
this->server()->sendMessage(this->getName(), message);
|
||||||
|
|
||||||
MessageBuilder builder;
|
MessageBuilder builder;
|
||||||
|
builder.emplace<TimestampElement>();
|
||||||
builder.emplace<TextElement>(this->server()->nick() + ":",
|
builder.emplace<TextElement>(this->server()->nick() + ":",
|
||||||
MessageElementFlag::Username);
|
MessageElementFlag::Username);
|
||||||
builder.emplace<TextElement>(message, MessageElementFlag::Text);
|
builder.emplace<TextElement>(message, MessageElementFlag::Text);
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
#include "messages/MessageBuilder.hpp"
|
#include "messages/MessageBuilder.hpp"
|
||||||
#include "providers/irc/Irc2.hpp"
|
#include "providers/irc/Irc2.hpp"
|
||||||
#include "providers/irc/IrcChannel2.hpp"
|
#include "providers/irc/IrcChannel2.hpp"
|
||||||
|
#include "util/QObjectRef.hpp"
|
||||||
|
|
||||||
namespace chatterino {
|
namespace chatterino {
|
||||||
|
|
||||||
|
@ -44,13 +45,13 @@ const QString &IrcServer::user()
|
||||||
|
|
||||||
const QString &IrcServer::nick()
|
const QString &IrcServer::nick()
|
||||||
{
|
{
|
||||||
return this->data_->nick;
|
return this->data_->nick.isEmpty() ? this->data_->user : this->data_->nick;
|
||||||
}
|
}
|
||||||
|
|
||||||
void IrcServer::initializeConnection(IrcConnection *connection, bool isRead,
|
void IrcServer::initializeConnection(IrcConnection *connection,
|
||||||
bool isWrite)
|
ConnectionType type)
|
||||||
{
|
{
|
||||||
assert(isRead && isWrite);
|
assert(type == Both);
|
||||||
|
|
||||||
connection->setSecure(this->data_->ssl);
|
connection->setSecure(this->data_->ssl);
|
||||||
connection->setHost(this->data_->host);
|
connection->setHost(this->data_->host);
|
||||||
|
@ -61,7 +62,18 @@ void IrcServer::initializeConnection(IrcConnection *connection, bool isRead,
|
||||||
: this->data_->nick);
|
: this->data_->nick);
|
||||||
connection->setRealName(this->data_->real.isEmpty() ? this->data_->user
|
connection->setRealName(this->data_->real.isEmpty() ? this->data_->user
|
||||||
: this->data_->nick);
|
: this->data_->nick);
|
||||||
connection->setPassword(this->data_->password);
|
|
||||||
|
this->data_->getPassword(
|
||||||
|
this, [conn = new QObjectRef(connection) /* can't copy */,
|
||||||
|
this](const QString &password) mutable {
|
||||||
|
if (*conn)
|
||||||
|
{
|
||||||
|
(*conn)->setPassword(password);
|
||||||
|
this->open(Both);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete conn;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
std::shared_ptr<Channel> IrcServer::createChannel(const QString &channelName)
|
std::shared_ptr<Channel> IrcServer::createChannel(const QString &channelName)
|
||||||
|
@ -76,22 +88,16 @@ bool IrcServer::hasSeparateWriteConnection() const
|
||||||
|
|
||||||
void IrcServer::onReadConnected(IrcConnection *connection)
|
void IrcServer::onReadConnected(IrcConnection *connection)
|
||||||
{
|
{
|
||||||
AbstractIrcServer::onReadConnected(connection);
|
|
||||||
|
|
||||||
std::lock_guard lock(this->channelMutex);
|
|
||||||
|
|
||||||
for (auto &&command : this->data_->connectCommands)
|
|
||||||
{
|
{
|
||||||
connection->sendRaw(command + "\r\n");
|
std::lock_guard lock(this->channelMutex);
|
||||||
}
|
|
||||||
|
|
||||||
for (auto &&weak : this->channels)
|
for (auto &&command : this->data_->connectCommands)
|
||||||
{
|
|
||||||
if (auto channel = weak.lock())
|
|
||||||
{
|
{
|
||||||
connection->sendRaw("JOIN #" + channel->getName());
|
connection->sendRaw(command + "\r\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AbstractIrcServer::onReadConnected(connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
void IrcServer::privateMessageReceived(Communi::IrcPrivateMessage *message)
|
void IrcServer::privateMessageReceived(Communi::IrcPrivateMessage *message)
|
||||||
|
|
|
@ -21,8 +21,8 @@ public:
|
||||||
|
|
||||||
// AbstractIrcServer interface
|
// AbstractIrcServer interface
|
||||||
protected:
|
protected:
|
||||||
void initializeConnection(IrcConnection *connection, bool isRead,
|
void initializeConnection(IrcConnection *connection,
|
||||||
bool isWrite) override;
|
ConnectionType type) override;
|
||||||
std::shared_ptr<Channel> createChannel(const QString &channelName) override;
|
std::shared_ptr<Channel> createChannel(const QString &channelName) override;
|
||||||
bool hasSeparateWriteConnection() const override;
|
bool hasSeparateWriteConnection() const override;
|
||||||
|
|
||||||
|
|
|
@ -63,11 +63,9 @@ void TwitchServer::initialize(Settings &settings, Paths &paths)
|
||||||
this->ffz.loadEmotes();
|
this->ffz.loadEmotes();
|
||||||
}
|
}
|
||||||
|
|
||||||
void TwitchServer::initializeConnection(IrcConnection *connection, bool isRead,
|
void TwitchServer::initializeConnection(IrcConnection *connection,
|
||||||
bool isWrite)
|
ConnectionType type)
|
||||||
{
|
{
|
||||||
this->singleConnection_ = isRead == isWrite;
|
|
||||||
|
|
||||||
std::shared_ptr<TwitchAccount> account =
|
std::shared_ptr<TwitchAccount> account =
|
||||||
getApp()->accounts->twitch.getCurrent();
|
getApp()->accounts->twitch.getCurrent();
|
||||||
|
|
||||||
|
@ -97,6 +95,8 @@ void TwitchServer::initializeConnection(IrcConnection *connection, bool isRead,
|
||||||
// SSL enabled: irc://irc.chat.twitch.tv:6697
|
// SSL enabled: irc://irc.chat.twitch.tv:6697
|
||||||
connection->setHost("irc.chat.twitch.tv");
|
connection->setHost("irc.chat.twitch.tv");
|
||||||
connection->setPort(6697);
|
connection->setPort(6697);
|
||||||
|
|
||||||
|
this->open(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::shared_ptr<Channel> TwitchServer::createChannel(const QString &channelName)
|
std::shared_ptr<Channel> TwitchServer::createChannel(const QString &channelName)
|
||||||
|
|
|
@ -44,8 +44,8 @@ public:
|
||||||
const FfzEmotes &getFfzEmotes() const;
|
const FfzEmotes &getFfzEmotes() const;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
virtual void initializeConnection(IrcConnection *connection, bool isRead,
|
virtual void initializeConnection(IrcConnection *connection,
|
||||||
bool isWrite) override;
|
ConnectionType type) override;
|
||||||
virtual std::shared_ptr<Channel> createChannel(
|
virtual std::shared_ptr<Channel> createChannel(
|
||||||
const QString &channelName) override;
|
const QString &channelName) override;
|
||||||
|
|
||||||
|
@ -75,7 +75,6 @@ private:
|
||||||
std::chrono::steady_clock::time_point lastErrorTimeSpeed_;
|
std::chrono::steady_clock::time_point lastErrorTimeSpeed_;
|
||||||
std::chrono::steady_clock::time_point lastErrorTimeAmount_;
|
std::chrono::steady_clock::time_point lastErrorTimeAmount_;
|
||||||
|
|
||||||
bool singleConnection_ = false;
|
|
||||||
TwitchBadges twitchBadges;
|
TwitchBadges twitchBadges;
|
||||||
BttvEmotes bttv;
|
BttvEmotes bttv;
|
||||||
FfzEmotes ffz;
|
FfzEmotes ffz;
|
||||||
|
|
|
@ -62,8 +62,9 @@ private:
|
||||||
if (other)
|
if (other)
|
||||||
{
|
{
|
||||||
this->conn_ =
|
this->conn_ =
|
||||||
QObject::connect(other, &QObject::destroyed,
|
QObject::connect(other, &QObject::destroyed, qApp,
|
||||||
[this](QObject *) { this->set(nullptr); });
|
[this](QObject *) { this->set(nullptr); },
|
||||||
|
Qt::DirectConnection);
|
||||||
}
|
}
|
||||||
|
|
||||||
this->t_ = other;
|
this->t_ = other;
|
||||||
|
|
|
@ -28,7 +28,10 @@ IrcConnectionEditor::IrcConnectionEditor(const IrcServerData &data, bool isAdd,
|
||||||
this->ui_->realNameLineEdit->setText(data.real);
|
this->ui_->realNameLineEdit->setText(data.real);
|
||||||
this->ui_->connectCommandsEditor->setPlainText(
|
this->ui_->connectCommandsEditor->setPlainText(
|
||||||
data.connectCommands.join('\n'));
|
data.connectCommands.join('\n'));
|
||||||
this->ui_->passwordLineEdit->setText(data.password);
|
|
||||||
|
data.getPassword(this, [this](const QString &password) {
|
||||||
|
this->ui_->passwordLineEdit->setText(password);
|
||||||
|
});
|
||||||
|
|
||||||
QFont font("Monospace");
|
QFont font("Monospace");
|
||||||
font.setStyleHint(QFont::TypeWriter);
|
font.setStyleHint(QFont::TypeWriter);
|
||||||
|
@ -51,7 +54,7 @@ IrcServerData IrcConnectionEditor::data()
|
||||||
data.real = this->ui_->realNameLineEdit->text();
|
data.real = this->ui_->realNameLineEdit->text();
|
||||||
data.connectCommands =
|
data.connectCommands =
|
||||||
this->ui_->connectCommandsEditor->toPlainText().split('\n');
|
this->ui_->connectCommandsEditor->toPlainText().split('\n');
|
||||||
data.password = this->ui_->passwordLineEdit->text();
|
data.setPassword(this->ui_->passwordLineEdit->text());
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -158,7 +158,11 @@
|
||||||
<item row="10" column="0">
|
<item row="10" column="0">
|
||||||
<widget class="QLabel" name="connectCommandsLabel">
|
<widget class="QLabel" name="connectCommandsLabel">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string><html><head/><body><p>Send IRC commands</p><p>on connect:</p></body></html></string>
|
<string>Send IRC commands
|
||||||
|
on connect:</string>
|
||||||
|
</property>
|
||||||
|
<property name="textFormat">
|
||||||
|
<enum>Qt::PlainText</enum>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
|
Loading…
Reference in a new issue