mirror of
https://github.com/AyuGram/AyuGramDesktop.git
synced 2025-04-17 22:57:11 +02:00
Implement PoC custom reaction selection.
This commit is contained in:
parent
cece9cf09b
commit
09124f6424
24 changed files with 449 additions and 152 deletions
|
@ -1015,7 +1015,7 @@ void Controller::fillManageSection() {
|
|||
!_peer->isBroadcast(),
|
||||
session->data().reactions().list(
|
||||
Data::Reactions::Type::Active),
|
||||
*Data::PeerAllowedReactions(_peer),
|
||||
*Data::PeerReactionsFilter(_peer).allowed,
|
||||
done));
|
||||
},
|
||||
{ &st::infoRoundedIconReactions, Settings::kIconRed });
|
||||
|
|
|
@ -566,45 +566,50 @@ void TabbedSelector::resizeEvent(QResizeEvent *e) {
|
|||
_tabsSlider->width(),
|
||||
st::lineWidth);
|
||||
}
|
||||
updateScrollGeometry(e->oldSize());
|
||||
updateRestrictedLabelGeometry();
|
||||
updateFooterGeometry();
|
||||
update();
|
||||
}
|
||||
|
||||
void TabbedSelector::updateScrollGeometry(QSize oldSize) {
|
||||
auto scrollWidth = width() - st::roundRadiusSmall;
|
||||
auto scrollHeight = height() - scrollTop() - scrollBottom();
|
||||
auto inner = currentTab()->widget();
|
||||
auto innerWidth = scrollWidth - st::emojiScroll.width;
|
||||
auto updateScrollGeometry = [&] {
|
||||
auto setScrollGeometry = [&] {
|
||||
_scroll->setGeometryToLeft(
|
||||
st::roundRadiusSmall,
|
||||
scrollTop(),
|
||||
scrollWidth,
|
||||
scrollHeight);
|
||||
};
|
||||
auto updateInnerGeometry = [&] {
|
||||
auto setInnerGeometry = [&] {
|
||||
auto scrollTop = _scroll->scrollTop();
|
||||
auto scrollBottom = scrollTop + scrollHeight;
|
||||
inner->setMinimalHeight(innerWidth, scrollHeight);
|
||||
inner->setVisibleTopBottom(scrollTop, scrollBottom);
|
||||
};
|
||||
if (e->oldSize().height() > height()) {
|
||||
updateScrollGeometry();
|
||||
updateInnerGeometry();
|
||||
if (oldSize.height() > height()) {
|
||||
setScrollGeometry();
|
||||
setInnerGeometry();
|
||||
} else {
|
||||
updateInnerGeometry();
|
||||
updateScrollGeometry();
|
||||
setInnerGeometry();
|
||||
setScrollGeometry();
|
||||
}
|
||||
_bottomShadow->setGeometry(
|
||||
0,
|
||||
_scroll->y() + _scroll->height() - st::lineWidth,
|
||||
width(),
|
||||
st::lineWidth);
|
||||
updateRestrictedLabelGeometry();
|
||||
}
|
||||
|
||||
void TabbedSelector::updateFooterGeometry() {
|
||||
_footerTop = _dropDown ? 0 : (height() - st::emojiFooterHeight);
|
||||
for (auto &tab : _tabs) {
|
||||
tab.footer()->resizeToWidth(width());
|
||||
tab.footer()->moveToLeft(0, _footerTop);
|
||||
}
|
||||
|
||||
update();
|
||||
}
|
||||
|
||||
void TabbedSelector::updateRestrictedLabelGeometry() {
|
||||
|
@ -1139,6 +1144,15 @@ void TabbedSelector::showMenuWithType(SendMenu::Type type) {
|
|||
}
|
||||
}
|
||||
|
||||
void TabbedSelector::setDropDown(bool dropDown) {
|
||||
if (_dropDown == dropDown) {
|
||||
return;
|
||||
}
|
||||
_dropDown = dropDown;
|
||||
updateFooterGeometry();
|
||||
updateScrollGeometry(size());
|
||||
}
|
||||
|
||||
rpl::producer<> TabbedSelector::contextMenuRequested() const {
|
||||
return events(
|
||||
) | rpl::filter([=](not_null<QEvent*> e) {
|
||||
|
|
|
@ -130,9 +130,7 @@ public:
|
|||
}
|
||||
|
||||
void showMenuWithType(SendMenu::Type type);
|
||||
void setDropDown(bool dropDown) {
|
||||
_dropDown = dropDown;
|
||||
}
|
||||
void setDropDown(bool dropDown);
|
||||
|
||||
// Float player interface.
|
||||
bool floatPlayerHandleWheelEvent(QEvent *e);
|
||||
|
@ -204,6 +202,8 @@ private:
|
|||
void checkRestrictedPeer();
|
||||
bool isRestrictedView();
|
||||
void updateRestrictedLabelGeometry();
|
||||
void updateScrollGeometry(QSize oldSize);
|
||||
void updateFooterGeometry();
|
||||
void handleScroll();
|
||||
|
||||
QImage grabForAnimation();
|
||||
|
|
32
Telegram/SourceFiles/data/data_message_reaction_id.cpp
Normal file
32
Telegram/SourceFiles/data/data_message_reaction_id.cpp
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
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 "data/data_message_reaction_id.h"
|
||||
|
||||
namespace Data {
|
||||
|
||||
ReactionId ReactionFromMTP(const MTPReaction &reaction) {
|
||||
return reaction.match([](MTPDreactionEmpty) {
|
||||
return ReactionId{ QString() };
|
||||
}, [](const MTPDreactionEmoji &data) {
|
||||
return ReactionId{ qs(data.vemoticon()) };
|
||||
}, [](const MTPDreactionCustomEmoji &data) {
|
||||
return ReactionId{ DocumentId(data.vdocument_id().v) };
|
||||
});
|
||||
}
|
||||
|
||||
MTPReaction ReactionToMTP(ReactionId id) {
|
||||
if (const auto custom = id.custom()) {
|
||||
return MTP_reactionCustomEmoji(MTP_long(custom));
|
||||
}
|
||||
const auto emoji = id.emoji();
|
||||
return emoji.isEmpty()
|
||||
? MTP_reactionEmpty()
|
||||
: MTP_reactionEmoji(MTP_string(emoji));
|
||||
}
|
||||
|
||||
} // namespace Data
|
49
Telegram/SourceFiles/data/data_message_reaction_id.h
Normal file
49
Telegram/SourceFiles/data/data_message_reaction_id.h
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
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 Data {
|
||||
|
||||
struct ReactionId {
|
||||
std::variant<QString, DocumentId> data;
|
||||
|
||||
[[nodiscard]] bool empty() const {
|
||||
const auto emoji = std::get_if<QString>(&data);
|
||||
return emoji && emoji->isEmpty();
|
||||
}
|
||||
[[nodiscard]] QString emoji() const {
|
||||
const auto emoji = std::get_if<QString>(&data);
|
||||
return emoji ? *emoji : QString();
|
||||
}
|
||||
[[nodiscard]] DocumentId custom() const {
|
||||
const auto custom = std::get_if<DocumentId>(&data);
|
||||
return custom ? *custom : DocumentId();
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(ReactionId);
|
||||
|
||||
inline bool operator<(const ReactionId &a, const ReactionId &b) {
|
||||
return a.data < b.data;
|
||||
}
|
||||
inline bool operator==(const ReactionId &a, const ReactionId &b) {
|
||||
return a.data == b.data;
|
||||
}
|
||||
|
||||
[[nodiscard]] ReactionId ReactionFromMTP(const MTPReaction &reaction);
|
||||
[[nodiscard]] MTPReaction ReactionToMTP(ReactionId id);
|
||||
|
||||
struct ReactionsFilter {
|
||||
std::optional<base::flat_set<QString>> allowed;
|
||||
bool customAllowed = false;
|
||||
|
||||
friend inline auto operator<=>(
|
||||
const ReactionsFilter &,
|
||||
const ReactionsFilter &) = default;
|
||||
};
|
||||
|
||||
} // namespace Data
|
|
@ -42,26 +42,6 @@ constexpr auto kSizeForDownscale = 64;
|
|||
|
||||
} // namespace
|
||||
|
||||
ReactionId ReactionFromMTP(const MTPReaction &reaction) {
|
||||
return reaction.match([](MTPDreactionEmpty) {
|
||||
return ReactionId{ QString() };
|
||||
}, [](const MTPDreactionEmoji &data) {
|
||||
return ReactionId{ qs(data.vemoticon()) };
|
||||
}, [](const MTPDreactionCustomEmoji &data) {
|
||||
return ReactionId{ DocumentId(data.vdocument_id().v) };
|
||||
});
|
||||
}
|
||||
|
||||
MTPReaction ReactionToMTP(ReactionId id) {
|
||||
if (const auto custom = id.custom()) {
|
||||
return MTP_reactionCustomEmoji(MTP_long(custom));
|
||||
}
|
||||
const auto emoji = id.emoji();
|
||||
return emoji.isEmpty()
|
||||
? MTP_reactionEmpty()
|
||||
: MTP_reactionEmoji(MTP_string(emoji));
|
||||
}
|
||||
|
||||
Reactions::Reactions(not_null<Session*> owner)
|
||||
: _owner(owner)
|
||||
, _repaintTimer([=] { repaintCollected(); }) {
|
||||
|
|
|
@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|||
#pragma once
|
||||
|
||||
#include "base/timer.h"
|
||||
#include "data/data_message_reaction_id.h"
|
||||
|
||||
namespace Lottie {
|
||||
class Icon;
|
||||
|
@ -18,34 +19,6 @@ namespace Data {
|
|||
class DocumentMedia;
|
||||
class Session;
|
||||
|
||||
struct ReactionId {
|
||||
std::variant<QString, DocumentId> data;
|
||||
|
||||
[[nodiscard]] bool empty() const {
|
||||
const auto emoji = std::get_if<QString>(&data);
|
||||
return emoji && emoji->isEmpty();
|
||||
}
|
||||
[[nodiscard]] QString emoji() const {
|
||||
const auto emoji = std::get_if<QString>(&data);
|
||||
return emoji ? *emoji : QString();
|
||||
}
|
||||
[[nodiscard]] DocumentId custom() const {
|
||||
const auto custom = std::get_if<DocumentId>(&data);
|
||||
return custom ? *custom : DocumentId();
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(ReactionId);
|
||||
|
||||
inline bool operator<(const ReactionId &a, const ReactionId &b) {
|
||||
return a.data < b.data;
|
||||
}
|
||||
inline bool operator==(const ReactionId &a, const ReactionId &b) {
|
||||
return a.data == b.data;
|
||||
}
|
||||
|
||||
[[nodiscard]] ReactionId ReactionFromMTP(const MTPReaction &reaction);
|
||||
[[nodiscard]] MTPReaction ReactionToMTP(ReactionId id);
|
||||
|
||||
struct Reaction {
|
||||
ReactionId id;
|
||||
QString title;
|
||||
|
|
|
@ -513,25 +513,23 @@ rpl::producer<QImage> PeerUserpicImageValue(
|
|||
};
|
||||
}
|
||||
|
||||
std::optional<base::flat_set<QString>> PeerAllowedReactions(
|
||||
not_null<PeerData*> peer) {
|
||||
ReactionsFilter PeerReactionsFilter(not_null<PeerData*> peer) {
|
||||
if (const auto chat = peer->asChat()) {
|
||||
return chat->allowedReactions();
|
||||
return { .allowed = chat->allowedReactions() };
|
||||
} else if (const auto channel = peer->asChannel()) {
|
||||
return channel->allowedReactions();
|
||||
return { .allowed = channel->allowedReactions() };
|
||||
} else {
|
||||
return std::nullopt;
|
||||
return { .customAllowed = true };
|
||||
}
|
||||
}
|
||||
|
||||
auto PeerAllowedReactionsValue(
|
||||
not_null<PeerData*> peer)
|
||||
-> rpl::producer<std::optional<base::flat_set<QString>>> {
|
||||
rpl::producer<ReactionsFilter> PeerReactionsFilterValue(
|
||||
not_null<PeerData*> peer) {
|
||||
return peer->session().changes().peerFlagsValue(
|
||||
peer,
|
||||
Data::PeerUpdate::Flag::Reactions
|
||||
) | rpl::map([=]{
|
||||
return PeerAllowedReactions(peer);
|
||||
return PeerReactionsFilter(peer);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ class Session;
|
|||
namespace Data {
|
||||
|
||||
struct Reaction;
|
||||
struct ReactionsFilter;
|
||||
|
||||
template <typename ChangeType, typename Error, typename Generator>
|
||||
inline auto FlagsValueWithMask(
|
||||
|
@ -133,10 +134,9 @@ inline auto PeerFullFlagValue(
|
|||
int size,
|
||||
ImageRoundRadius radius);
|
||||
|
||||
[[nodiscard]] std::optional<base::flat_set<QString>> PeerAllowedReactions(
|
||||
[[nodiscard]] ReactionsFilter PeerReactionsFilter(not_null<PeerData*> peer);
|
||||
[[nodiscard]] rpl::producer<ReactionsFilter> PeerReactionsFilterValue(
|
||||
not_null<PeerData*> peer);
|
||||
[[nodiscard]] auto PeerAllowedReactionsValue(not_null<PeerData*> peer)
|
||||
-> rpl::producer<std::optional<base::flat_set<QString>>>;
|
||||
|
||||
[[nodiscard]] rpl::producer<int> UniqueReactionsLimitValue(
|
||||
not_null<Main::Session*> session);
|
||||
|
|
|
@ -22,6 +22,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|||
#include "history/view/history_view_context_menu.h"
|
||||
#include "history/view/history_view_quick_action.h"
|
||||
#include "history/view/history_view_react_button.h"
|
||||
#include "history/view/history_view_react_selector.h"
|
||||
#include "history/view/history_view_emoji_interactions.h"
|
||||
#include "history/history_item_components.h"
|
||||
#include "history/history_item_text.h"
|
||||
|
@ -345,6 +346,7 @@ HistoryInner::HistoryInner(
|
|||
Data::UniqueReactionsLimitValue(&controller->session()),
|
||||
[=](QRect updated) { update(updated); },
|
||||
controller->cachedReactionIconFactory().createMethod()))
|
||||
, _reactionsSelector(std::make_unique<HistoryView::Reactions::Selector>())
|
||||
, _touchSelectTimer([=] { onTouchSelect(); })
|
||||
, _touchScrollTimer([=] { onTouchScrollTimer(); })
|
||||
, _scrollDateCheck([this] { scrollDateCheck(); })
|
||||
|
@ -393,28 +395,25 @@ HistoryInner::HistoryInner(
|
|||
_controller->emojiInteractions().playStarted(_peer, std::move(emoji));
|
||||
}, lifetime());
|
||||
|
||||
using ChosenReaction = HistoryView::Reactions::Manager::Chosen;
|
||||
_reactionsManager->chosen(
|
||||
rpl::merge(
|
||||
_reactionsManager->chosen(),
|
||||
_reactionsSelector->chosen()
|
||||
) | rpl::start_with_next([=](ChosenReaction reaction) {
|
||||
const auto item = session().data().message(reaction.context);
|
||||
if (!item
|
||||
|| Window::ShowReactPremiumError(
|
||||
_reactionsManager->updateButton({});
|
||||
reactionChosen(reaction);
|
||||
}, lifetime());
|
||||
|
||||
_reactionsManager->setExternalSelectorShown(_reactionsSelector->shown());
|
||||
_reactionsManager->expandSelectorRequests(
|
||||
) | rpl::start_with_next([=](ReactionExpandRequest request) {
|
||||
if (request.expanded) {
|
||||
_reactionsSelector->show(
|
||||
_controller,
|
||||
item,
|
||||
reaction.id)) {
|
||||
return;
|
||||
}
|
||||
item->toggleReaction(reaction.id);
|
||||
if (item->chosenReaction() != reaction.id) {
|
||||
return;
|
||||
} else if (const auto view = item->mainView()) {
|
||||
if (const auto top = itemTop(view); top >= 0) {
|
||||
view->animateReaction({
|
||||
.id = reaction.id,
|
||||
.flyIcon = reaction.icon,
|
||||
.flyFrom = reaction.geometry.translated(0, -top),
|
||||
});
|
||||
}
|
||||
this,
|
||||
request.context,
|
||||
request.button);
|
||||
} else {
|
||||
_reactionsSelector->hide();
|
||||
}
|
||||
}, lifetime());
|
||||
|
||||
|
@ -463,7 +462,7 @@ HistoryInner::HistoryInner(
|
|||
HistoryView::Reactions::SetupManagerList(
|
||||
_reactionsManager.get(),
|
||||
&session(),
|
||||
Data::PeerAllowedReactionsValue(_peer));
|
||||
Data::PeerReactionsFilterValue(_peer));
|
||||
|
||||
controller->adaptive().chatWideValue(
|
||||
) | rpl::start_with_next([=](bool wide) {
|
||||
|
@ -478,6 +477,31 @@ HistoryInner::HistoryInner(
|
|||
setupSharingDisallowed();
|
||||
}
|
||||
|
||||
void HistoryInner::reactionChosen(const ChosenReaction &reaction) {
|
||||
const auto guard = gsl::finally([&] { _reactionsSelector->hide(); });
|
||||
|
||||
const auto item = session().data().message(reaction.context);
|
||||
if (!item
|
||||
|| Window::ShowReactPremiumError(
|
||||
_controller,
|
||||
item,
|
||||
reaction.id)) {
|
||||
return;
|
||||
}
|
||||
item->toggleReaction(reaction.id);
|
||||
if (item->chosenReaction() != reaction.id) {
|
||||
return;
|
||||
} else if (const auto view = item->mainView()) {
|
||||
if (const auto top = itemTop(view); top >= 0) {
|
||||
view->animateReaction({
|
||||
.id = reaction.id,
|
||||
.flyIcon = reaction.icon,
|
||||
.flyFrom = reaction.geometry.translated(0, -top),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Main::Session &HistoryInner::session() const {
|
||||
return _controller->session();
|
||||
}
|
||||
|
@ -1925,10 +1949,11 @@ void HistoryInner::mouseDoubleClickEvent(QMouseEvent *e) {
|
|||
|
||||
void HistoryInner::toggleFavoriteReaction(not_null<Element*> view) const {
|
||||
const auto favorite = session().data().reactions().favorite();
|
||||
const auto allowed = _reactionsManager->allowedSublist();
|
||||
if (allowed
|
||||
&& (favorite.emoji().isEmpty()
|
||||
|| !allowed->contains(favorite.emoji()))) {
|
||||
const auto &filter = _reactionsManager->filter();
|
||||
if (favorite.emoji().isEmpty() && !filter.customAllowed) {
|
||||
return;
|
||||
} else if (filter.allowed
|
||||
&& !filter.allowed->contains(favorite.emoji())) {
|
||||
return;
|
||||
}
|
||||
const auto item = view->data();
|
||||
|
|
|
@ -36,6 +36,9 @@ class Element;
|
|||
|
||||
namespace HistoryView::Reactions {
|
||||
class Manager;
|
||||
class Selector;
|
||||
struct ChosenReaction;
|
||||
struct ExpandRequest;
|
||||
struct ButtonParameters;
|
||||
} // namespace HistoryView::Reactions
|
||||
|
||||
|
@ -225,6 +228,8 @@ private:
|
|||
void onTouchScrollTimer();
|
||||
|
||||
class BotAbout;
|
||||
using ChosenReaction = HistoryView::Reactions::ChosenReaction;
|
||||
using ReactionExpandRequest = HistoryView::Reactions::ExpandRequest;
|
||||
using VideoUserpic = Dialogs::Ui::VideoUserpic;
|
||||
using SelectedItems = std::map<HistoryItem*, TextSelection, std::less<>>;
|
||||
enum class MouseAction {
|
||||
|
@ -396,6 +401,7 @@ private:
|
|||
const HistoryView::TextState &reactionState) const
|
||||
-> HistoryView::Reactions::ButtonParameters;
|
||||
void toggleFavoriteReaction(not_null<Element*> view) const;
|
||||
void reactionChosen(const ChosenReaction &reaction);
|
||||
|
||||
void setupSharingDisallowed();
|
||||
[[nodiscard]] bool hasCopyRestriction(HistoryItem *item = nullptr) const;
|
||||
|
@ -458,6 +464,7 @@ private:
|
|||
std::unique_ptr<VideoUserpic>> _videoUserpics;
|
||||
|
||||
std::unique_ptr<HistoryView::Reactions::Manager> _reactionsManager;
|
||||
std::unique_ptr<HistoryView::Reactions::Selector> _reactionsSelector;
|
||||
|
||||
MouseAction _mouseAction = MouseAction::None;
|
||||
TextSelectType _mouseSelectType = TextSelectType::Letters;
|
||||
|
|
|
@ -353,9 +353,10 @@ ListWidget::ListWidget(
|
|||
}
|
||||
}, lifetime());
|
||||
|
||||
using ChosenReaction = Reactions::Manager::Chosen;
|
||||
_reactionsManager->chosen(
|
||||
) | rpl::start_with_next([=](ChosenReaction reaction) {
|
||||
_reactionsManager->updateButton({});
|
||||
|
||||
const auto item = session().data().message(reaction.context);
|
||||
if (!item
|
||||
|| Window::ShowReactPremiumError(
|
||||
|
@ -2115,10 +2116,11 @@ void ListWidget::mouseDoubleClickEvent(QMouseEvent *e) {
|
|||
|
||||
void ListWidget::toggleFavoriteReaction(not_null<Element*> view) const {
|
||||
const auto favorite = session().data().reactions().favorite();
|
||||
const auto allowed = _reactionsManager->allowedSublist();
|
||||
if (allowed
|
||||
&& (favorite.emoji().isEmpty()
|
||||
|| !allowed->contains(favorite.emoji()))) {
|
||||
const auto &filter = _reactionsManager->filter();
|
||||
if (favorite.emoji().isEmpty() && !filter.customAllowed) {
|
||||
return;
|
||||
} else if (filter.allowed
|
||||
&& !filter.allowed->contains(favorite.emoji())) {
|
||||
return;
|
||||
}
|
||||
const auto item = view->data();
|
||||
|
|
|
@ -36,10 +36,12 @@ namespace Data {
|
|||
struct Group;
|
||||
class CloudImageView;
|
||||
struct Reaction;
|
||||
struct ReactionsFilter;
|
||||
} // namespace Data
|
||||
|
||||
namespace HistoryView::Reactions {
|
||||
class Manager;
|
||||
struct ChosenReaction;
|
||||
struct ButtonParameters;
|
||||
} // namespace HistoryView::Reactions
|
||||
|
||||
|
@ -118,7 +120,7 @@ public:
|
|||
}
|
||||
virtual CopyRestrictionType listSelectRestrictionType() = 0;
|
||||
virtual auto listAllowedReactionsValue()
|
||||
-> rpl::producer<std::optional<base::flat_set<QString>>> = 0;
|
||||
-> rpl::producer<Data::ReactionsFilter> = 0;
|
||||
virtual void listShowPremiumToast(not_null<DocumentData*> document) = 0;
|
||||
};
|
||||
|
||||
|
@ -379,6 +381,7 @@ private:
|
|||
using ScrollTopState = ListMemento::ScrollTopState;
|
||||
using PointState = HistoryView::PointState;
|
||||
using CursorState = HistoryView::CursorState;
|
||||
using ChosenReaction = HistoryView::Reactions::ChosenReaction;
|
||||
|
||||
void refreshViewer();
|
||||
void updateAroundPositionFromNearest(int nearestIndex);
|
||||
|
|
|
@ -684,8 +684,8 @@ CopyRestrictionType PinnedWidget::listSelectRestrictionType() {
|
|||
}
|
||||
|
||||
auto PinnedWidget::listAllowedReactionsValue()
|
||||
-> rpl::producer<std::optional<base::flat_set<QString>>> {
|
||||
return Data::PeerAllowedReactionsValue(_history->peer);
|
||||
-> rpl::producer<Data::ReactionsFilter> {
|
||||
return Data::PeerReactionsFilterValue(_history->peer);
|
||||
}
|
||||
|
||||
void PinnedWidget::listShowPremiumToast(not_null<DocumentData*> document) {
|
||||
|
|
|
@ -106,7 +106,7 @@ public:
|
|||
CopyRestrictionType listCopyRestrictionType(HistoryItem *item) override;
|
||||
CopyRestrictionType listSelectRestrictionType() override;
|
||||
auto listAllowedReactionsValue()
|
||||
-> rpl::producer<std::optional<base::flat_set<QString>>> override;
|
||||
-> rpl::producer<Data::ReactionsFilter> override;
|
||||
void listShowPremiumToast(not_null<DocumentData*> document) override;
|
||||
|
||||
protected:
|
||||
|
|
|
@ -95,18 +95,24 @@ constexpr auto kMaxReactionsScrollAtOnce = 2;
|
|||
Button::Button(
|
||||
Fn<void(QRect)> update,
|
||||
ButtonParameters parameters,
|
||||
Fn<void()> hideMe)
|
||||
Fn<void(bool expanded)> toggleExpanded,
|
||||
Fn<void()> hide)
|
||||
: _update(std::move(update))
|
||||
, _toggleExpanded(std::move(toggleExpanded))
|
||||
, _finalScale(ScaleForState(_state))
|
||||
, _collapsed(QPoint(), CountOuterSize())
|
||||
, _finalHeight(_collapsed.height())
|
||||
, _expandTimer([=] { applyState(State::Inside, _update); })
|
||||
, _hideTimer(hideMe) {
|
||||
, _expandTimer([=] { _toggleExpanded(true); })
|
||||
, _hideTimer(hide) {
|
||||
applyParameters(parameters, nullptr);
|
||||
}
|
||||
|
||||
Button::~Button() = default;
|
||||
|
||||
void Button::expandWithoutCustom() {
|
||||
applyState(State::Inside, _update);
|
||||
}
|
||||
|
||||
bool Button::isHidden() const {
|
||||
return (_state == State::Hidden) && !_opacityAnimation.animating();
|
||||
}
|
||||
|
@ -316,6 +322,7 @@ void Button::applyState(State state, Fn<void(QRect)> update) {
|
|||
_finalScale = finalScale;
|
||||
}
|
||||
_state = state;
|
||||
_toggleExpanded(false);
|
||||
}
|
||||
|
||||
float64 Button::ScaleForState(State state) {
|
||||
|
@ -410,15 +417,14 @@ Manager::Manager(
|
|||
_createChooseCallback = [=](ReactionId id) {
|
||||
return [=] {
|
||||
if (auto chosen = lookupChosen(id)) {
|
||||
updateButton({});
|
||||
_chosen.fire(std::move(chosen));
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Manager::Chosen Manager::lookupChosen(const ReactionId &id) const {
|
||||
auto result = Chosen{
|
||||
ChosenReaction Manager::lookupChosen(const ReactionId &id) const {
|
||||
auto result = ChosenReaction{
|
||||
.context = _buttonContext,
|
||||
.id = id,
|
||||
};
|
||||
|
@ -461,21 +467,26 @@ Manager::Chosen Manager::lookupChosen(const ReactionId &id) const {
|
|||
return result;
|
||||
}
|
||||
|
||||
void Manager::applyListFilters() {
|
||||
bool Manager::applyUniqueLimit() const {
|
||||
const auto limit = _uniqueLimit.current();
|
||||
const auto applyUniqueLimit = _buttonContext
|
||||
return _buttonContext
|
||||
&& (limit > 0)
|
||||
&& (_buttonAlreadyNotMineCount >= limit);
|
||||
}
|
||||
|
||||
void Manager::applyListFilters() {
|
||||
const auto limited = applyUniqueLimit();
|
||||
auto icons = std::vector<not_null<ReactionIcons*>>();
|
||||
icons.reserve(_list.size());
|
||||
auto showPremiumLock = (ReactionIcons*)nullptr;
|
||||
auto favoriteIndex = -1;
|
||||
for (auto &icon : _list) {
|
||||
const auto &id = icon.id;
|
||||
const auto add = applyUniqueLimit
|
||||
const auto add = limited
|
||||
? _buttonAlreadyList.contains(id)
|
||||
: (!_filter
|
||||
|| (!id.emoji().isEmpty() && _filter->contains(id.emoji())));
|
||||
: id.emoji().isEmpty()
|
||||
? _filter.customAllowed
|
||||
: (!_filter.allowed || _filter.allowed->contains(id.emoji()));
|
||||
if (add) {
|
||||
if (icon.premium
|
||||
&& !_allowSendingPremium
|
||||
|
@ -504,6 +515,9 @@ void Manager::applyListFilters() {
|
|||
const auto first = begin(icons);
|
||||
std::rotate(first, first + favoriteIndex, first + favoriteIndex + 1);
|
||||
}
|
||||
if (!limited && _filter.customAllowed && icons.size() > 1) {
|
||||
icons.erase(begin(icons) + 1, end(icons));
|
||||
}
|
||||
if (_icons == icons) {
|
||||
return;
|
||||
}
|
||||
|
@ -528,8 +542,13 @@ void Manager::stealWheelEvents(not_null<QWidget*> target) {
|
|||
Manager::~Manager() = default;
|
||||
|
||||
void Manager::updateButton(ButtonParameters parameters) {
|
||||
if (parameters.cursorLeft && _menu) {
|
||||
return;
|
||||
if (parameters.cursorLeft) {
|
||||
if (_menu) {
|
||||
return;
|
||||
} else if (_externalSelectorShown) {
|
||||
setSelectedIcon(-1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const auto contextChanged = (_buttonContext != parameters.context);
|
||||
if (contextChanged) {
|
||||
|
@ -537,6 +556,7 @@ void Manager::updateButton(ButtonParameters parameters) {
|
|||
if (_button) {
|
||||
_button->applyState(ButtonState::Hidden);
|
||||
_buttonHiding.push_back(std::move(_button));
|
||||
_expandSelectorRequests.fire({ .expanded = false });
|
||||
}
|
||||
_buttonShowTimer.cancel();
|
||||
_scheduledParameters = std::nullopt;
|
||||
|
@ -567,11 +587,32 @@ void Manager::updateButton(ButtonParameters parameters) {
|
|||
}
|
||||
}
|
||||
|
||||
void Manager::toggleExpanded(bool expanded) {
|
||||
if (!_button || !_buttonContext) {
|
||||
} else if (!expanded || (_filter.customAllowed && !applyUniqueLimit())) {
|
||||
_expandSelectorRequests.fire({
|
||||
.context = _buttonContext,
|
||||
.button = _button->geometry().marginsRemoved(
|
||||
st::reactionCornerShadow),
|
||||
.expanded = expanded,
|
||||
});
|
||||
} else {
|
||||
_button->expandWithoutCustom();
|
||||
}
|
||||
}
|
||||
|
||||
void Manager::setExternalSelectorShown(rpl::producer<bool> shown) {
|
||||
std::move(shown) | rpl::start_with_next([=](bool shown) {
|
||||
_externalSelectorShown = shown;
|
||||
}, _lifetime);
|
||||
}
|
||||
|
||||
void Manager::showButtonDelayed() {
|
||||
clearAppearAnimations();
|
||||
_button = std::make_unique<Button>(
|
||||
_buttonUpdate,
|
||||
*_scheduledParameters,
|
||||
[=](bool expanded) { toggleExpanded(expanded); },
|
||||
[=]{ updateButton({}); });
|
||||
}
|
||||
|
||||
|
@ -614,7 +655,7 @@ void Manager::applyList(
|
|||
setSelectedIcon((selected < _icons.size()) ? selected : -1);
|
||||
}
|
||||
|
||||
void Manager::updateAllowedSublist(AllowedSublist filter) {
|
||||
void Manager::updateFilter(Data::ReactionsFilter filter) {
|
||||
if (_filter == filter) {
|
||||
return;
|
||||
}
|
||||
|
@ -630,7 +671,7 @@ void Manager::updateAllowSendingPremium(bool allow) {
|
|||
applyListFilters();
|
||||
}
|
||||
|
||||
const Manager::AllowedSublist &Manager::allowedSublist() const {
|
||||
const Data::ReactionsFilter &Manager::filter() const {
|
||||
return _filter;
|
||||
}
|
||||
|
||||
|
@ -1662,7 +1703,7 @@ auto Manager::faveRequests() const -> rpl::producer<ReactionId> {
|
|||
void SetupManagerList(
|
||||
not_null<Manager*> manager,
|
||||
not_null<Main::Session*> session,
|
||||
rpl::producer<Manager::AllowedSublist> filter) {
|
||||
rpl::producer<Data::ReactionsFilter> filter) {
|
||||
const auto reactions = &session->data().reactions();
|
||||
rpl::single(rpl::empty) | rpl::then(
|
||||
reactions->updates()
|
||||
|
@ -1675,8 +1716,8 @@ void SetupManagerList(
|
|||
|
||||
std::move(
|
||||
filter
|
||||
) | rpl::start_with_next([=](Manager::AllowedSublist &&list) {
|
||||
manager->updateAllowedSublist(std::move(list));
|
||||
) | rpl::start_with_next([=](Data::ReactionsFilter &&list) {
|
||||
manager->updateFilter(std::move(list));
|
||||
}, manager->lifetime());
|
||||
|
||||
manager->faveRequests(
|
||||
|
|
|
@ -73,10 +73,12 @@ public:
|
|||
Button(
|
||||
Fn<void(QRect)> update,
|
||||
ButtonParameters parameters,
|
||||
Fn<void()> hideMe);
|
||||
Fn<void(bool)> toggleExpanded,
|
||||
Fn<void()> hide);
|
||||
~Button();
|
||||
|
||||
void applyParameters(ButtonParameters parameters);
|
||||
void expandWithoutCustom();
|
||||
|
||||
using State = ButtonState;
|
||||
void applyState(State state);
|
||||
|
@ -110,6 +112,8 @@ private:
|
|||
void updateExpandDirection(const ButtonParameters ¶meters);
|
||||
|
||||
const Fn<void(QRect)> _update;
|
||||
const Fn<void(bool)> _toggleExpanded;
|
||||
|
||||
State _state = State::Hidden;
|
||||
float64 _finalScale = 0.;
|
||||
Ui::Animations::Simple _scaleAnimation;
|
||||
|
@ -131,6 +135,23 @@ private:
|
|||
|
||||
};
|
||||
|
||||
struct ChosenReaction {
|
||||
FullMsgId context;
|
||||
Data::ReactionId id;
|
||||
std::shared_ptr<Lottie::Icon> icon;
|
||||
QRect geometry;
|
||||
|
||||
explicit operator bool() const {
|
||||
return context && !id.empty();
|
||||
}
|
||||
};
|
||||
|
||||
struct ExpandRequest {
|
||||
FullMsgId context;
|
||||
QRect button;
|
||||
bool expanded = false;
|
||||
};
|
||||
|
||||
using IconFactory = Fn<std::shared_ptr<Lottie::Icon>(
|
||||
not_null<Data::DocumentMedia*>,
|
||||
int)>;
|
||||
|
@ -145,15 +166,14 @@ public:
|
|||
~Manager();
|
||||
|
||||
using ReactionId = ::Data::ReactionId;
|
||||
using AllowedSublist = std::optional<base::flat_set<QString>>;
|
||||
|
||||
void applyList(
|
||||
const std::vector<Data::Reaction> &list,
|
||||
const ReactionId &favorite,
|
||||
bool premiumPossible);
|
||||
void updateAllowedSublist(AllowedSublist filter);
|
||||
void updateFilter(Data::ReactionsFilter filter);
|
||||
void updateAllowSendingPremium(bool allow);
|
||||
[[nodiscard]] const AllowedSublist &allowedSublist() const;
|
||||
[[nodiscard]] const Data::ReactionsFilter &filter() const;
|
||||
void updateUniqueLimit(not_null<HistoryItem*> item);
|
||||
|
||||
void updateButton(ButtonParameters parameters);
|
||||
|
@ -163,19 +183,14 @@ public:
|
|||
|
||||
[[nodiscard]] bool consumeWheelEvent(not_null<QWheelEvent*> e);
|
||||
|
||||
struct Chosen {
|
||||
FullMsgId context;
|
||||
ReactionId id;
|
||||
std::shared_ptr<Lottie::Icon> icon;
|
||||
QRect geometry;
|
||||
|
||||
explicit operator bool() const {
|
||||
return context && !id.empty();
|
||||
}
|
||||
};
|
||||
[[nodiscard]] rpl::producer<Chosen> chosen() const {
|
||||
[[nodiscard]] rpl::producer<ChosenReaction> chosen() const {
|
||||
return _chosen.events();
|
||||
}
|
||||
[[nodiscard]] auto expandSelectorRequests() const
|
||||
-> rpl::producer<ExpandRequest> {
|
||||
return _expandSelectorRequests.events();
|
||||
}
|
||||
void setExternalSelectorShown(rpl::producer<bool> shown);
|
||||
|
||||
[[nodiscard]] std::optional<QRect> lookupEffectArea(
|
||||
FullMsgId itemId) const;
|
||||
|
@ -223,8 +238,10 @@ private:
|
|||
void showButtonDelayed();
|
||||
void stealWheelEvents(not_null<QWidget*> target);
|
||||
|
||||
[[nodiscard]] Chosen lookupChosen(const ReactionId &id) const;
|
||||
[[nodiscard]] ChosenReaction lookupChosen(const ReactionId &id) const;
|
||||
[[nodiscard]] bool overCurrentButton(QPoint position) const;
|
||||
[[nodiscard]] bool applyUniqueLimit() const;
|
||||
void toggleExpanded(bool expanded);
|
||||
|
||||
void removeStaleButtons();
|
||||
void paintButton(
|
||||
|
@ -314,10 +331,11 @@ private:
|
|||
void checkIcons();
|
||||
|
||||
const IconFactory _iconFactory;
|
||||
rpl::event_stream<Chosen> _chosen;
|
||||
rpl::event_stream<ChosenReaction> _chosen;
|
||||
rpl::event_stream<ExpandRequest> _expandSelectorRequests;
|
||||
std::vector<ReactionIcons> _list;
|
||||
ReactionId _favorite;
|
||||
AllowedSublist _filter;
|
||||
Data::ReactionsFilter _filter;
|
||||
QSize _outer;
|
||||
QRect _inner;
|
||||
QSize _overlayFull;
|
||||
|
@ -372,6 +390,7 @@ private:
|
|||
|
||||
base::unique_qptr<Ui::PopupMenu> _menu;
|
||||
rpl::event_stream<ReactionId> _faveRequests;
|
||||
bool _externalSelectorShown = false;
|
||||
|
||||
rpl::lifetime _lifetime;
|
||||
|
||||
|
@ -395,7 +414,7 @@ private:
|
|||
void SetupManagerList(
|
||||
not_null<Manager*> manager,
|
||||
not_null<Main::Session*> session,
|
||||
rpl::producer<Manager::AllowedSublist> filter);
|
||||
rpl::producer<Data::ReactionsFilter> filter);
|
||||
|
||||
[[nodiscard]] std::shared_ptr<Lottie::Icon> DefaultIconFactory(
|
||||
not_null<Data::DocumentMedia*> media,
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
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 "history/view/history_view_react_selector.h"
|
||||
|
||||
#include "history/view/history_view_react_button.h"
|
||||
#include "data/data_document.h"
|
||||
#include "chat_helpers/tabbed_panel.h"
|
||||
#include "chat_helpers/tabbed_selector.h"
|
||||
#include "window/window_session_controller.h"
|
||||
#include "window/window_controller.h"
|
||||
#include "mainwindow.h"
|
||||
#include "styles/style_chat_helpers.h"
|
||||
|
||||
namespace HistoryView::Reactions {
|
||||
|
||||
void Selector::show(
|
||||
not_null<Window::SessionController*> controller,
|
||||
not_null<QWidget*> widget,
|
||||
FullMsgId contextId,
|
||||
QRect around) {
|
||||
if (!_panel) {
|
||||
create(controller);
|
||||
} else if (_contextId == contextId
|
||||
&& (!_panel->hiding() && !_panel->isHidden())) {
|
||||
return;
|
||||
}
|
||||
_contextId = contextId;
|
||||
const auto parent = _panel->parentWidget();
|
||||
const auto global = widget->mapToGlobal(around.topLeft());
|
||||
const auto local = parent->mapFromGlobal(global);
|
||||
const auto availableTop = local.y();
|
||||
const auto availableBottom = parent->height()
|
||||
- local.y()
|
||||
- around.height();
|
||||
if (availableTop >= st::emojiPanMinHeight
|
||||
|| availableTop >= availableBottom) {
|
||||
_panel->setDropDown(false);
|
||||
_panel->moveBottomRight(
|
||||
local.y(),
|
||||
local.x() + around.width() * 3);
|
||||
} else {
|
||||
_panel->setDropDown(true);
|
||||
_panel->moveTopRight(
|
||||
local.y() + around.height(),
|
||||
local.x() + around.width() * 3);
|
||||
}
|
||||
_panel->setDesiredHeightValues(
|
||||
1.,
|
||||
st::emojiPanMinHeight / 2,
|
||||
st::emojiPanMinHeight);
|
||||
_panel->showAnimated();
|
||||
}
|
||||
|
||||
rpl::producer<ChosenReaction> Selector::chosen() const {
|
||||
return _chosen.events();
|
||||
}
|
||||
|
||||
rpl::producer<bool> Selector::shown() const {
|
||||
return _shown.events();
|
||||
}
|
||||
|
||||
void Selector::create(
|
||||
not_null<Window::SessionController*> controller) {
|
||||
using Selector = ChatHelpers::TabbedSelector;
|
||||
_panel = base::make_unique_q<ChatHelpers::TabbedPanel>(
|
||||
controller->window().widget()->bodyWidget(),
|
||||
controller,
|
||||
object_ptr<Selector>(
|
||||
nullptr,
|
||||
controller,
|
||||
Window::GifPauseReason::Layer,
|
||||
ChatHelpers::TabbedSelector::Mode::EmojiStatus));
|
||||
_panel->shownValue() | rpl::start_to_stream(_shown, _panel->lifetime());
|
||||
_panel->hide();
|
||||
_panel->selector()->setAllowEmojiWithoutPremium(false);
|
||||
|
||||
auto statusChosen = _panel->selector()->customEmojiChosen(
|
||||
) | rpl::map([=](Selector::FileChosen data) {
|
||||
return data.document->id;
|
||||
});
|
||||
|
||||
rpl::merge(
|
||||
std::move(statusChosen),
|
||||
_panel->selector()->emojiChosen() | rpl::map_to(DocumentId())
|
||||
) | rpl::start_with_next([=](DocumentId id) {
|
||||
_chosen.fire(ChosenReaction{ .context = _contextId, .id = { id } });
|
||||
}, _panel->lifetime());
|
||||
|
||||
_panel->selector()->showPromoForPremiumEmoji();
|
||||
}
|
||||
|
||||
void Selector::hide(anim::type animated) {
|
||||
if (!_panel || _panel->isHidden()) {
|
||||
return;
|
||||
} else if (animated == anim::type::instant) {
|
||||
_panel->hideFast();
|
||||
} else {
|
||||
_panel->hideAnimated();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace HistoryView::Reactions
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
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 "base/unique_qptr.h"
|
||||
#include "ui/effects/animation_value.h"
|
||||
|
||||
namespace ChatHelpers {
|
||||
class TabbedPanel;
|
||||
} // namespace ChatHelpers
|
||||
|
||||
namespace Window {
|
||||
class SessionController;
|
||||
} // namespace Window
|
||||
|
||||
namespace HistoryView::Reactions {
|
||||
|
||||
struct ChosenReaction;
|
||||
|
||||
class Selector final {
|
||||
public:
|
||||
void show(
|
||||
not_null<Window::SessionController*> controller,
|
||||
not_null<QWidget*> widget,
|
||||
FullMsgId contextId,
|
||||
QRect around);
|
||||
void hide(anim::type animated = anim::type::normal);
|
||||
|
||||
[[nodiscard]] rpl::producer<ChosenReaction> chosen() const;
|
||||
[[nodiscard]] rpl::producer<bool> shown() const;
|
||||
|
||||
private:
|
||||
void create(not_null<Window::SessionController*> controller);
|
||||
|
||||
rpl::event_stream<bool> _shown;
|
||||
base::unique_qptr<ChatHelpers::TabbedPanel> _panel;
|
||||
rpl::event_stream<ChosenReaction> _chosen;
|
||||
FullMsgId _contextId;
|
||||
|
||||
};
|
||||
|
||||
} // namespace HistoryView::Reactions
|
|
@ -2041,8 +2041,8 @@ CopyRestrictionType RepliesWidget::listSelectRestrictionType() {
|
|||
}
|
||||
|
||||
auto RepliesWidget::listAllowedReactionsValue()
|
||||
-> rpl::producer<std::optional<base::flat_set<QString>>> {
|
||||
return Data::PeerAllowedReactionsValue(_history->peer);
|
||||
-> rpl::producer<Data::ReactionsFilter> {
|
||||
return Data::PeerReactionsFilterValue(_history->peer);
|
||||
}
|
||||
|
||||
void RepliesWidget::listShowPremiumToast(not_null<DocumentData*> document) {
|
||||
|
|
|
@ -143,7 +143,7 @@ public:
|
|||
CopyRestrictionType listCopyRestrictionType(HistoryItem *item) override;
|
||||
CopyRestrictionType listSelectRestrictionType() override;
|
||||
auto listAllowedReactionsValue()
|
||||
-> rpl::producer<std::optional<base::flat_set<QString>>> override;
|
||||
->rpl::producer<Data::ReactionsFilter> override;
|
||||
void listShowPremiumToast(not_null<DocumentData*> document) override;
|
||||
|
||||
protected:
|
||||
|
|
|
@ -1355,9 +1355,9 @@ CopyRestrictionType ScheduledWidget::listSelectRestrictionType() {
|
|||
}
|
||||
|
||||
auto ScheduledWidget::listAllowedReactionsValue()
|
||||
-> rpl::producer<std::optional<base::flat_set<QString>>> {
|
||||
-> rpl::producer<Data::ReactionsFilter> {
|
||||
const auto empty = base::flat_set<QString>();
|
||||
return rpl::single(std::optional<base::flat_set<QString>>(empty));
|
||||
return rpl::single(Data::ReactionsFilter{ .allowed = empty });
|
||||
}
|
||||
|
||||
void ScheduledWidget::listShowPremiumToast(
|
||||
|
|
|
@ -128,7 +128,7 @@ public:
|
|||
CopyRestrictionType listCopyRestrictionType(HistoryItem *item) override;
|
||||
CopyRestrictionType listSelectRestrictionType() override;
|
||||
auto listAllowedReactionsValue()
|
||||
-> rpl::producer<std::optional<base::flat_set<QString>>> override;
|
||||
-> rpl::producer<Data::ReactionsFilter> override;
|
||||
void listShowPremiumToast(not_null<DocumentData*> document) override;
|
||||
|
||||
protected:
|
||||
|
|
|
@ -351,7 +351,7 @@ bool ShowSendPremiumError(
|
|||
const auto type = peer->isBroadcast()
|
||||
? ReactionDisableType::Channel
|
||||
: ReactionDisableType::Group;
|
||||
if (const auto allowed = Data::PeerAllowedReactions(peer)) {
|
||||
if (const auto allowed = Data::PeerReactionsFilter(peer).allowed) {
|
||||
for (const auto &reaction : list) {
|
||||
if (reaction.premium
|
||||
&& !allowed->contains(reaction.id.emoji())) {
|
||||
|
|
Loading…
Add table
Reference in a new issue