mirror of
https://github.com/AyuGram/AyuGramDesktop.git
synced 2025-06-05 22:54:01 +02:00
Show PeerShortInfoCover in group call context menu.
This commit is contained in:
parent
bcddda3cd3
commit
d0606a3798
9 changed files with 195 additions and 21 deletions
|
@ -258,6 +258,8 @@ PRIVATE
|
||||||
boxes/username_box.h
|
boxes/username_box.h
|
||||||
calls/group/calls_choose_join_as.cpp
|
calls/group/calls_choose_join_as.cpp
|
||||||
calls/group/calls_choose_join_as.h
|
calls/group/calls_choose_join_as.h
|
||||||
|
calls/group/calls_cover_item.cpp
|
||||||
|
calls/group/calls_cover_item.h
|
||||||
calls/group/calls_group_call.cpp
|
calls/group/calls_group_call.cpp
|
||||||
calls/group/calls_group_call.h
|
calls/group/calls_group_call.h
|
||||||
calls/group/calls_group_common.cpp
|
calls/group/calls_group_common.cpp
|
||||||
|
|
|
@ -18,6 +18,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
||||||
#include "info/profile/info_profile_text.h"
|
#include "info/profile/info_profile_text.h"
|
||||||
#include "media/streaming/media_streaming_instance.h"
|
#include "media/streaming/media_streaming_instance.h"
|
||||||
#include "media/streaming/media_streaming_player.h"
|
#include "media/streaming/media_streaming_player.h"
|
||||||
|
#include "base/event_filter.h"
|
||||||
#include "lang/lang_keys.h"
|
#include "lang/lang_keys.h"
|
||||||
#include "styles/style_layers.h"
|
#include "styles/style_layers.h"
|
||||||
#include "styles/style_info.h"
|
#include "styles/style_info.h"
|
||||||
|
@ -93,6 +94,8 @@ PeerShortInfoCover::PeerShortInfoCover(
|
||||||
, _statusStyle(std::make_unique<CustomLabelStyle>(_st.status))
|
, _statusStyle(std::make_unique<CustomLabelStyle>(_st.status))
|
||||||
, _status(_widget.get(), std::move(status), _statusStyle->st)
|
, _status(_widget.get(), std::move(status), _statusStyle->st)
|
||||||
, _videoPaused(std::move(videoPaused)) {
|
, _videoPaused(std::move(videoPaused)) {
|
||||||
|
_widget->setCursor(_cursor);
|
||||||
|
|
||||||
_widget->resize(_st.size, _st.size);
|
_widget->resize(_st.size, _st.size);
|
||||||
|
|
||||||
std::move(
|
std::move(
|
||||||
|
@ -112,21 +115,23 @@ PeerShortInfoCover::PeerShortInfoCover(
|
||||||
paint(p);
|
paint(p);
|
||||||
}, lifetime());
|
}, lifetime());
|
||||||
|
|
||||||
_widget->events(
|
base::install_event_filter(_widget.get(), [=](not_null<QEvent*> e) {
|
||||||
) | rpl::filter([=](not_null<QEvent*> e) {
|
if (e->type() != QEvent::MouseButtonPress
|
||||||
return (e->type() == QEvent::MouseButtonPress)
|
&& e->type() != QEvent::MouseButtonDblClick) {
|
||||||
|| (e->type() == QEvent::MouseButtonDblClick);
|
return base::EventFilterResult::Continue;
|
||||||
}) | rpl::start_with_next([=](not_null<QEvent*> e) {
|
}
|
||||||
const auto mouse = static_cast<QMouseEvent*>(e.get());
|
const auto mouse = static_cast<QMouseEvent*>(e.get());
|
||||||
const auto x = mouse->pos().x();
|
const auto x = mouse->pos().x();
|
||||||
if (mouse->button() != Qt::LeftButton) {
|
if (mouse->button() != Qt::LeftButton) {
|
||||||
return;
|
return base::EventFilterResult::Continue;
|
||||||
} else if (/*_index > 0 && */x < _st.size / 3) {
|
} else if (/*_index > 0 && */x < _st.size / 3) {
|
||||||
_moveRequests.fire(-1);
|
_moveRequests.fire(-1);
|
||||||
} else if (/*_index + 1 < _count && */x >= _st.size / 3) {
|
} else if (/*_index + 1 < _count && */x >= _st.size / 3) {
|
||||||
_moveRequests.fire(1);
|
_moveRequests.fire(1);
|
||||||
}
|
}
|
||||||
}, lifetime());
|
e->accept();
|
||||||
|
return base::EventFilterResult::Cancel;
|
||||||
|
});
|
||||||
|
|
||||||
_name->moveToLeft(
|
_name->moveToLeft(
|
||||||
_st.namePosition.x(),
|
_st.namePosition.x(),
|
||||||
|
@ -148,6 +153,10 @@ PeerShortInfoCover::PeerShortInfoCover(
|
||||||
|
|
||||||
PeerShortInfoCover::~PeerShortInfoCover() = default;
|
PeerShortInfoCover::~PeerShortInfoCover() = default;
|
||||||
|
|
||||||
|
not_null<Ui::RpWidget*> PeerShortInfoCover::widget() const {
|
||||||
|
return _widget;
|
||||||
|
}
|
||||||
|
|
||||||
object_ptr<Ui::RpWidget> PeerShortInfoCover::takeOwned() {
|
object_ptr<Ui::RpWidget> PeerShortInfoCover::takeOwned() {
|
||||||
return std::move(_owned);
|
return std::move(_owned);
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,6 +62,7 @@ public:
|
||||||
Fn<bool()> videoPaused);
|
Fn<bool()> videoPaused);
|
||||||
~PeerShortInfoCover();
|
~PeerShortInfoCover();
|
||||||
|
|
||||||
|
[[nodiscard]] not_null<Ui::RpWidget*> widget() const;
|
||||||
[[nodiscard]] object_ptr<Ui::RpWidget> takeOwned();
|
[[nodiscard]] object_ptr<Ui::RpWidget> takeOwned();
|
||||||
|
|
||||||
void setScrollTop(int scrollTop);
|
void setScrollTop(int scrollTop);
|
||||||
|
|
|
@ -86,18 +86,20 @@ void ProcessUserpic(
|
||||||
st::shortInfoWidth * style::DevicePixelRatio(),
|
st::shortInfoWidth * style::DevicePixelRatio(),
|
||||||
ImageRoundRadius::None),
|
ImageRoundRadius::None),
|
||||||
false);
|
false);
|
||||||
|
state->current.photoLoadingProgress = 1.;
|
||||||
state->photoView = nullptr;
|
state->photoView = nullptr;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
peer->loadUserpic();
|
peer->loadUserpic();
|
||||||
state->current.photoLoadingProgress = 0.;
|
|
||||||
const auto image = state->userpicView->image();
|
const auto image = state->userpicView->image();
|
||||||
if (!image) {
|
if (!image) {
|
||||||
|
state->current.photoLoadingProgress = 0.;
|
||||||
state->current.photo = QImage();
|
state->current.photo = QImage();
|
||||||
state->waitingLoad = true;
|
state->waitingLoad = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
GenerateImage(state, image, true);
|
GenerateImage(state, image, true);
|
||||||
|
state->current.photoLoadingProgress = peer->userpicPhotoId() ? 0. : 1.;
|
||||||
state->photoView = nullptr;
|
state->photoView = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,10 +267,6 @@ void ProcessFullPhoto(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProcessOld(not_null<PeerData*> peer, not_null<UserpicState*> state) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
void ValidatePhotoId(
|
void ValidatePhotoId(
|
||||||
not_null<UserpicState*> state,
|
not_null<UserpicState*> state,
|
||||||
PhotoId oldUserpicPhotoId) {
|
PhotoId oldUserpicPhotoId) {
|
||||||
|
@ -357,12 +355,8 @@ bool ProcessCurrent(
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UserpicResult {
|
[[nodiscard]] PreparedShortInfoUserpic UserpicValue(
|
||||||
rpl::producer<PeerShortInfoUserpic> value;
|
not_null<PeerData*> peer) {
|
||||||
Fn<void(int)> move;
|
|
||||||
};
|
|
||||||
|
|
||||||
[[nodiscard]] UserpicResult UserpicValue(not_null<PeerData*> peer) {
|
|
||||||
const auto moveRequests = std::make_shared<rpl::event_stream<int>>();
|
const auto moveRequests = std::make_shared<rpl::event_stream<int>>();
|
||||||
auto move = [=](int shift) {
|
auto move = [=](int shift) {
|
||||||
moveRequests->fire_copy(shift);
|
moveRequests->fire_copy(shift);
|
||||||
|
@ -463,3 +457,11 @@ object_ptr<Ui::BoxContent> PrepareShortInfoBox(
|
||||||
open,
|
open,
|
||||||
videoIsPaused);
|
videoIsPaused);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rpl::producer<QString> PrepareShortInfoStatus(not_null<PeerData*> peer) {
|
||||||
|
return StatusValue(peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreparedShortInfoUserpic PrepareShortInfoUserpic(not_null<PeerData*> peer) {
|
||||||
|
return UserpicValue(peer);
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,13 @@ namespace Window {
|
||||||
class SessionNavigation;
|
class SessionNavigation;
|
||||||
} // namespace Window
|
} // namespace Window
|
||||||
|
|
||||||
|
struct PeerShortInfoUserpic;
|
||||||
|
|
||||||
|
struct PreparedShortInfoUserpic {
|
||||||
|
rpl::producer<PeerShortInfoUserpic> value;
|
||||||
|
Fn<void(int)> move;
|
||||||
|
};
|
||||||
|
|
||||||
[[nodiscard]] object_ptr<Ui::BoxContent> PrepareShortInfoBox(
|
[[nodiscard]] object_ptr<Ui::BoxContent> PrepareShortInfoBox(
|
||||||
not_null<PeerData*> peer,
|
not_null<PeerData*> peer,
|
||||||
Fn<void()> open,
|
Fn<void()> open,
|
||||||
|
@ -27,3 +34,9 @@ class SessionNavigation;
|
||||||
[[nodiscard]] object_ptr<Ui::BoxContent> PrepareShortInfoBox(
|
[[nodiscard]] object_ptr<Ui::BoxContent> PrepareShortInfoBox(
|
||||||
not_null<PeerData*> peer,
|
not_null<PeerData*> peer,
|
||||||
not_null<Window::SessionNavigation*> navigation);
|
not_null<Window::SessionNavigation*> navigation);
|
||||||
|
|
||||||
|
[[nodiscard]] rpl::producer<QString> PrepareShortInfoStatus(
|
||||||
|
not_null<PeerData*> peer);
|
||||||
|
|
||||||
|
[[nodiscard]] PreparedShortInfoUserpic PrepareShortInfoUserpic(
|
||||||
|
not_null<PeerData*> peer);
|
||||||
|
|
|
@ -10,6 +10,7 @@ using "ui/basic.style";
|
||||||
using "ui/widgets/widgets.style";
|
using "ui/widgets/widgets.style";
|
||||||
using "ui/layers/layers.style";
|
using "ui/layers/layers.style";
|
||||||
using "ui/chat/chat.style"; // GroupCallUserpics
|
using "ui/chat/chat.style"; // GroupCallUserpics
|
||||||
|
using "info/info.style"; // ShortInfoCover
|
||||||
using "window/window.style";
|
using "window/window.style";
|
||||||
|
|
||||||
CallSignalBars {
|
CallSignalBars {
|
||||||
|
@ -542,6 +543,24 @@ groupCallPopupVolumeMenu: Menu(groupCallMenu) {
|
||||||
widthMin: 210px;
|
widthMin: 210px;
|
||||||
itemBgOver: groupCallMenuBg;
|
itemBgOver: groupCallMenuBg;
|
||||||
}
|
}
|
||||||
|
groupCallMenuCoverSize: 240px;
|
||||||
|
groupCallPopupCoverMenu: Menu(groupCallMenu) {
|
||||||
|
widthMin: groupCallMenuCoverSize;
|
||||||
|
widthMax: groupCallMenuCoverSize;
|
||||||
|
itemBgOver: groupCallMenuBg;
|
||||||
|
}
|
||||||
|
groupCallPopupMenuWithCover: PopupMenu(groupCallPopupMenu) {
|
||||||
|
scrollPadding: margins(0px, 0px, 0px, 8px);
|
||||||
|
menu: Menu(groupCallMenu) {
|
||||||
|
widthMin: groupCallMenuCoverSize;
|
||||||
|
widthMax: groupCallMenuCoverSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groupCallMenuCover: ShortInfoCover(shortInfoCover) {
|
||||||
|
size: groupCallMenuCoverSize;
|
||||||
|
namePosition: point(17px, 28px);
|
||||||
|
statusPosition: point(17px, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
groupCallRecordingTimerPadding: margins(0px, 4px, 0px, 4px);
|
groupCallRecordingTimerPadding: margins(0px, 4px, 0px, 4px);
|
||||||
groupCallRecordingTimerFont: font(12px);
|
groupCallRecordingTimerFont: font(12px);
|
||||||
|
|
59
Telegram/SourceFiles/calls/group/calls_cover_item.cpp
Normal file
59
Telegram/SourceFiles/calls/group/calls_cover_item.cpp
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
/*
|
||||||
|
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 "calls/group/calls_cover_item.h"
|
||||||
|
|
||||||
|
#include "boxes/peers/prepare_short_info_box.h"
|
||||||
|
#include "styles/style_calls.h"
|
||||||
|
#include "styles/style_info.h"
|
||||||
|
|
||||||
|
namespace Calls {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
CoverItem::CoverItem(
|
||||||
|
not_null<RpWidget*> parent,
|
||||||
|
const style::Menu &stMenu,
|
||||||
|
const style::ShortInfoCover &st,
|
||||||
|
rpl::producer<QString> name,
|
||||||
|
rpl::producer<QString> status,
|
||||||
|
PreparedShortInfoUserpic userpic)
|
||||||
|
: Ui::Menu::ItemBase(parent, stMenu)
|
||||||
|
, _cover(
|
||||||
|
this,
|
||||||
|
st,
|
||||||
|
std::move(name),
|
||||||
|
std::move(status),
|
||||||
|
std::move(userpic.value),
|
||||||
|
[] { return false; })
|
||||||
|
, _dummyAction(new QAction(parent))
|
||||||
|
, _st(st) {
|
||||||
|
setPointerCursor(false);
|
||||||
|
|
||||||
|
initResizeHook(parent->sizeValue());
|
||||||
|
enableMouseSelecting();
|
||||||
|
enableMouseSelecting(_cover.widget());
|
||||||
|
|
||||||
|
_cover.widget()->move(0, 0);
|
||||||
|
_cover.moveRequests(
|
||||||
|
) | rpl::start_with_next(userpic.move, lifetime());
|
||||||
|
}
|
||||||
|
|
||||||
|
not_null<QAction*> CoverItem::action() const {
|
||||||
|
return _dummyAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CoverItem::isEnabled() const {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int CoverItem::contentHeight() const {
|
||||||
|
return _st.size + st::groupCallMenu.separatorPadding.bottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Calls
|
51
Telegram/SourceFiles/calls/group/calls_cover_item.h
Normal file
51
Telegram/SourceFiles/calls/group/calls_cover_item.h
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
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
|
||||||
|
|
||||||
|
#include "ui/rp_widget.h"
|
||||||
|
#include "ui/widgets/menu/menu_item_base.h"
|
||||||
|
#include "boxes/peers/peer_short_info_box.h"
|
||||||
|
|
||||||
|
struct PreparedShortInfoUserpic;
|
||||||
|
|
||||||
|
namespace style {
|
||||||
|
struct ShortInfoCover;
|
||||||
|
} // namespace style
|
||||||
|
|
||||||
|
namespace Calls {
|
||||||
|
|
||||||
|
namespace Group {
|
||||||
|
struct MuteRequest;
|
||||||
|
struct VolumeRequest;
|
||||||
|
struct ParticipantState;
|
||||||
|
} // namespace Group
|
||||||
|
|
||||||
|
class CoverItem final : public Ui::Menu::ItemBase {
|
||||||
|
public:
|
||||||
|
CoverItem(
|
||||||
|
not_null<RpWidget*> parent,
|
||||||
|
const style::Menu &stMenu,
|
||||||
|
const style::ShortInfoCover &st,
|
||||||
|
rpl::producer<QString> name,
|
||||||
|
rpl::producer<QString> status,
|
||||||
|
PreparedShortInfoUserpic userpic);
|
||||||
|
|
||||||
|
not_null<QAction*> action() const override;
|
||||||
|
bool isEnabled() const override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
int contentHeight() const override;
|
||||||
|
|
||||||
|
const PeerShortInfoCover _cover;
|
||||||
|
const not_null<QAction*> _dummyAction;
|
||||||
|
const style::ShortInfoCover &_st;
|
||||||
|
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Calls
|
|
@ -7,6 +7,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
||||||
*/
|
*/
|
||||||
#include "calls/group/calls_group_members.h"
|
#include "calls/group/calls_group_members.h"
|
||||||
|
|
||||||
|
#include "calls/group/calls_cover_item.h"
|
||||||
#include "calls/group/calls_group_call.h"
|
#include "calls/group/calls_group_call.h"
|
||||||
#include "calls/group/calls_group_menu.h"
|
#include "calls/group/calls_group_menu.h"
|
||||||
#include "calls/group/calls_volume_item.h"
|
#include "calls/group/calls_volume_item.h"
|
||||||
|
@ -31,7 +32,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
||||||
#include "main/main_session.h"
|
#include "main/main_session.h"
|
||||||
#include "main/main_account.h" // account().appConfig().
|
#include "main/main_account.h" // account().appConfig().
|
||||||
#include "main/main_app_config.h" // appConfig().get<double>().
|
#include "main/main_app_config.h" // appConfig().get<double>().
|
||||||
|
#include "info/profile/info_profile_values.h" // Info::Profile::NameValue.
|
||||||
#include "boxes/peers/edit_participants_box.h" // SubscribeToMigration.
|
#include "boxes/peers/edit_participants_box.h" // SubscribeToMigration.
|
||||||
|
#include "boxes/peers/prepare_short_info_box.h" // PrepareShortInfo...
|
||||||
#include "window/window_controller.h" // Controller::sessionController.
|
#include "window/window_controller.h" // Controller::sessionController.
|
||||||
#include "window/window_session_controller.h"
|
#include "window/window_session_controller.h"
|
||||||
#include "webrtc/webrtc_video_track.h"
|
#include "webrtc/webrtc_video_track.h"
|
||||||
|
@ -1189,6 +1192,7 @@ base::unique_qptr<Ui::PopupMenu> Members::Controller::createRowContextMenu(
|
||||||
const auto muteState = real->state();
|
const auto muteState = real->state();
|
||||||
const auto muted = (muteState == Row::State::Muted)
|
const auto muted = (muteState == Row::State::Muted)
|
||||||
|| (muteState == Row::State::RaisedHand);
|
|| (muteState == Row::State::RaisedHand);
|
||||||
|
const auto addCover = true;
|
||||||
const auto addVolumeItem = !muted || isMe(participantPeer);
|
const auto addVolumeItem = !muted || isMe(participantPeer);
|
||||||
const auto admin = IsGroupCallAdmin(_peer, participantPeer);
|
const auto admin = IsGroupCallAdmin(_peer, participantPeer);
|
||||||
const auto session = &_peer->session();
|
const auto session = &_peer->session();
|
||||||
|
@ -1213,7 +1217,9 @@ base::unique_qptr<Ui::PopupMenu> Members::Controller::createRowContextMenu(
|
||||||
|
|
||||||
auto result = base::make_unique_q<Ui::PopupMenu>(
|
auto result = base::make_unique_q<Ui::PopupMenu>(
|
||||||
parent,
|
parent,
|
||||||
(addVolumeItem
|
(addCover
|
||||||
|
? st::groupCallPopupMenuWithCover
|
||||||
|
: addVolumeItem
|
||||||
? st::groupCallPopupMenuWithVolume
|
? st::groupCallPopupMenuWithVolume
|
||||||
: st::groupCallPopupMenu));
|
: st::groupCallPopupMenu));
|
||||||
const auto weakMenu = Ui::MakeWeak(result.get());
|
const auto weakMenu = Ui::MakeWeak(result.get());
|
||||||
|
@ -1247,6 +1253,18 @@ base::unique_qptr<Ui::PopupMenu> Members::Controller::createRowContextMenu(
|
||||||
_kickParticipantRequests.fire_copy(participantPeer);
|
_kickParticipantRequests.fire_copy(participantPeer);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (addCover) {
|
||||||
|
result->addAction(base::make_unique_q<CoverItem>(
|
||||||
|
result->menu(),
|
||||||
|
st::groupCallPopupCoverMenu,
|
||||||
|
st::groupCallMenuCover,
|
||||||
|
Info::Profile::NameValue(
|
||||||
|
participantPeer
|
||||||
|
) | rpl::map([](const auto &text) { return text.text; }),
|
||||||
|
PrepareShortInfoStatus(participantPeer),
|
||||||
|
PrepareShortInfoUserpic(participantPeer)));
|
||||||
|
}
|
||||||
|
|
||||||
if (const auto real = _call->lookupReal()) {
|
if (const auto real = _call->lookupReal()) {
|
||||||
auto oneFound = false;
|
auto oneFound = false;
|
||||||
auto hasTwoOrMore = false;
|
auto hasTwoOrMore = false;
|
||||||
|
@ -1357,7 +1375,7 @@ base::unique_qptr<Ui::PopupMenu> Members::Controller::createRowContextMenu(
|
||||||
removeFromVoiceChat));
|
removeFromVoiceChat));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (result->empty()) {
|
if (result->actions().size() < (addCover ? 2 : 1)) {
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
@ -1451,7 +1469,7 @@ void Members::Controller::addMuteActionsToContextMenu(
|
||||||
}
|
}
|
||||||
}, volumeItem->lifetime());
|
}, volumeItem->lifetime());
|
||||||
|
|
||||||
if (!menu->empty()) {
|
if (menu->actions().size() > 1) { // First - cover.
|
||||||
menu->addSeparator();
|
menu->addSeparator();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue