parent
12db51fe75
commit
03a868a6e3
11 changed files with 150 additions and 22 deletions
|
@ -2474,6 +2474,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|||
"lng_export_state_chats_list" = "Processing chats...";
|
||||
"lng_export_state_chats" = "Chats";
|
||||
"lng_export_state_ready_progress" = "{ready} / {total}";
|
||||
"lng_export_skip_file" = "Skip this file";
|
||||
"lng_export_progress" = "You can close this window now. Please don't quit Telegram until the data export is completed.";
|
||||
"lng_export_stop" = "Stop";
|
||||
"lng_export_sure_stop" = "Are you sure you want to stop exporting your data?\n\nIf you do, you'll need to start over.";
|
||||
|
|
|
@ -14,6 +14,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|||
#include "mtproto/mtproto_response.h"
|
||||
#include "base/value_ordering.h"
|
||||
#include "base/bytes.h"
|
||||
#include "base/openssl_help.h"
|
||||
#include <set>
|
||||
#include <deque>
|
||||
|
||||
|
@ -182,6 +183,7 @@ struct ApiWrap::FileProcess {
|
|||
Fn<bool(FileProgress)> progress;
|
||||
FnMut<void(const QString &relativePath)> done;
|
||||
|
||||
uint64 randomId = 0;
|
||||
Data::FileLocation location;
|
||||
Data::FileOrigin origin;
|
||||
int offset = 0;
|
||||
|
@ -192,6 +194,7 @@ struct ApiWrap::FileProcess {
|
|||
QByteArray bytes;
|
||||
};
|
||||
std::deque<Request> requests;
|
||||
mtpRequestId requestId = 0;
|
||||
};
|
||||
|
||||
struct ApiWrap::FileProgress {
|
||||
|
@ -383,6 +386,7 @@ auto ApiWrap::fileRequest(const Data::FileLocation &location, int offset) {
|
|||
Expects(location.dcId != 0
|
||||
|| location.data.type() == mtpc_inputTakeoutFileLocation);
|
||||
Expects(_takeoutId.has_value());
|
||||
Expects(_fileProcess->requestId == 0);
|
||||
|
||||
return std::move(_mtp.request(MTPInvokeWithTakeout<MTPupload_GetFile>(
|
||||
MTP_long(*_takeoutId),
|
||||
|
@ -392,6 +396,7 @@ auto ApiWrap::fileRequest(const Data::FileLocation &location, int offset) {
|
|||
MTP_int(offset),
|
||||
MTP_int(kFileChunkSize))
|
||||
)).fail([=](const MTP::Error &result) {
|
||||
_fileProcess->requestId = 0;
|
||||
if (result.type() == qstr("TAKEOUT_FILE_EMPTY")
|
||||
&& _otherDataProcess != nullptr) {
|
||||
filePartDone(
|
||||
|
@ -853,6 +858,7 @@ bool ApiWrap::loadUserpicProgress(FileProgress progress) {
|
|||
< _userpicsProcess->slice->list.size()));
|
||||
|
||||
return _userpicsProcess->fileProgress(DownloadProgress{
|
||||
_fileProcess->randomId,
|
||||
_fileProcess->relativePath,
|
||||
_userpicsProcess->fileIndex,
|
||||
progress.ready,
|
||||
|
@ -1061,6 +1067,17 @@ void ApiWrap::finishExport(FnMut<void()> done) {
|
|||
)).done(std::move(done)).send();
|
||||
}
|
||||
|
||||
void ApiWrap::skipFile(uint64 randomId) {
|
||||
if (!_fileProcess || _fileProcess->randomId != randomId) {
|
||||
return;
|
||||
}
|
||||
LOG(("Export Info: File skipped."));
|
||||
Assert(!_fileProcess->requests.empty());
|
||||
Assert(_fileProcess->requestId != 0);
|
||||
_mtp.request(base::take(_fileProcess->requestId)).cancel();
|
||||
base::take(_fileProcess)->done(QString());
|
||||
}
|
||||
|
||||
void ApiWrap::cancelExportFast() {
|
||||
if (_takeoutId.has_value()) {
|
||||
const auto requestId = mainRequest(MTPaccount_FinishTakeoutSession(
|
||||
|
@ -1591,10 +1608,11 @@ bool ApiWrap::loadMessageFileProgress(FileProgress progress) {
|
|||
&& (_chatProcess->fileIndex < _chatProcess->slice->list.size()));
|
||||
|
||||
return _chatProcess->fileProgress(DownloadProgress{
|
||||
_fileProcess->relativePath,
|
||||
_chatProcess->fileIndex,
|
||||
progress.ready,
|
||||
progress.total });
|
||||
.randomId = _fileProcess->randomId,
|
||||
.path = _fileProcess->relativePath,
|
||||
.itemIndex = _chatProcess->fileIndex,
|
||||
.ready = progress.ready,
|
||||
.total = progress.total });
|
||||
}
|
||||
|
||||
void ApiWrap::loadMessageFileDone(const QString &relativePath) {
|
||||
|
@ -1740,6 +1758,8 @@ void ApiWrap::loadFile(
|
|||
}
|
||||
|
||||
loadFilePart();
|
||||
|
||||
Ensures(_fileProcess->requestId != 0);
|
||||
}
|
||||
|
||||
auto ApiWrap::prepareFileProcess(
|
||||
|
@ -1758,11 +1778,13 @@ auto ApiWrap::prepareFileProcess(
|
|||
result->location = file.location;
|
||||
result->size = file.size;
|
||||
result->origin = origin;
|
||||
result->randomId = openssl::RandomValue<uint64>();
|
||||
return result;
|
||||
}
|
||||
|
||||
void ApiWrap::loadFilePart() {
|
||||
if (!_fileProcess
|
||||
|| _fileProcess->requestId
|
||||
|| _fileProcess->requests.size() >= kFileRequestsCount
|
||||
|| (_fileProcess->size > 0
|
||||
&& _fileProcess->offset >= _fileProcess->size)) {
|
||||
|
@ -1771,16 +1793,18 @@ void ApiWrap::loadFilePart() {
|
|||
|
||||
const auto offset = _fileProcess->offset;
|
||||
_fileProcess->requests.push_back({ offset });
|
||||
fileRequest(
|
||||
_fileProcess->requestId = fileRequest(
|
||||
_fileProcess->location,
|
||||
_fileProcess->offset
|
||||
).done([=](const MTPupload_File &result) {
|
||||
_fileProcess->requestId = 0;
|
||||
filePartDone(offset, result);
|
||||
}).send();
|
||||
_fileProcess->offset += kFileChunkSize;
|
||||
|
||||
if (_fileProcess->size > 0
|
||||
&& _fileProcess->requests.size() < kFileRequestsCount) {
|
||||
// Only one request at a time supported right now.
|
||||
//const auto runner = _runner;
|
||||
//crl::on_main([=] {
|
||||
// QTimer::singleShot(kFileNextRequestDelay, [=] {
|
||||
|
@ -1854,6 +1878,7 @@ void ApiWrap::filePartDone(int offset, const MTPupload_File &result) {
|
|||
|
||||
void ApiWrap::filePartRefreshReference(int offset) {
|
||||
Expects(_fileProcess != nullptr);
|
||||
Expects(_fileProcess->requestId == 0);
|
||||
|
||||
const auto &origin = _fileProcess->origin;
|
||||
if (!origin.messageId) {
|
||||
|
@ -1870,26 +1895,33 @@ void ApiWrap::filePartRefreshReference(int offset) {
|
|||
origin.peer.c_inputPeerChannelFromMessage().vpeer(),
|
||||
origin.peer.c_inputPeerChannelFromMessage().vmsg_id(),
|
||||
origin.peer.c_inputPeerChannelFromMessage().vchannel_id());
|
||||
mainRequest(MTPchannels_GetMessages(
|
||||
_fileProcess->requestId = mainRequest(MTPchannels_GetMessages(
|
||||
channel,
|
||||
MTP_vector<MTPInputMessage>(
|
||||
1,
|
||||
MTP_inputMessageID(MTP_int(origin.messageId)))
|
||||
)).fail([=](const MTP::Error &error) {
|
||||
_fileProcess->requestId = 0;
|
||||
filePartUnavailable();
|
||||
return true;
|
||||
}).done([=](const MTPmessages_Messages &result) {
|
||||
_fileProcess->requestId = 0;
|
||||
filePartExtractReference(offset, result);
|
||||
}).send();
|
||||
} else {
|
||||
splitRequest(origin.split, MTPmessages_GetMessages(
|
||||
MTP_vector<MTPInputMessage>(
|
||||
1,
|
||||
MTP_inputMessageID(MTP_int(origin.messageId)))
|
||||
)).fail([=](const MTP::Error &error) {
|
||||
_fileProcess->requestId = splitRequest(
|
||||
origin.split,
|
||||
MTPmessages_GetMessages(
|
||||
MTP_vector<MTPInputMessage>(
|
||||
1,
|
||||
MTP_inputMessageID(MTP_int(origin.messageId)))
|
||||
)
|
||||
).fail([=](const MTP::Error &error) {
|
||||
_fileProcess->requestId = 0;
|
||||
filePartUnavailable();
|
||||
return true;
|
||||
}).done([=](const MTPmessages_Messages &result) {
|
||||
_fileProcess->requestId = 0;
|
||||
filePartExtractReference(offset, result);
|
||||
}).send();
|
||||
}
|
||||
|
@ -1899,6 +1931,7 @@ void ApiWrap::filePartExtractReference(
|
|||
int offset,
|
||||
const MTPmessages_Messages &result) {
|
||||
Expects(_fileProcess != nullptr);
|
||||
Expects(_fileProcess->requestId == 0);
|
||||
|
||||
result.match([&](const MTPDmessages_messagesNotModified &data) {
|
||||
error("Unexpected messagesNotModified received.");
|
||||
|
@ -1922,10 +1955,11 @@ void ApiWrap::filePartExtractReference(
|
|||
_fileProcess->location,
|
||||
message.thumb().file.location);
|
||||
if (refresh1 || refresh2) {
|
||||
fileRequest(
|
||||
_fileProcess->requestId = fileRequest(
|
||||
_fileProcess->location,
|
||||
offset
|
||||
).done([=](const MTPupload_File &result) {
|
||||
_fileProcess->requestId = 0;
|
||||
filePartDone(offset, result);
|
||||
}).send();
|
||||
return;
|
||||
|
|
|
@ -60,6 +60,7 @@ public:
|
|||
FnMut<void(Data::File&&)> done);
|
||||
|
||||
struct DownloadProgress {
|
||||
uint64 randomId = 0;
|
||||
QString path;
|
||||
int itemIndex = 0;
|
||||
int ready = 0;
|
||||
|
@ -83,6 +84,7 @@ public:
|
|||
FnMut<void()> done);
|
||||
|
||||
void finishExport(FnMut<void()> done);
|
||||
void skipFile(uint64 randomId);
|
||||
void cancelExportFast();
|
||||
|
||||
~ApiWrap();
|
||||
|
|
|
@ -51,12 +51,14 @@ public:
|
|||
void startExport(
|
||||
const Settings &settings,
|
||||
const Environment &environment);
|
||||
void skipFile(uint64 randomId);
|
||||
void cancelExportFast();
|
||||
|
||||
private:
|
||||
using Step = ProcessingState::Step;
|
||||
using DownloadProgress = ApiWrap::DownloadProgress;
|
||||
|
||||
[[nodiscard]] bool stopped() const;
|
||||
void setState(State &&state);
|
||||
void ioError(const QString &path);
|
||||
bool ioCatchError(Output::Result result);
|
||||
|
@ -166,8 +168,15 @@ rpl::producer<State> ControllerObject::state() const {
|
|||
});
|
||||
}
|
||||
|
||||
bool ControllerObject::stopped() const {
|
||||
return v::is<CancelledState>(_state)
|
||||
|| v::is<ApiErrorState>(_state)
|
||||
|| v::is<OutputErrorState>(_state)
|
||||
|| v::is<FinishedState>(_state);
|
||||
}
|
||||
|
||||
void ControllerObject::setState(State &&state) {
|
||||
if (v::is<CancelledState>(_state)) {
|
||||
if (stopped()) {
|
||||
return;
|
||||
}
|
||||
_state = std::move(state);
|
||||
|
@ -245,6 +254,13 @@ void ControllerObject::startExport(
|
|||
exportNext();
|
||||
}
|
||||
|
||||
void ControllerObject::skipFile(uint64 randomId) {
|
||||
if (stopped()) {
|
||||
return;
|
||||
}
|
||||
_api.skipFile(randomId);
|
||||
}
|
||||
|
||||
void ControllerObject::fillExportSteps() {
|
||||
using Type = Settings::Type;
|
||||
_steps.push_back(Step::Initializing);
|
||||
|
@ -518,6 +534,7 @@ ProcessingState ControllerObject::stateUserpics(
|
|||
result.entityIndex = _userpicsWritten + progress.itemIndex;
|
||||
result.entityCount = std::max(_userpicsCount, result.entityIndex);
|
||||
result.bytesType = ProcessingState::FileType::Photo;
|
||||
result.bytesRandomId = progress.randomId;
|
||||
if (!progress.path.isEmpty()) {
|
||||
const auto last = progress.path.lastIndexOf('/');
|
||||
result.bytesName = progress.path.mid(last + 1);
|
||||
|
@ -570,6 +587,7 @@ void ControllerObject::fillMessagesState(
|
|||
result.itemIndex = _messagesWritten + progress.itemIndex;
|
||||
result.itemCount = std::max(_messagesCount, result.itemIndex);
|
||||
result.bytesType = ProcessingState::FileType::File; // TODO
|
||||
result.bytesRandomId = progress.randomId;
|
||||
if (!progress.path.isEmpty()) {
|
||||
const auto last = progress.path.lastIndexOf('/');
|
||||
result.bytesName = progress.path.mid(last + 1);
|
||||
|
@ -643,6 +661,12 @@ void Controller::startExport(
|
|||
});
|
||||
}
|
||||
|
||||
void Controller::skipFile(uint64 randomId) {
|
||||
_wrapped.with([=](Implementation &unwrapped) {
|
||||
unwrapped.skipFile(randomId);
|
||||
});
|
||||
}
|
||||
|
||||
void Controller::cancelExportFast() {
|
||||
LOG(("Export Info: Cancelled export."));
|
||||
|
||||
|
|
|
@ -74,6 +74,7 @@ struct ProcessingState {
|
|||
int itemIndex = 0;
|
||||
int itemCount = 0;
|
||||
|
||||
uint64 bytesRandomId = 0;
|
||||
FileType bytesType = FileType::None;
|
||||
QString bytesName;
|
||||
int bytesLoaded = 0;
|
||||
|
@ -136,6 +137,7 @@ public:
|
|||
void startExport(
|
||||
const Settings &settings,
|
||||
const Environment &environment);
|
||||
void skipFile(uint64 randomId);
|
||||
void cancelExportFast();
|
||||
|
||||
rpl::lifetime &lifetime();
|
||||
|
|
|
@ -50,7 +50,8 @@ exportErrorLabel: FlatLabel(boxLabel) {
|
|||
|
||||
exportProgressDuration: 200;
|
||||
exportProgressRowHeight: 30px;
|
||||
exportProgressRowPadding: margins(22px, 10px, 22px, 20px);
|
||||
exportProgressRowPadding: margins(22px, 10px, 22px, 10px);
|
||||
exportProgressRowSkip: 10px;
|
||||
exportProgressLabel: FlatLabel(boxLabel) {
|
||||
textFg: windowBoldFg;
|
||||
maxHeight: 20px;
|
||||
|
|
|
@ -26,8 +26,9 @@ Content ContentFromState(
|
|||
const QString &id,
|
||||
const QString &label,
|
||||
const QString &info,
|
||||
float64 progress) {
|
||||
result.rows.push_back({ id, label, info, progress });
|
||||
float64 progress,
|
||||
uint64 randomId = 0) {
|
||||
result.rows.push_back({ id, label, info, progress, randomId });
|
||||
};
|
||||
const auto pushMain = [&](const QString &label) {
|
||||
const auto info = (state.entityCount > 0)
|
||||
|
@ -56,7 +57,10 @@ Content ContentFromState(
|
|||
: addPart(state.entityIndex, state.entityCount);
|
||||
push("main", label, info, doneProgress + addProgress);
|
||||
};
|
||||
const auto pushBytes = [&](const QString &id, const QString &label) {
|
||||
const auto pushBytes = [&](
|
||||
const QString &id,
|
||||
const QString &label,
|
||||
uint64 randomId) {
|
||||
if (!state.bytesCount) {
|
||||
return;
|
||||
}
|
||||
|
@ -64,7 +68,7 @@ Content ContentFromState(
|
|||
const auto info = Ui::FormatDownloadText(
|
||||
state.bytesLoaded,
|
||||
state.bytesCount);
|
||||
push(id, label, info, progress);
|
||||
push(id, label, info, progress, randomId);
|
||||
};
|
||||
switch (state.step) {
|
||||
case Step::Initializing:
|
||||
|
@ -80,7 +84,8 @@ Content ContentFromState(
|
|||
pushMain(tr::lng_export_state_userpics(tr::now));
|
||||
pushBytes(
|
||||
"userpic" + QString::number(state.entityIndex),
|
||||
state.bytesName);
|
||||
state.bytesName,
|
||||
state.bytesRandomId);
|
||||
break;
|
||||
case Step::Contacts:
|
||||
pushMain(tr::lng_export_option_contacts(tr::now));
|
||||
|
@ -117,7 +122,8 @@ Content ContentFromState(
|
|||
+ QString::number(state.entityIndex)
|
||||
+ '_'
|
||||
+ QString::number(state.itemIndex)),
|
||||
state.bytesName);
|
||||
state.bytesName,
|
||||
state.bytesRandomId);
|
||||
break;
|
||||
default: Unexpected("Step in ContentFromState.");
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ struct Content {
|
|||
QString label;
|
||||
QString info;
|
||||
float64 progress = 0.;
|
||||
uint64 randomId = 0;
|
||||
};
|
||||
|
||||
std::vector<Row> rows;
|
||||
|
|
|
@ -299,6 +299,11 @@ void PanelController::showProgress() {
|
|||
ContentFromState(_settings.get(), ProcessingState())
|
||||
) | rpl::then(progressState()));
|
||||
|
||||
progress->skipFileClicks(
|
||||
) | rpl::start_with_next([=](uint64 randomId) {
|
||||
_process->skipFile(randomId);
|
||||
}, progress->lifetime());
|
||||
|
||||
progress->cancelClicks(
|
||||
) | rpl::start_with_next([=] {
|
||||
stopWithConfirmation();
|
||||
|
|
|
@ -18,6 +18,11 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|||
|
||||
namespace Export {
|
||||
namespace View {
|
||||
namespace {
|
||||
|
||||
constexpr auto kShowSkipFileTimeout = 5 * crl::time(1000);
|
||||
|
||||
} // namespace
|
||||
|
||||
class ProgressWidget::Row : public Ui::RpWidget {
|
||||
public:
|
||||
|
@ -235,13 +240,26 @@ ProgressWidget::ProgressWidget(
|
|||
QWidget *parent,
|
||||
rpl::producer<Content> content)
|
||||
: RpWidget(parent)
|
||||
, _body(this) {
|
||||
, _body(this)
|
||||
, _fileShowSkipTimer([=] { _skipFile->show(anim::type::normal); }) {
|
||||
widthValue(
|
||||
) | rpl::start_with_next([=](int width) {
|
||||
_body->resizeToWidth(width);
|
||||
_body->moveToLeft(0, 0);
|
||||
}, _body->lifetime());
|
||||
|
||||
auto skipFileWrap = _body->add(object_ptr<Ui::FixedHeightWidget>(
|
||||
_body.data(),
|
||||
st::defaultLinkButton.font->height + st::exportProgressRowSkip));
|
||||
_skipFile = base::make_unique_q<Ui::FadeWrap<Ui::LinkButton>>(
|
||||
skipFileWrap,
|
||||
object_ptr<Ui::LinkButton>(
|
||||
this,
|
||||
tr::lng_export_skip_file(tr::now),
|
||||
st::defaultLinkButton));
|
||||
_skipFile->hide(anim::type::instant);
|
||||
_skipFile->moveToLeft(st::exportProgressRowPadding.left(), 0);
|
||||
|
||||
_about = _body->add(
|
||||
object_ptr<Ui::FlatLabel>(
|
||||
this,
|
||||
|
@ -262,6 +280,11 @@ ProgressWidget::ProgressWidget(
|
|||
setupBottomButton(_cancel.get());
|
||||
}
|
||||
|
||||
rpl::producer<uint64> ProgressWidget::skipFileClicks() const {
|
||||
return _skipFile->entity()->clicks(
|
||||
) | rpl::map([=] { return _fileRandomId; });
|
||||
}
|
||||
|
||||
rpl::producer<> ProgressWidget::cancelClicks() const {
|
||||
return _cancel
|
||||
? (_cancel->clicks() | rpl::to_empty)
|
||||
|
@ -294,14 +317,32 @@ void ProgressWidget::updateState(Content &&content) {
|
|||
if (index < _rows.size()) {
|
||||
_rows[index]->updateData(std::move(row));
|
||||
} else {
|
||||
if (index > 0) {
|
||||
_body->insert(
|
||||
index * 2 - 1,
|
||||
object_ptr<Ui::FixedHeightWidget>(
|
||||
this,
|
||||
st::exportProgressRowSkip));
|
||||
}
|
||||
_rows.push_back(_body->insert(
|
||||
index,
|
||||
index * 2,
|
||||
object_ptr<Row>(this, std::move(row)),
|
||||
st::exportProgressRowPadding));
|
||||
_rows.back()->show();
|
||||
}
|
||||
++index;
|
||||
}
|
||||
const auto fileRandomId = !content.rows.empty()
|
||||
? content.rows.back().randomId
|
||||
: uint64(0);
|
||||
if (_fileRandomId != fileRandomId) {
|
||||
_fileShowSkipTimer.cancel();
|
||||
_skipFile->hide(anim::type::normal);
|
||||
_fileRandomId = fileRandomId;
|
||||
if (_fileRandomId) {
|
||||
_fileShowSkipTimer.callOnce(kShowSkipFileTimeout);
|
||||
}
|
||||
}
|
||||
for (const auto count = _rows.size(); index != count; ++index) {
|
||||
_rows[index]->updateData(Content::Row());
|
||||
}
|
||||
|
@ -312,6 +353,8 @@ void ProgressWidget::updateState(Content &&content) {
|
|||
|
||||
void ProgressWidget::showDone() {
|
||||
_cancel = nullptr;
|
||||
_skipFile->hide(anim::type::instant);
|
||||
_fileShowSkipTimer.cancel();
|
||||
_about->setText(tr::lng_export_about_done(tr::now));
|
||||
_done = base::make_unique_q<Ui::RoundButton>(
|
||||
this,
|
||||
|
|
|
@ -10,11 +10,15 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|||
#include "ui/rp_widget.h"
|
||||
#include "export/view/export_view_content.h"
|
||||
#include "base/object_ptr.h"
|
||||
#include "base/timer.h"
|
||||
|
||||
namespace Ui {
|
||||
class VerticalLayout;
|
||||
class RoundButton;
|
||||
class FlatLabel;
|
||||
class LinkButton;
|
||||
template <typename Widget>
|
||||
class FadeWrap;
|
||||
} // namespace Ui
|
||||
|
||||
namespace Export {
|
||||
|
@ -26,6 +30,7 @@ public:
|
|||
QWidget *parent,
|
||||
rpl::producer<Content> content);
|
||||
|
||||
rpl::producer<uint64> skipFileClicks() const;
|
||||
rpl::producer<> cancelClicks() const;
|
||||
rpl::producer<> doneClicks() const;
|
||||
|
||||
|
@ -42,11 +47,15 @@ private:
|
|||
object_ptr<Ui::VerticalLayout> _body;
|
||||
std::vector<not_null<Row*>> _rows;
|
||||
|
||||
base::unique_qptr<Ui::FadeWrap<Ui::LinkButton>> _skipFile;
|
||||
QPointer<Ui::FlatLabel> _about;
|
||||
base::unique_qptr<Ui::RoundButton> _cancel;
|
||||
base::unique_qptr<Ui::RoundButton> _done;
|
||||
rpl::event_stream<> _doneClicks;
|
||||
|
||||
uint64 _fileRandomId = 0;
|
||||
base::Timer _fileShowSkipTimer;
|
||||
|
||||
};
|
||||
|
||||
} // namespace View
|
||||
|
|
Loading…
Reference in a new issue