mirror of
https://github.com/AyuGram/AyuGramDesktop.git
synced 2025-04-15 13:47:05 +02:00
Implemented initial ui of moderation box.
This commit is contained in:
parent
dd1cb00c62
commit
643ecd2c2c
6 changed files with 539 additions and 1 deletions
|
@ -273,6 +273,8 @@ PRIVATE
|
|||
boxes/local_storage_box.h
|
||||
boxes/max_invite_box.cpp
|
||||
boxes/max_invite_box.h
|
||||
boxes/moderate_messages_box.cpp
|
||||
boxes/moderate_messages_box.h
|
||||
boxes/peer_list_box.cpp
|
||||
boxes/peer_list_box.h
|
||||
boxes/peer_list_controllers.cpp
|
||||
|
|
|
@ -2854,7 +2854,17 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|||
"lng_in_dlg_audio_count#other" = "{count} audio";
|
||||
|
||||
"lng_ban_user" = "Ban User";
|
||||
"lng_ban_users" = "Ban users";
|
||||
"lng_restrict_users" = "Restrict users";
|
||||
"lng_delete_all_from_user" = "Delete all from {user}";
|
||||
"lng_delete_all_from_users" = "Delete all from users";
|
||||
"lng_restrict_user_part" = "Partially restrict this user";
|
||||
"lng_restrict_users_part" = "Partially restrict users";
|
||||
"lng_restrict_user_full" = "Fully ban this user";
|
||||
"lng_restrict_users_full" = "Fully ban users";
|
||||
"lng_restrict_users_part_single_header" = "What can this user do?";
|
||||
"lng_restrict_users_part_header#one" = "What can {count} selected user do?";
|
||||
"lng_restrict_users_part_header#other" = "What can {count} selected users do?";
|
||||
"lng_report_spam" = "Report Spam";
|
||||
"lng_report_spam_and_leave" = "Report spam and leave";
|
||||
"lng_report_spam_done" = "Thank you for your report.";
|
||||
|
|
|
@ -1074,3 +1074,16 @@ collectibleBox: Box(defaultBox) {
|
|||
buttonHeight: 36px;
|
||||
button: collectibleCopy;
|
||||
}
|
||||
|
||||
moderateBoxUserpic: UserpicButton(defaultUserpicButton) {
|
||||
size: size(34px, 42px);
|
||||
photoSize: 34px;
|
||||
photoPosition: point(0px, 4px);
|
||||
}
|
||||
moderateBoxExpand: icon {{ "chat/reply_type_group", boxTextFg }};
|
||||
moderateBoxExpandHeight: 20px;
|
||||
moderateBoxExpandRight: 10px;
|
||||
moderateBoxExpandInnerSkip: 2px;
|
||||
moderateBoxExpandFont: font(11px);
|
||||
moderateBoxExpandToggleSize: 4px;
|
||||
moderateBoxExpandToggleFourStrokes: 3px;
|
||||
|
|
494
Telegram/SourceFiles/boxes/moderate_messages_box.cpp
Normal file
494
Telegram/SourceFiles/boxes/moderate_messages_box.cpp
Normal file
|
@ -0,0 +1,494 @@
|
|||
/*
|
||||
This file is part of Telegram Desktop,
|
||||
the official desktop application for the Telegram messaging service.
|
||||
|
||||
For license and copyright information please follow this link:
|
||||
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
||||
*/
|
||||
#include "boxes/moderate_messages_box.h"
|
||||
|
||||
#include "boxes/delete_messages_box.h"
|
||||
#include "boxes/peers/edit_peer_permissions_box.h"
|
||||
#include "data/data_channel.h"
|
||||
#include "data/data_chat.h"
|
||||
#include "data/data_peer.h"
|
||||
#include "data/data_user.h"
|
||||
#include "history/history.h"
|
||||
#include "history/history_item.h"
|
||||
#include "lang/lang_keys.h"
|
||||
#include "ui/controls/userpic_button.h"
|
||||
#include "ui/effects/ripple_animation.h"
|
||||
#include "ui/effects/toggle_arrow.h"
|
||||
#include "ui/layers/generic_box.h"
|
||||
#include "ui/painter.h"
|
||||
#include "ui/rect.h"
|
||||
#include "ui/rect_part.h"
|
||||
#include "ui/text/text_utilities.h"
|
||||
#include "ui/vertical_list.h"
|
||||
#include "ui/widgets/checkbox.h"
|
||||
#include "ui/wrap/slide_wrap.h"
|
||||
#include "styles/style_boxes.h"
|
||||
#include "styles/style_layers.h"
|
||||
|
||||
namespace {
|
||||
|
||||
enum class ModerateOption {
|
||||
Ban = (1 << 0),
|
||||
DeleteAll = (1 << 1),
|
||||
};
|
||||
inline constexpr bool is_flag_type(ModerateOption) { return true; }
|
||||
using ModerateOptions = base::flags<ModerateOption>;
|
||||
|
||||
ModerateOptions CalculateModerateOptions(const HistoryItemsList &items) {
|
||||
Expects(!items.empty());
|
||||
|
||||
const auto peer = items.front()->history()->peer;
|
||||
auto allCanBan = true;
|
||||
auto allCanDelete = true;
|
||||
for (const auto &item : items) {
|
||||
if (!allCanBan && !allCanDelete) {
|
||||
return ModerateOptions(0);
|
||||
}
|
||||
if (peer != item->history()->peer) {
|
||||
return ModerateOptions(0);
|
||||
}
|
||||
if (!item->suggestBanReport()) {
|
||||
allCanBan = false;
|
||||
}
|
||||
if (!item->suggestDeleteAllReport()) {
|
||||
allCanDelete = false;
|
||||
}
|
||||
}
|
||||
return ModerateOptions(0)
|
||||
| (allCanBan ? ModerateOption::Ban : ModerateOptions(0))
|
||||
| (allCanDelete ? ModerateOption::DeleteAll : ModerateOptions(0));
|
||||
}
|
||||
|
||||
class Button final : public Ui::RippleButton {
|
||||
public:
|
||||
Button(not_null<QWidget*> parent, int count);
|
||||
|
||||
void setChecked(bool checked);
|
||||
[[nodiscard]] bool checked() const;
|
||||
|
||||
[[nodiscard]] static QSize ComputeSize(int);
|
||||
|
||||
private:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
QImage prepareRippleMask() const override;
|
||||
QPoint prepareRippleStartPosition() const override;
|
||||
|
||||
const int _count;
|
||||
const QString _text;
|
||||
bool _checked = false;
|
||||
|
||||
Ui::Animations::Simple _animation;
|
||||
|
||||
};
|
||||
|
||||
Button::Button(not_null<QWidget*> parent, int count)
|
||||
: RippleButton(parent, st::defaultRippleAnimation)
|
||||
, _count(count)
|
||||
, _text(QString::number(std::abs(_count))) {
|
||||
}
|
||||
|
||||
QSize Button::ComputeSize(int count) {
|
||||
return QSize(
|
||||
st::moderateBoxExpandHeight
|
||||
+ st::moderateBoxExpand.width()
|
||||
+ st::moderateBoxExpandInnerSkip * 4
|
||||
+ st::moderateBoxExpandFont->width(
|
||||
QString::number(std::abs(count)))
|
||||
+ st::moderateBoxExpandToggleSize,
|
||||
st::moderateBoxExpandHeight);
|
||||
}
|
||||
|
||||
void Button::setChecked(bool checked) {
|
||||
if (_checked == checked) {
|
||||
return;
|
||||
}
|
||||
_checked = checked;
|
||||
_animation.stop();
|
||||
_animation.start(
|
||||
[=] { update(); },
|
||||
checked ? 0 : 1,
|
||||
checked ? 1 : 0,
|
||||
st::slideWrapDuration);
|
||||
}
|
||||
|
||||
bool Button::checked() const {
|
||||
return _checked;
|
||||
}
|
||||
|
||||
void Button::paintEvent(QPaintEvent *event) {
|
||||
auto p = Painter(this);
|
||||
auto hq = PainterHighQualityEnabler(p);
|
||||
Ui::RippleButton::paintRipple(p, QPoint());
|
||||
const auto radius = height() / 2;
|
||||
p.setPen(Qt::NoPen);
|
||||
st::moderateBoxExpand.paint(
|
||||
p,
|
||||
radius,
|
||||
(height() - st::moderateBoxExpand.height()) / 2,
|
||||
width());
|
||||
|
||||
const auto innerSkip = st::moderateBoxExpandInnerSkip;
|
||||
|
||||
p.setBrush(Qt::NoBrush);
|
||||
p.setPen(st::boxTextFg);
|
||||
p.setFont(st::moderateBoxExpandFont);
|
||||
p.drawText(
|
||||
QRect(
|
||||
innerSkip + radius + st::moderateBoxExpand.width(),
|
||||
0,
|
||||
width(),
|
||||
height()),
|
||||
_text,
|
||||
style::al_left);
|
||||
|
||||
const auto path = Ui::ToggleUpDownArrowPath(
|
||||
width() - st::moderateBoxExpandToggleSize - radius,
|
||||
height() / 2,
|
||||
st::moderateBoxExpandToggleSize,
|
||||
st::moderateBoxExpandToggleFourStrokes,
|
||||
_animation.value(_checked ? 1. : 0.));
|
||||
p.fillPath(path, st::boxTextFg);
|
||||
}
|
||||
|
||||
QImage Button::prepareRippleMask() const {
|
||||
return Ui::RippleAnimation::RoundRectMask(size(), size().height() / 2);
|
||||
}
|
||||
|
||||
QPoint Button::prepareRippleStartPosition() const {
|
||||
return mapFromGlobal(QCursor::pos());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void CreateModerateMessagesBox(
|
||||
not_null<Ui::GenericBox*> box,
|
||||
const HistoryItemsList &items) {
|
||||
const auto options = CalculateModerateOptions(items);
|
||||
const auto inner = box->verticalLayout();
|
||||
|
||||
const auto users = [&] {
|
||||
auto result = std::vector<not_null<UserData*>>();
|
||||
for (const auto &item : items) {
|
||||
if (const auto user = item->from()->asUser()) {
|
||||
if (!ranges::contains(result, not_null{ user })) {
|
||||
result.push_back(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}();
|
||||
Assert(!users.empty());
|
||||
|
||||
const auto isSingle = users.size() == 1;
|
||||
const auto buttonPadding = isSingle
|
||||
? QMargins()
|
||||
: QMargins(0, 0, Button::ComputeSize(users.size()).width(), 0);
|
||||
|
||||
struct Controller final {
|
||||
rpl::event_stream<bool> toggleRequestsFromTop;
|
||||
rpl::event_stream<bool> toggleRequestsFromInner;
|
||||
rpl::event_stream<bool> checkAllRequests;
|
||||
};
|
||||
const auto createUsersList = [&](not_null<Controller*> controller) {
|
||||
const auto wrap = inner->add(
|
||||
object_ptr<Ui::SlideWrap<Ui::VerticalLayout>>(
|
||||
inner,
|
||||
object_ptr<Ui::VerticalLayout>(inner)));
|
||||
wrap->toggle(false, anim::type::instant);
|
||||
|
||||
controller->toggleRequestsFromTop.events(
|
||||
) | rpl::start_with_next([=](bool toggled) {
|
||||
wrap->toggle(toggled, anim::type::normal);
|
||||
}, wrap->lifetime());
|
||||
|
||||
const auto container = wrap->entity();
|
||||
Ui::AddSkip(container);
|
||||
|
||||
auto &lifetime = wrap->lifetime();
|
||||
const auto clicks = lifetime.make_state<rpl::event_stream<>>();
|
||||
const auto checkboxes = ranges::views::all(
|
||||
users
|
||||
) | ranges::views::transform([&](not_null<UserData*> user) {
|
||||
const auto line = container->add(
|
||||
object_ptr<Ui::AbstractButton>(container));
|
||||
const auto &st = st::moderateBoxUserpic;
|
||||
line->resize(line->width(), st.size.height());
|
||||
|
||||
const auto userpic = Ui::CreateChild<Ui::UserpicButton>(
|
||||
line,
|
||||
user,
|
||||
st);
|
||||
const auto checkbox = Ui::CreateChild<Ui::Checkbox>(
|
||||
line,
|
||||
user->name(),
|
||||
false,
|
||||
st::defaultBoxCheckbox);
|
||||
line->widthValue(
|
||||
) | rpl::start_with_next([=](int width) {
|
||||
userpic->moveToLeft(
|
||||
st::boxRowPadding.left()
|
||||
+ checkbox->checkRect().width()
|
||||
+ st::defaultBoxCheckbox.textPosition.x(),
|
||||
0);
|
||||
const auto skip = st::defaultBoxCheckbox.textPosition.x();
|
||||
checkbox->resizeToWidth(width
|
||||
- rect::right(userpic)
|
||||
- skip
|
||||
- st::boxRowPadding.right());
|
||||
checkbox->moveToLeft(
|
||||
rect::right(userpic) + skip,
|
||||
((userpic->height() - checkbox->height()) / 2)
|
||||
+ st::defaultBoxCheckbox.margin.top());
|
||||
}, checkbox->lifetime());
|
||||
|
||||
userpic->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
checkbox->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
|
||||
line->setClickedCallback([=] {
|
||||
checkbox->setChecked(!checkbox->checked());
|
||||
clicks->fire({});
|
||||
});
|
||||
|
||||
return checkbox;
|
||||
}) | ranges::to_vector;
|
||||
|
||||
clicks->events(
|
||||
) | rpl::start_with_next([=] {
|
||||
controller->toggleRequestsFromInner.fire_copy(
|
||||
ranges::any_of(checkboxes, &Ui::Checkbox::checked));
|
||||
}, container->lifetime());
|
||||
|
||||
controller->checkAllRequests.events(
|
||||
) | rpl::start_with_next([=](bool checked) {
|
||||
for (const auto &c : checkboxes) {
|
||||
c->setChecked(checked);
|
||||
}
|
||||
}, container->lifetime());
|
||||
};
|
||||
|
||||
const auto appendList = [&](not_null<Ui::Checkbox*> checkbox) {
|
||||
const auto button = Ui::CreateChild<Button>(inner, users.size());
|
||||
button->resize(Button::ComputeSize(users.size()));
|
||||
|
||||
const auto overlay = Ui::CreateChild<Ui::AbstractButton>(inner);
|
||||
|
||||
checkbox->geometryValue(
|
||||
) | rpl::start_with_next([=](const QRect &rect) {
|
||||
overlay->setGeometry(rect);
|
||||
overlay->raise();
|
||||
|
||||
button->moveToRight(
|
||||
st::moderateBoxExpandRight,
|
||||
rect.top() + (rect.height() - button->height()) / 2,
|
||||
box->width());
|
||||
button->raise();
|
||||
}, button->lifetime());
|
||||
|
||||
const auto controller = checkbox->lifetime().make_state<Controller>();
|
||||
controller->toggleRequestsFromInner.events(
|
||||
) | rpl::start_with_next([=](bool toggled) {
|
||||
checkbox->setChecked(toggled);
|
||||
}, checkbox->lifetime());
|
||||
button->setClickedCallback([=] {
|
||||
button->setChecked(!button->checked());
|
||||
controller->toggleRequestsFromTop.fire_copy(button->checked());
|
||||
});
|
||||
overlay->setClickedCallback([=] {
|
||||
checkbox->setChecked(!checkbox->checked());
|
||||
controller->checkAllRequests.fire_copy(checkbox->checked());
|
||||
});
|
||||
createUsersList(controller);
|
||||
};
|
||||
|
||||
Ui::AddSkip(inner);
|
||||
box->addRow(
|
||||
object_ptr<Ui::FlatLabel>(
|
||||
box,
|
||||
(items.size() == 1)
|
||||
? tr::lng_selected_delete_sure_this()
|
||||
: tr::lng_selected_delete_sure(
|
||||
lt_count,
|
||||
rpl::single(items.size()) | tr::to_count()),
|
||||
st::boxLabel));
|
||||
Ui::AddSkip(inner);
|
||||
Ui::AddSkip(inner);
|
||||
Ui::AddSkip(inner);
|
||||
{
|
||||
const auto report = box->addRow(
|
||||
object_ptr<Ui::Checkbox>(
|
||||
box,
|
||||
tr::lng_report_spam(tr::now),
|
||||
false,
|
||||
st::defaultBoxCheckbox),
|
||||
st::boxRowPadding + buttonPadding);
|
||||
if (!isSingle) {
|
||||
appendList(report);
|
||||
}
|
||||
}
|
||||
|
||||
if (options & ModerateOption::DeleteAll) {
|
||||
Ui::AddSkip(inner);
|
||||
Ui::AddSkip(inner);
|
||||
|
||||
const auto deleteAll = inner->add(
|
||||
object_ptr<Ui::Checkbox>(
|
||||
inner,
|
||||
!(isSingle)
|
||||
? tr::lng_delete_all_from_users(
|
||||
tr::now,
|
||||
Ui::Text::WithEntities)
|
||||
: tr::lng_delete_all_from_user(
|
||||
tr::now,
|
||||
lt_user,
|
||||
Ui::Text::Bold(items.front()->from()->name()),
|
||||
Ui::Text::WithEntities),
|
||||
false,
|
||||
st::defaultBoxCheckbox),
|
||||
st::boxRowPadding + buttonPadding);
|
||||
if (!isSingle) {
|
||||
appendList(deleteAll);
|
||||
}
|
||||
}
|
||||
if (options & ModerateOption::Ban) {
|
||||
auto ownedWrap = object_ptr<Ui::SlideWrap<Ui::VerticalLayout>>(
|
||||
inner,
|
||||
object_ptr<Ui::VerticalLayout>(inner));
|
||||
|
||||
Ui::AddSkip(inner);
|
||||
Ui::AddSkip(inner);
|
||||
const auto ban = inner->add(
|
||||
object_ptr<Ui::Checkbox>(
|
||||
box,
|
||||
rpl::conditional(
|
||||
ownedWrap->toggledValue(),
|
||||
tr::lng_context_restrict_user(),
|
||||
rpl::conditional(
|
||||
rpl::single(isSingle),
|
||||
tr::lng_ban_user(),
|
||||
tr::lng_ban_users())),
|
||||
false,
|
||||
st::defaultBoxCheckbox),
|
||||
st::boxRowPadding + buttonPadding);
|
||||
if (!isSingle) {
|
||||
appendList(ban);
|
||||
}
|
||||
Ui::AddSkip(inner);
|
||||
Ui::AddSkip(inner);
|
||||
|
||||
const auto wrap = inner->add(std::move(ownedWrap));
|
||||
const auto container = wrap->entity();
|
||||
wrap->toggle(false, anim::type::instant);
|
||||
auto label = object_ptr<Ui::FlatLabel>(
|
||||
inner,
|
||||
wrap->toggledValue(
|
||||
) | rpl::map([isSingle](bool toggled) {
|
||||
return Ui::Text::Link(
|
||||
((toggled && isSingle)
|
||||
? tr::lng_restrict_user_part
|
||||
: (toggled && !isSingle)
|
||||
? tr::lng_restrict_users_part
|
||||
: isSingle
|
||||
? tr::lng_restrict_user_full
|
||||
: tr::lng_restrict_users_full)(tr::now),
|
||||
u"internal:"_q);
|
||||
}),
|
||||
st::boxDividerLabel);
|
||||
|
||||
auto &lifetime = wrap->lifetime();
|
||||
const auto scrollLifetime = lifetime.make_state<rpl::lifetime>();
|
||||
label->setClickHandlerFilter([=](
|
||||
const ClickHandlerPtr &handler,
|
||||
Qt::MouseButton button) {
|
||||
if (button != Qt::LeftButton) {
|
||||
return false;
|
||||
}
|
||||
wrap->toggle(!wrap->toggled(), anim::type::normal);
|
||||
{
|
||||
const auto start = crl::now();
|
||||
inner->heightValue(
|
||||
) | rpl::start_with_next([=] {
|
||||
if (!wrap->animating()) {
|
||||
scrollLifetime->destroy();
|
||||
Ui::PostponeCall(crl::guard(box, [=] {
|
||||
box->scrollToY(std::numeric_limits<int>::max());
|
||||
}));
|
||||
} else {
|
||||
box->scrollToY(std::numeric_limits<int>::max());
|
||||
}
|
||||
}, *scrollLifetime);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
Ui::AddSkip(inner);
|
||||
inner->add(object_ptr<Ui::DividerLabel>(
|
||||
inner,
|
||||
std::move(label),
|
||||
st::defaultBoxDividerLabelPadding,
|
||||
RectPart::Top | RectPart::Bottom));
|
||||
|
||||
using Flag = ChatRestriction;
|
||||
using Flags = ChatRestrictions;
|
||||
const auto peer = items.front()->history()->peer;
|
||||
const auto chat = peer->asChat();
|
||||
const auto channel = peer->asChannel();
|
||||
const auto defaultRestrictions = chat
|
||||
? chat->defaultRestrictions()
|
||||
: channel->defaultRestrictions();
|
||||
const auto prepareFlags = FixDependentRestrictions(
|
||||
defaultRestrictions
|
||||
| ((channel && channel->isPublic())
|
||||
? (Flag::ChangeInfo | Flag::PinMessages)
|
||||
: Flags(0)));
|
||||
const auto disabledMessages = [&] {
|
||||
auto result = base::flat_map<Flags, QString>();
|
||||
{
|
||||
const auto disabled = FixDependentRestrictions(
|
||||
defaultRestrictions
|
||||
| ((channel && channel->isPublic())
|
||||
? (Flag::ChangeInfo | Flag::PinMessages)
|
||||
: Flags(0)));
|
||||
result.emplace(
|
||||
disabled,
|
||||
tr::lng_rights_restriction_for_all(tr::now));
|
||||
}
|
||||
return result;
|
||||
}();
|
||||
|
||||
auto [checkboxes, getRestrictions, changes] = CreateEditRestrictions(
|
||||
box,
|
||||
rpl::conditional(
|
||||
rpl::single(isSingle),
|
||||
tr::lng_restrict_users_part_single_header(),
|
||||
tr::lng_restrict_users_part_header(
|
||||
lt_count,
|
||||
rpl::single(users.size()) | tr::to_count())),
|
||||
prepareFlags,
|
||||
disabledMessages,
|
||||
{ .isForum = peer->isForum() });
|
||||
std::move(
|
||||
changes
|
||||
) | rpl::start_with_next([=] {
|
||||
ban->setChecked(true);
|
||||
}, ban->lifetime());
|
||||
Ui::AddSkip(container);
|
||||
Ui::AddDivider(container);
|
||||
Ui::AddSkip(container);
|
||||
container->add(std::move(checkboxes));
|
||||
}
|
||||
Ui::AddSkip(inner);
|
||||
|
||||
box->addButton(tr::lng_box_delete(), [=] { box->closeBox(); });
|
||||
box->addButton(tr::lng_cancel(), [=] { box->closeBox(); });
|
||||
}
|
||||
|
||||
bool CanCreateModerateMessagesBox(const HistoryItemsList &items) {
|
||||
const auto options = CalculateModerateOptions(items);
|
||||
return (options & ModerateOption::Ban)
|
||||
|| (options & ModerateOption::DeleteAll);
|
||||
}
|
19
Telegram/SourceFiles/boxes/moderate_messages_box.h
Normal file
19
Telegram/SourceFiles/boxes/moderate_messages_box.h
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
This file is part of Telegram Desktop,
|
||||
the official desktop application for the Telegram messaging service.
|
||||
|
||||
For license and copyright information please follow this link:
|
||||
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
|
||||
namespace Ui {
|
||||
class GenericBox;
|
||||
} // namespace Ui
|
||||
|
||||
void CreateModerateMessagesBox(
|
||||
not_null<Ui::GenericBox*> box,
|
||||
const HistoryItemsList &items);
|
||||
|
||||
[[nodiscard]] bool CanCreateModerateMessagesBox(const HistoryItemsList &);
|
|
@ -1 +1 @@
|
|||
Subproject commit 69e474ea775f115afb3e4afeb80d3227325dfcc4
|
||||
Subproject commit 6d1014fa8f67d4f997c0580a90df075008e0f34c
|
Loading…
Add table
Reference in a new issue