Implement rich reactions selector.

This commit is contained in:
John Preston 2023-11-14 14:35:57 +04:00
parent 1e26c33b3d
commit 43a8733fc7
17 changed files with 718 additions and 214 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1347,6 +1347,12 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
"lng_manage_peer_reactions_none_about" = "Members of the group can't add any reactions to messages.";
"lng_manage_peer_reactions_some_title" = "Only allow these reactions";
"lng_manage_peer_reactions_available" = "Available reactions";
"lng_manage_peer_reactions_own" = "You can also {link} emoji packs and use them as reactions.";
"lng_manage_peer_reactions_own_link" = "create your own";
"lng_manage_peer_reactions_level#one" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reaction.";
"lng_manage_peer_reactions_level#other" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reactions.";
"lng_manage_peer_reactions_boost" = "Boost your channel {link}.";
"lng_manage_peer_reactions_boost_link" = "here";
"lng_manage_peer_antispam" = "Aggressive Anti-Spam";
"lng_manage_peer_antispam_about" = "Telegram will filter more spam but may occasionally affect ordinary messages. You can report False Positives in Recent Actions.";
@ -2087,6 +2093,11 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
"lng_boost_channel_title_color" = "Enable colors";
"lng_boost_channel_needs_level_color#one" = "Your channel needs to reach **Level {count}** to change channel color.";
"lng_boost_channel_needs_level_color#other" = "Your channel needs to reach **Level {count}** to change channel color.";
"lng_boost_channel_title_reactions" = "Custom reactions";
"lng_boost_channel_needs_level_reactions#one" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as a reaction.";
"lng_boost_channel_needs_level_reactions#other" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as reactions.";
"lng_boost_channel_ask" = "Ask your **Premium** subscribers to boost your channel with this link:";
"lng_boost_channel_ask_button" = "Copy Link";
"lng_boost_channel_or" = "or";

View file

@ -527,7 +527,7 @@ void Apply(
show->show(Box(Ui::AskBoostBox, Ui::AskBoostBoxData{
.link = qs(data.vboost_url()),
.boost = counters,
.requiredLevel = required,
.reason = { Ui::AskBoostChannelColor{ required } },
}, openStatistics, nullptr));
cancel();
}).fail([=](const MTP::Error &error) {

View file

@ -22,6 +22,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "boxes/peers/edit_linked_chat_box.h"
#include "boxes/peers/edit_peer_requests_box.h"
#include "boxes/peers/edit_peer_reactions.h"
#include "boxes/peers/replace_boost_box.h"
#include "boxes/peer_list_controllers.h"
#include "boxes/stickers_box.h"
#include "boxes/username_box.h"
#include "chat_helpers/emoji_suggestions_widget.h"
@ -36,12 +38,15 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "data/data_peer_values.h"
#include "data/data_user.h"
#include "history/admin_log/history_admin_log_section.h"
#include "info/boosts/info_boosts_widget.h"
#include "info/profile/info_profile_values.h"
#include "info/info_memento.h"
#include "lang/lang_keys.h"
#include "mtproto/sender.h"
#include "main/main_account.h"
#include "main/main_app_config.h"
#include "settings/settings_common.h" // IconDescriptor.
#include "settings/settings_common.h"
#include "ui/boxes/boost_box.h"
#include "ui/controls/userpic_button.h"
#include "ui/rp_widget.h"
#include "ui/vertical_list.h"
@ -255,6 +260,7 @@ private:
Ui::VerticalLayout *buttonsLayout = nullptr;
Ui::SettingsButton *forumToggle = nullptr;
bool forumToggleLocked = false;
bool levelRequested = false;
Ui::SlideWrap<> *historyVisibilityWrap = nullptr;
};
struct Saving {
@ -304,6 +310,7 @@ private:
void submitDescription();
void deleteWithConfirmation();
void deleteChannel();
void editReactions();
[[nodiscard]] std::optional<Saving> validate() const;
[[nodiscard]] bool validateUsernamesOrder(Saving &to) const;
@ -1100,36 +1107,21 @@ void Controller::fillManageSection() {
return Data::PeerAllowedReactions(peer);
});
}) | rpl::flatten_latest();
auto label = rpl::combine(
std::move(allowedReactions),
Info::Profile::FullReactionsCountValue(session)
) | rpl::map([=](const Data::AllowedReactions &allowed, int total) {
auto label = std::move(
allowedReactions
) | rpl::map([=](const Data::AllowedReactions &allowed) {
const auto some = int(allowed.some.size());
return (allowed.type != Data::AllowedReactionsType::Some)
? tr::lng_manage_peer_reactions_on(tr::now)
: some
? (QString::number(some)
+ " / "
+ QString::number(std::max(some, total)))
? QString::number(some)
: tr::lng_manage_peer_reactions_off(tr::now);
});
const auto done = [=](const Data::AllowedReactions &chosen) {
SaveAllowedReactions(_peer, chosen);
};
AddButtonWithCount(
_controls.buttonsLayout,
tr::lng_manage_peer_reactions(),
std::move(label),
[=] {
_navigation->parentController()->show(Box(
EditAllowedReactionsBox,
_navigation,
!_peer->isBroadcast(),
session->data().reactions().list(
Data::Reactions::Type::Active),
Data::PeerAllowedReactions(_peer),
done));
},
[=] { editReactions(); },
{ &st::menuIconGroupReactions });
}
if (canEditPermissions) {
@ -1277,6 +1269,61 @@ void Controller::fillManageSection() {
}
}
void Controller::editReactions() {
const auto done = [=](const Data::AllowedReactions &chosen) {
SaveAllowedReactions(_peer, chosen);
};
if (!_peer->isBroadcast()) {
_navigation->uiShow()->show(Box(
EditAllowedReactionsBox,
EditAllowedReactionsArgs{
.navigation = _navigation,
.isGroup = true,
.list = _navigation->session().data().reactions().list(
Data::Reactions::Type::Active),
.allowed = Data::PeerAllowedReactions(_peer),
.save = done,
}));
return;
}
if (_controls.levelRequested) {
return;
}
_controls.levelRequested = true;
_api.request(MTPpremium_GetBoostsStatus(
_peer->input
)).done([=](const MTPpremium_BoostsStatus &result) {
_controls.levelRequested = false;
const auto link = qs(result.data().vboost_url());
const auto weak = base::make_weak(_navigation->parentController());
auto counters = ParseBoostCounters(result);
counters.mine = 0; // Don't show current level as just-reached.
const auto askForBoosts = [=](int required) {
if (const auto strong = weak.get()) {
const auto openStatistics = [=, peer = _peer] {
strong->showSection(Info::Boosts::Make(peer));
};
strong->show(Box(Ui::AskBoostBox, Ui::AskBoostBoxData{
.link = link,
.boost = counters,
.reason = { Ui::AskBoostCustomReactions{ required } },
}, openStatistics, nullptr));
}
};
_navigation->uiShow()->show(Box(
EditAllowedReactionsBox,
EditAllowedReactionsArgs{
.navigation = _navigation,
.allowedCustomReactions = counters.level,
.list = _navigation->session().data().reactions().list(
Data::Reactions::Type::Active),
.allowed = Data::PeerAllowedReactions(_peer),
.askForBoosts = askForBoosts,
.save = done,
}));
}).send();
}
void Controller::fillPendingRequestsButton() {
auto pendingRequestsCount = Info::Profile::MigratedOrMeValue(
_peer

View file

@ -15,13 +15,16 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "data/data_peer.h"
#include "data/data_chat.h"
#include "data/data_channel.h"
#include "data/data_document.h"
#include "data/data_session.h"
#include "history/view/reactions/history_view_reactions_selector.h"
#include "main/main_session.h"
#include "apiwrap.h"
#include "lang/lang_keys.h"
#include "ui/boxes/boost_box.h"
#include "ui/widgets/fields/input_field.h"
#include "ui/controls/emoji_button.h"
#include "ui/layers/generic_box.h"
#include "ui/text/text_utilities.h"
#include "ui/widgets/buttons.h"
#include "ui/widgets/checkbox.h"
#include "ui/wrap/slide_wrap.h"
@ -30,6 +33,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "styles/style_chat_helpers.h"
#include "styles/style_info.h"
#include "styles/style_settings.h"
#include "styles/style_layers.h"
#include <QtWidgets/QTextEdit>
#include <QtGui/QTextBlock>
@ -37,6 +41,70 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
namespace {
constexpr auto kDisabledEmojiOpacity = 0.4;
struct UniqueCustomEmojiContext {
std::vector<DocumentId> ids;
};
class MaybeDisabledEmoji final : public Ui::Text::CustomEmoji {
public:
MaybeDisabledEmoji(
std::unique_ptr<CustomEmoji> wrapped,
Fn<bool()> enabled);
int width() override;
QString entityData() override;
void paint(QPainter &p, const Context &context) override;
void unload() override;
bool ready() override;
bool readyInDefaultState() override;
private:
const std::unique_ptr<Ui::Text::CustomEmoji> _wrapped;
const Fn<bool()> _enabled;
};
MaybeDisabledEmoji::MaybeDisabledEmoji(
std::unique_ptr<CustomEmoji> wrapped,
Fn<bool()> enabled)
: _wrapped(std::move(wrapped))
, _enabled(std::move(enabled)) {
}
int MaybeDisabledEmoji::width() {
return _wrapped->width();
}
QString MaybeDisabledEmoji::entityData() {
return _wrapped->entityData();
}
void MaybeDisabledEmoji::paint(QPainter &p, const Context &context) {
const auto disabled = !_enabled();
const auto was = disabled ? p.opacity() : 1.;
if (disabled) {
p.setOpacity(kDisabledEmojiOpacity);
}
_wrapped->paint(p, context);
if (disabled) {
p.setOpacity(was);
}
}
void MaybeDisabledEmoji::unload() {
_wrapped->unload();
}
bool MaybeDisabledEmoji::ready() {
return _wrapped->ready();
}
bool MaybeDisabledEmoji::readyInDefaultState() {
return _wrapped->readyInDefaultState();
}
[[nodiscard]] QString AllowOnlyCustomEmojiProcessor(QStringView mimeTag) {
auto all = TextUtilities::SplitTags(mimeTag);
for (auto i = all.begin(); i != all.end();) {
@ -78,9 +146,11 @@ namespace {
Unexpected("Action in MimeData hook.");
}
struct UniqueCustomEmojiContext {
base::flat_set<uint64> ids;
};
[[nodiscard]] std::vector<Data::ReactionId> DefaultSelected() {
const auto like = QString::fromUtf8("\xf0\x9f\x91\x8d");
const auto dislike = QString::fromUtf8("\xf0\x9f\x91\x8e");
return { Data::ReactionId{ like }, Data::ReactionId{ dislike } };
}
[[nodiscard]] bool RemoveNonCustomEmojiFragment(
not_null<QTextDocument*> document,
@ -100,10 +170,12 @@ struct UniqueCustomEmojiContext {
break;
}
const auto id = format.property(Ui::InputField::kCustomEmojiId);
if (!context.ids.emplace(id.toULongLong()).second) {
const auto documentId = id.toULongLong();
if (ranges::contains(context.ids, documentId)) {
removeTill += fragment.length();
break;
}
context.ids.push_back(documentId);
}
while (removeTill == removeFrom) {
block = block.next();
@ -131,7 +203,9 @@ bool RemoveNonCustomEmoji(
return true;
}
void SetupOnlyCustomEmojiField(not_null<Ui::InputField*> field) {
void SetupOnlyCustomEmojiField(
not_null<Ui::InputField*> field,
Fn<void(std::vector<DocumentId>)> callback) {
field->setTagMimeProcessor(AllowOnlyCustomEmojiProcessor);
field->setMimeDataHook(AllowOnlyCustomEmojiMimeDataHook);
@ -164,12 +238,54 @@ void SetupOnlyCustomEmojiField(not_null<Ui::InputField*> field) {
document->setPageSize(pageSize);
}
}
callback(context.ids);
if (changed) {
field->forceProcessContentsChanges();
}
}, field->lifetime());
}
[[nodiscard]] TextWithTags ComposeEmojiList(
not_null<Data::Reactions*> reactions,
const std::vector<Data::ReactionId> &list) {
auto result = TextWithTags();
const auto size = [&] {
return int(result.text.size());
};
auto added = base::flat_set<Data::ReactionId>();
const auto &all = reactions->list(Data::Reactions::Type::All);
const auto add = [&](Data::ReactionId id) {
if (!added.emplace(id).second) {
return;
}
auto unifiedId = id.custom();
const auto offset = size();
if (unifiedId) {
result.text.append('@');
} else {
result.text.append(id.emoji());
const auto i = ranges::find(all, id, &Data::Reaction::id);
if (i == end(all)) {
return;
}
unifiedId = i->selectAnimation->id;
}
const auto data = Data::SerializeCustomEmojiId(unifiedId);
const auto tag = Ui::InputField::CustomEmojiLink(data);
result.tags.append({ offset, size() - offset, tag });
};
for (const auto &id : list) {
add(id);
}
return result;
}
enum class ReactionsSelectorState {
Active,
Disabled,
Hidden,
};
struct ReactionsSelectorArgs {
not_null<QWidget*> outer;
not_null<Window::SessionController*> controller;
@ -177,13 +293,16 @@ struct ReactionsSelectorArgs {
std::vector<Data::Reaction> list;
std::vector<Data::ReactionId> selected;
Fn<void(std::vector<Data::ReactionId>)> callback;
rpl::producer<> focusRequests;
rpl::producer<ReactionsSelectorState> stateValue;
int customAllowed = 0;
bool all = false;
};
object_ptr<Ui::RpWidget> AddReactionsSelector(
not_null<Ui::RpWidget*> parent,
ReactionsSelectorArgs &&args) {
using namespace ChatHelpers;
using HistoryView::Reactions::UnifiedFactoryOwner;
auto result = object_ptr<Ui::InputField>(
parent,
@ -191,23 +310,111 @@ object_ptr<Ui::RpWidget> AddReactionsSelector(
Ui::InputField::Mode::MultiLine,
std::move(args.title));
const auto raw = result.data();
const auto session = &args.controller->session();
const auto owner = &session->data();
const auto reactions = &owner->reactions();
const auto customAllowed = args.customAllowed;
struct State {
std::unique_ptr<Ui::RpWidget> overlay;
std::unique_ptr<UnifiedFactoryOwner> unifiedFactoryOwner;
UnifiedFactoryOwner::RecentFactory factory;
base::flat_set<DocumentId> allowed;
rpl::lifetime focusLifetime;
};
const auto state = raw->lifetime().make_state<State>();
state->unifiedFactoryOwner = std::make_unique<UnifiedFactoryOwner>(
session,
reactions->list(Data::Reactions::Type::Active));
state->factory = state->unifiedFactoryOwner->factory();
const auto customEmojiPaused = [controller = args.controller] {
return controller->isGifPausedAtLeastFor(PauseReason::Layer);
};
raw->setCustomEmojiFactory(
args.controller->session().data().customEmojiManager().factory(),
std::move(customEmojiPaused));
raw->setCustomEmojiFactory([=](QStringView data, Fn<void()> update)
-> std::unique_ptr<Ui::Text::CustomEmoji> {
const auto id = Data::ParseCustomEmojiData(data);
auto result = owner->customEmojiManager().create(
data,
std::move(update));
if (state->unifiedFactoryOwner->lookupReactionId(id).custom()) {
return std::make_unique<MaybeDisabledEmoji>(
std::move(result),
[=] { return state->allowed.contains(id); });
}
using namespace Ui::Text;
return std::make_unique<FirstFrameEmoji>(std::move(result));
}, std::move(customEmojiPaused));
SetupOnlyCustomEmojiField(raw);
const auto callback = args.callback;
SetupOnlyCustomEmojiField(raw, [=](std::vector<DocumentId> ids) {
auto allowed = base::flat_set<DocumentId>();
auto reactions = std::vector<Data::ReactionId>();
reactions.reserve(ids.size());
allowed.reserve(std::min(customAllowed, int(ids.size())));
const auto owner = state->unifiedFactoryOwner.get();
for (const auto id : ids) {
const auto reactionId = owner->lookupReactionId(id);
if (reactionId.custom() && allowed.size() < customAllowed) {
allowed.emplace(id);
}
reactions.push_back(reactionId);
}
if (state->allowed != allowed) {
state->allowed = std::move(allowed);
raw->rawTextEdit()->update();
}
callback(std::move(reactions));
});
raw->setTextWithTags(ComposeEmojiList(reactions, args.selected));
std::move(args.focusRequests) | rpl::start_with_next([=] {
raw->setFocusFast();
using SelectorState = ReactionsSelectorState;
std::move(
args.stateValue
) | rpl::start_with_next([=](SelectorState value) {
switch (value) {
case SelectorState::Active:
state->overlay = nullptr;
state->focusLifetime.destroy();
if (raw->empty()) {
raw->setTextWithTags(
ComposeEmojiList(reactions, DefaultSelected()));
}
raw->setDisabled(false);
raw->setFocusFast();
break;
case SelectorState::Disabled:
state->overlay = std::make_unique<Ui::RpWidget>(parent);
state->overlay->show();
raw->geometryValue() | rpl::start_with_next([=](QRect rect) {
state->overlay->setGeometry(rect);
}, state->overlay->lifetime());
state->overlay->paintRequest() | rpl::start_with_next([=](QRect clip) {
auto color = st::boxBg->c;
color.setAlphaF(0.5);
QPainter(state->overlay.get()).fillRect(
clip,
color);
}, state->overlay->lifetime());
[[fallthrough]];
case SelectorState::Hidden:
if (Ui::InFocusChain(raw)) {
raw->parentWidget()->setFocus();
}
raw->setDisabled(true);
raw->focusedChanges(
) | rpl::start_with_next([=](bool focused) {
if (focused) {
raw->parentWidget()->setFocus();
}
}, state->focusLifetime);
break;
}
}, raw->lifetime());
const auto toggle = Ui::CreateChild<Ui::EmojiButton>(
const auto toggle = Ui::CreateChild<Ui::IconButton>(
parent.get(),
st::boxAttachEmoji);
st::manageGroupReactions);
const auto panel = Ui::CreateChild<TabbedPanel>(
args.outer.get(),
@ -216,7 +423,11 @@ object_ptr<Ui::RpWidget> AddReactionsSelector(
nullptr,
args.controller->uiShow(),
Window::GifPauseReason::Layer,
TabbedSelector::Mode::EmojiOnly));
(args.all
? TabbedSelector::Mode::FullReactions
: TabbedSelector::Mode::RecentReactions)));
panel->selector()->provideRecentEmoji(
state->unifiedFactoryOwner->unifiedIdsList());
panel->setDesiredHeightValues(
1.,
st::emojiPanMinHeight / 2,
@ -247,7 +458,13 @@ object_ptr<Ui::RpWidget> AddReactionsSelector(
}
return base::EventFilterResult::Continue;
};
base::install_event_filter(args.outer, filterCallback);
for (auto widget = (QWidget*)raw
; widget && widget != args.outer
; widget = widget->parentWidget()) {
base::install_event_filter(raw, widget, filterCallback);
}
base::install_event_filter(raw, args.outer, filterCallback);
scheduleUpdateEmojiPanelGeometry();
toggle->installEventFilter(panel);
toggle->addClickHandler([=] {
@ -264,33 +481,105 @@ object_ptr<Ui::RpWidget> AddReactionsSelector(
return result;
}
void AddReactionsText(
not_null<Ui::VerticalLayout*> container,
not_null<Window::SessionNavigation*> navigation,
int allowedCustomReactions,
rpl::producer<int> customCountValue,
Fn<void(int required)> askForBoosts) {
auto ownedInner = object_ptr<Ui::VerticalLayout>(container);
const auto inner = ownedInner.data();
const auto count = inner->lifetime().make_state<rpl::variable<int>>(
std::move(customCountValue));
auto outer = container->add(
object_ptr<Ui::DividerLabel>(
container,
std::move(ownedInner),
st::defaultBoxDividerLabelPadding),
QMargins(0, st::manageGroupReactionsTextSkip, 0, 0));
const auto label = inner->add(
object_ptr<Ui::FlatLabel>(
inner,
tr::lng_manage_peer_reactions_own(
lt_link,
tr::lng_manage_peer_reactions_own_link(
) | Ui::Text::ToLink(),
Ui::Text::WithEntities),
st::boxDividerLabel));
const auto weak = base::make_weak(navigation);
label->setClickHandlerFilter([=](const auto &...) {
if (const auto strong = weak.get()) {
using Info = Window::SessionNavigation::PeerByLinkInfo;
strong->showPeerByLink(Info{
.usernameOrId = u"stickers"_q,
.resolveType = Window::ResolveType::Mention,
});
}
return false;
});
auto countString = count->value() | rpl::map([](int count) {
return TextWithEntities{ QString::number(count) };
});
auto needs = rpl::combine(
tr::lng_manage_peer_reactions_level(
lt_count,
count->value() | tr::to_count(),
lt_same_count,
std::move(countString),
Ui::Text::RichLangValue),
tr::lng_manage_peer_reactions_boost(
lt_link,
tr::lng_manage_peer_reactions_boost_link() | Ui::Text::ToLink(),
Ui::Text::RichLangValue)
) | rpl::map([](TextWithEntities &&a, TextWithEntities &&b) {
a.append(' ').append(std::move(b));
return std::move(a);
});
const auto wrap = inner->add(
object_ptr<Ui::SlideWrap<Ui::FlatLabel>>(
inner,
object_ptr<Ui::FlatLabel>(
inner,
std::move(needs),
st::boxDividerLabel),
QMargins{ 0, st::normalFont->height, 0, 0 }));
wrap->toggleOn(count->value() | rpl::map(
rpl::mappers::_1 > allowedCustomReactions
));
wrap->finishAnimating();
wrap->entity()->setClickHandlerFilter([=](const auto &...) {
askForBoosts(count->current());
return false;
});
}
} // namespace
void EditAllowedReactionsBox(
not_null<Ui::GenericBox*> box,
not_null<Window::SessionNavigation*> navigation,
bool isGroup,
const std::vector<Data::Reaction> &list,
const Data::AllowedReactions &allowed,
Fn<void(const Data::AllowedReactions &)> callback) {
EditAllowedReactionsArgs &&args) {
using namespace Data;
using namespace rpl::mappers;
const auto iconHeight = st::editPeerReactionsPreview;
box->setTitle(tr::lng_manage_peer_reactions());
box->setWidth(st::boxWideWidth);
enum class Option {
All,
Some,
None,
};
using SelectorState = ReactionsSelectorState;
struct State {
base::flat_map<ReactionId, not_null<Ui::SettingsButton*>> toggles;
rpl::variable<Option> option; // For groups.
rpl::variable<bool> anyToggled; // For channels.
rpl::event_stream<bool> forceToggleAll; // For channels.
rpl::event_stream<> focusRequests;
rpl::variable<SelectorState> selectorState;
std::vector<Data::ReactionId> selected;
rpl::variable<int> customCount;
};
const auto allowed = args.allowed;
const auto optionInitial = (allowed.type != AllowedReactionsType::Some)
? Option::All
: allowed.some.empty()
@ -298,58 +587,24 @@ void EditAllowedReactionsBox(
: Option::Some;
const auto state = box->lifetime().make_state<State>(State{
.option = optionInitial,
.anyToggled = (optionInitial != Option::None),
});
const auto collect = [=] {
auto result = AllowedReactions();
if (!isGroup || state->option.current() == Option::Some) {
result.some.reserve(state->toggles.size());
for (const auto &[id, button] : state->toggles) {
if (button->toggled()) {
result.some.push_back(id);
}
}
}
result.type = isGroup
? (state->option.current() != Option::All
? AllowedReactionsType::Some
: AllowedReactionsType::All)
: (result.some.size() == state->toggles.size())
? AllowedReactionsType::Default
: AllowedReactionsType::Some;
return result;
};
const auto container = box->verticalLayout();
const auto isGroup = args.isGroup;
const auto enabled = isGroup
? nullptr
: container->add(object_ptr<Ui::SettingsButton>(
container,
container.get(),
tr::lng_manage_peer_reactions_enable(),
st::manageGroupButton.button));
if (enabled && !list.empty()) {
AddReactionAnimatedIcon(
enabled,
enabled->sizeValue(
) | rpl::map([=](const QSize &size) {
return QPoint(
st::manageGroupButton.iconPosition.x(),
(size.height() - iconHeight) / 2);
}),
iconHeight,
list.front(),
rpl::never<>(),
rpl::never<>(),
&enabled->lifetime());
}
st::manageGroupNoIconButton.button));
if (enabled) {
enabled->toggleOn(state->anyToggled.value());
enabled->toggledChanges(
) | rpl::filter([=](bool value) {
return (value != state->anyToggled.current());
}) | rpl::start_to_stream(state->forceToggleAll, enabled->lifetime());
enabled->toggleOn(rpl::single(optionInitial != Option::None));
enabled->toggledValue(
) | rpl::start_with_next([=](bool value) {
state->selectorState = value
? SelectorState::Active
: SelectorState::Disabled;
}, enabled->lifetime());
}
const auto group = std::make_shared<Ui::RadioenumGroup<Option>>(
state->option.current());
@ -395,44 +650,100 @@ void EditAllowedReactionsBox(
container,
object_ptr<Ui::VerticalLayout>(container)));
if (wrap) {
wrap->toggleOn(state->option.value() | rpl::map(_1 == Option::Some));
wrap->toggleOn(state->option.value(
) | rpl::map(_1 == Option::Some) | rpl::before_next([=](bool some) {
if (!some) {
state->selectorState = SelectorState::Hidden;
}
}) | rpl::after_next([=](bool some) {
if (some) {
state->selectorState = SelectorState::Active;
}
}));
wrap->finishAnimating();
}
const auto reactions = wrap ? wrap->entity() : container.get();
Ui::AddSkip(reactions);
const auto like = QString::fromUtf8("\xf0\x9f\x91\x8d");
const auto dislike = QString::fromUtf8("\xf0\x9f\x91\x8e");
auto selected = allowed.some;
if (selected.empty()) {
selected.push_back(Data::ReactionId(like));
selected.push_back(Data::ReactionId(dislike));
}
const auto all = args.list;
auto selected = (allowed.type != AllowedReactionsType::Some)
? (all
| ranges::views::transform(&Data::Reaction::id)
| ranges::to_vector)
: allowed.some;
const auto changed = [=](std::vector<Data::ReactionId> chosen) {
state->selected = std::move(chosen);
state->customCount = ranges::count_if(
state->selected,
&Data::ReactionId::custom);
};
changed(selected.empty() ? DefaultSelected() : std::move(selected));
reactions->add(AddReactionsSelector(reactions, {
.outer = box->getDelegate()->outerContainer(),
.controller = navigation->parentController(),
.controller = args.navigation->parentController(),
.title = (enabled
? tr::lng_manage_peer_reactions_available()
: tr::lng_manage_peer_reactions_some_title()),
.list = list,
.selected = std::move(selected),
.list = all,
.selected = state->selected,
.callback = changed,
.focusRequests = state->focusRequests.events(),
.stateValue = state->selectorState.value(),
.customAllowed = args.allowedCustomReactions,
.all = !args.isGroup,
}), st::boxRowPadding);
box->setFocusCallback([=] {
if (!wrap || state->option.current() == Option::Some) {
state->focusRequests.fire({});
state->selectorState.force_assign(SelectorState::Active);
}
});
if (!isGroup) {
AddReactionsText(
container,
args.navigation,
args.allowedCustomReactions,
state->customCount.value(),
args.askForBoosts);
}
const auto total = int(all.size());
const auto collect = [=] {
auto result = AllowedReactions();
if (isGroup
? (state->option.current() == Option::Some)
: (enabled->toggled())) {
result.some = state->selected;
}
auto some = result.some;
auto simple = all | ranges::views::transform(
&Data::Reaction::id
) | ranges::to_vector;
ranges::sort(some);
ranges::sort(simple);
result.type = isGroup
? (state->option.current() != Option::All
? AllowedReactionsType::Some
: AllowedReactionsType::All)
: (some == simple)
? AllowedReactionsType::Default
: AllowedReactionsType::Some;
return result;
};
box->addButton(tr::lng_settings_save(), [=] {
const auto result = collect();
if (!isGroup) {
const auto custom = ranges::count_if(
result.some,
&Data::ReactionId::custom);
if (custom > args.allowedCustomReactions) {
args.askForBoosts(custom);
return;
}
}
box->closeBox();
callback(result);
args.save(result);
});
box->addButton(tr::lng_cancel(), [=] {
box->closeBox();

View file

@ -7,7 +7,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#pragma once
class PeerData;
#include "data/data_peer.h"
namespace Data {
struct Reaction;
@ -22,13 +22,19 @@ namespace Window {
class SessionNavigation;
} // namespace Window
struct EditAllowedReactionsArgs {
not_null<Window::SessionNavigation*> navigation;
int allowedCustomReactions = 0;
bool isGroup = false;
std::vector<Data::Reaction> list;
Data::AllowedReactions allowed;
Fn<void(int required)> askForBoosts;
Fn<void(const Data::AllowedReactions &)> save;
};
void EditAllowedReactionsBox(
not_null<Ui::GenericBox*> box,
not_null<Window::SessionNavigation*> navigation,
bool isGroup,
const std::vector<Data::Reaction> &list,
const Data::AllowedReactions &allowed,
Fn<void(const Data::AllowedReactions &)> callback);
EditAllowedReactionsArgs &&args);
void SaveAllowedReactions(
not_null<PeerData*> peer,

View file

@ -785,6 +785,10 @@ void EmojiListWidget::unloadCustomIn(const SectionInfo &info) {
object_ptr<TabbedSelector::InnerFooter> EmojiListWidget::createFooter() {
Expects(_footer == nullptr);
if (_mode == EmojiListMode::RecentReactions) {
return { nullptr };
}
using FooterDescriptor = StickersListFooter::Descriptor;
const auto flag = powerSavingFlag();
const auto footerPaused = [method = pausedMethod(), flag]() {

View file

@ -331,8 +331,12 @@ TabbedSelector::TabbedSelector(
Mode mode)
: TabbedSelector(parent, {
.show = std::move(show),
.st = ((mode == Mode::EmojiStatus || mode == Mode::BackgroundEmoji)
.st = ((mode == Mode::EmojiStatus
|| mode == Mode::BackgroundEmoji
|| mode == Mode::FullReactions)
? st::statusEmojiPan
: (mode == Mode::RecentReactions)
? st::backgroundEmojiPan
: st::defaultEmojiPan),
.level = level,
.mode = mode,
@ -385,7 +389,11 @@ TabbedSelector::TabbedSelector(
resize(st::emojiPanWidth, st::emojiPanMaxHeight);
for (auto &tab : _tabs) {
tab.footer()->hide();
if (tab.hasFooter()) {
tab.footer()->hide();
} else {
_noFooter = true;
}
tab.widget()->hide();
}
if (tabbed()) {
@ -515,6 +523,10 @@ TabbedSelector::Tab TabbedSelector::createTab(SelectorTab type, int index) {
? EmojiMode::EmojiStatus
: _mode == Mode::BackgroundEmoji
? EmojiMode::BackgroundEmoji
: _mode == Mode::FullReactions
? EmojiMode::FullReactions
: _mode == Mode::RecentReactions
? EmojiMode::RecentReactions
: EmojiMode::Full),
.customTextColor = _customTextColor,
.paused = paused,
@ -694,10 +706,16 @@ void TabbedSelector::updateScrollGeometry(QSize oldSize) {
}
void TabbedSelector::updateFooterGeometry() {
_footerTop = _dropDown ? 0 : (height() - _st.footer);
_footerTop = _dropDown
? 0
: _noFooter
? (height() - _roundRadius)
: (height() - _st.footer);
for (auto &tab : _tabs) {
tab.footer()->resizeToWidth(width());
tab.footer()->moveToLeft(0, _footerTop);
if (tab.hasFooter()) {
tab.footer()->resizeToWidth(width());
tab.footer()->moveToLeft(0, _footerTop);
}
}
}
@ -767,7 +785,7 @@ void TabbedSelector::paintContent(QPainter &p) {
0,
_footerTop,
width(),
_st.footer);
_noFooter ? _roundRadius : _st.footer);
Ui::FillRoundRect(p, footerPart, footerBg, {
.p = {
_dropDown ? pixmaps.p[0] : QPixmap(),
@ -802,7 +820,7 @@ void TabbedSelector::paintContent(QPainter &p) {
}
int TabbedSelector::marginTop() const {
return _dropDown
return (_dropDown && !_noFooter)
? _st.footer
: _tabsSlider
? (_tabsSlider->height() - st::lineWidth)
@ -810,15 +828,19 @@ int TabbedSelector::marginTop() const {
}
int TabbedSelector::scrollTop() const {
return tabbed() ? marginTop() : _dropDown ? _st.footer : 0;
return tabbed()
? marginTop()
: (_dropDown && !_noFooter)
? _st.footer
: 0;
}
int TabbedSelector::marginBottom() const {
return _dropDown ? _roundRadius : _st.footer;
return (_dropDown || _noFooter) ? _roundRadius : _st.footer;
}
int TabbedSelector::scrollBottom() const {
return _dropDown ? 0 : marginBottom();
return (_dropDown || _noFooter) ? 0 : marginBottom();
}
void TabbedSelector::refreshStickers() {
@ -1013,7 +1035,9 @@ void TabbedSelector::showAll() {
if (isRestrictedView()) {
_restrictedLabel->show();
} else {
currentTab()->footer()->show();
if (currentTab()->hasFooter()) {
currentTab()->footer()->show();
}
_scroll->show();
_bottomShadow->setVisible(_mode == Mode::EmojiStatus);
}
@ -1100,7 +1124,7 @@ void TabbedSelector::fillTabsSliderSections() {
}
bool TabbedSelector::hasSectionIcons() const {
return !_restrictedLabel;
return !_restrictedLabel && !_noFooter;
}
void TabbedSelector::switchTab() {
@ -1125,7 +1149,9 @@ void TabbedSelector::switchTab() {
auto widget = _scroll->takeWidget<Inner>();
widget->setParent(this);
widget->hide();
currentTab()->footer()->hide();
if (currentTab()->hasFooter()) {
currentTab()->footer()->hide();
}
currentTab()->returnWidget(std::move(widget));
_currentTabType = newTabType;

View file

@ -82,6 +82,8 @@ enum class TabbedSelectorMode {
MediaEditor,
EmojiStatus,
BackgroundEmoji,
FullReactions,
RecentReactions,
};
struct TabbedSelectorDescriptor {
@ -192,16 +194,19 @@ private:
object_ptr<Inner> takeWidget();
void returnWidget(object_ptr<Inner> widget);
SelectorTab type() const {
[[nodiscard]] SelectorTab type() const {
return _type;
}
int index() const {
[[nodiscard]] int index() const {
return _index;
}
Inner *widget() const {
[[nodiscard]] Inner *widget() const {
return _weak;
}
not_null<InnerFooter*> footer() const {
[[nodiscard]] bool hasFooter() const {
return _footer != nullptr;
}
[[nodiscard]] not_null<InnerFooter*> footer() const {
return _footer;
}
@ -209,7 +214,7 @@ private:
void saveScrollTop(int scrollTop) {
_scrollTop = scrollTop;
}
int getScrollTop() const {
[[nodiscard]] int getScrollTop() const {
return _scrollTop;
}
@ -279,6 +284,7 @@ private:
Mode _mode = Mode::Full;
int _roundRadius = 0;
int _footerTop = 0;
bool _noFooter = false;
Ui::CornersPixmaps _panelRounding;
Ui::CornersPixmaps _categoriesRounding;
PeerData *_currentPeer = nullptr;

View file

@ -7,6 +7,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#pragma once
#include "base/qt/qt_compare.h"
namespace Data {
struct ReactionId {
@ -24,6 +26,13 @@ struct ReactionId {
const auto custom = std::get_if<DocumentId>(&data);
return custom ? *custom : DocumentId();
}
friend inline auto operator<=>(
const ReactionId &,
const ReactionId &) = default;
friend inline bool operator==(
const ReactionId &a,
const ReactionId &b) = default;
};
struct MessageReaction {
@ -32,13 +41,6 @@ struct MessageReaction {
bool my = false;
};
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]] QString ReactionEntityData(const ReactionId &id);
[[nodiscard]] ReactionId ReactionFromMTP(const MTPReaction &reaction);

View file

@ -109,6 +109,87 @@ bool StripEmoji::readyInDefaultState() {
} // namespace
UnifiedFactoryOwner::UnifiedFactoryOwner(
not_null<Main::Session*> session,
const std::vector<Data::Reaction> &reactions,
Strip *strip)
: _session(session)
, _strip(strip) {
auto index = 0;
const auto inStrip = _strip ? _strip->count() : 0;
_unifiedIdsList.reserve(reactions.size());
for (const auto &reaction : reactions) {
if (const auto id = reaction.id.custom()) {
_unifiedIdsList.push_back(id);
} else {
_unifiedIdsList.push_back(reaction.selectAnimation->id);
}
const auto unifiedId = _unifiedIdsList.back();
if (!reaction.id.custom()) {
_defaultReactionIds.emplace(unifiedId, reaction.id.emoji());
}
if (index + 1 < inStrip) {
_defaultReactionInStripMap.emplace(unifiedId, index++);
}
}
_stripPaintOneShift = [&] {
// See EmojiListWidget custom emoji position resolving.
const auto size = st::reactStripSize;
const auto area = st::emojiPanArea;
const auto areaPosition = QPoint(
(size - area.width()) / 2,
(size - area.height()) / 2);
const auto esize = Ui::Emoji::GetSizeLarge() / style::DevicePixelRatio();
const auto innerPosition = QPoint(
(area.width() - esize) / 2,
(area.height() - esize) / 2);
const auto customSize = Ui::Text::AdjustCustomEmojiSize(esize);
const auto customSkip = (esize - customSize) / 2;
const auto customPosition = QPoint(customSkip, customSkip);
return areaPosition + innerPosition + customPosition;
}();
_defaultReactionShift = QPoint(
(st::reactStripSize - st::reactStripImage) / 2,
(st::reactStripSize - st::reactStripImage) / 2
) - _stripPaintOneShift;
}
Data::ReactionId UnifiedFactoryOwner::lookupReactionId(
DocumentId unifiedId) const {
const auto i = _defaultReactionIds.find(unifiedId);
return (i != end(_defaultReactionIds))
? Data::ReactionId{ i->second }
: Data::ReactionId{ unifiedId };
}
UnifiedFactoryOwner::RecentFactory UnifiedFactoryOwner::factory() {
return [=](DocumentId id, Fn<void()> repaint)
-> std::unique_ptr<Ui::Text::CustomEmoji> {
const auto tag = Data::CustomEmojiManager::SizeTag::Large;
const auto sizeOverride = st::reactStripImage;
const auto isDefaultReaction = _defaultReactionIds.contains(id);
const auto manager = &_session->data().customEmojiManager();
auto result = isDefaultReaction
? std::make_unique<Ui::Text::ShiftedEmoji>(
manager->create(id, std::move(repaint), tag, sizeOverride),
_defaultReactionShift)
: manager->create(id, std::move(repaint), tag);
const auto i = _defaultReactionInStripMap.find(id);
if (i != end(_defaultReactionInStripMap)) {
Assert(_strip != nullptr);
return std::make_unique<StripEmoji>(
std::move(result),
_strip,
-_stripPaintOneShift,
i->second);
}
return result;
};
}
Selector::Selector(
not_null<QWidget*> parent,
const style::EmojiPan &st,
@ -749,65 +830,10 @@ void Selector::cacheExpandIcon() {
void Selector::createList() {
using namespace ChatHelpers;
auto recent = _recent;
auto defaultReactionIds = base::flat_map<DocumentId, QString>();
if (_strip) {
recent.reserve(recentCount());
auto index = 0;
const auto inStrip = _strip->count();
for (const auto &reaction : _reactions.recent) {
if (const auto id = reaction.id.custom()) {
recent.push_back(id);
} else {
recent.push_back(reaction.selectAnimation->id);
defaultReactionIds.emplace(recent.back(), reaction.id.emoji());
}
if (index + 1 < inStrip) {
_defaultReactionInStripMap.emplace(recent.back(), index++);
}
};
}
const auto manager = &_show->session().data().customEmojiManager();
_stripPaintOneShift = [&] {
// See EmojiListWidget custom emoji position resolving.
const auto area = st::emojiPanArea;
const auto areaPosition = QPoint(
(_size - area.width()) / 2,
(_size - area.height()) / 2);
const auto esize = Ui::Emoji::GetSizeLarge() / style::DevicePixelRatio();
const auto innerPosition = QPoint(
(area.width() - esize) / 2,
(area.height() - esize) / 2);
const auto customSize = Ui::Text::AdjustCustomEmojiSize(esize);
const auto customSkip = (esize - customSize) / 2;
const auto customPosition = QPoint(customSkip, customSkip);
return areaPosition + innerPosition + customPosition;
}();
_defaultReactionShift = QPoint(
(_size - st::reactStripImage) / 2,
(_size - st::reactStripImage) / 2
) - _stripPaintOneShift;
auto factory = [=](DocumentId id, Fn<void()> repaint)
-> std::unique_ptr<Ui::Text::CustomEmoji> {
const auto tag = Data::CustomEmojiManager::SizeTag::Large;
const auto sizeOverride = st::reactStripImage;
const auto isDefaultReaction = defaultReactionIds.contains(id);
auto result = isDefaultReaction
? std::make_unique<Ui::Text::ShiftedEmoji>(
manager->create(id, std::move(repaint), tag, sizeOverride),
_defaultReactionShift)
: manager->create(id, std::move(repaint), tag);
const auto i = _defaultReactionInStripMap.find(id);
if (i != end(_defaultReactionInStripMap)) {
Assert(_strip != nullptr);
return std::make_unique<StripEmoji>(
std::move(result),
_strip.get(),
-_stripPaintOneShift,
i->second);
}
return result;
};
_unifiedFactoryOwner = std::make_unique<UnifiedFactoryOwner>(
&_show->session(),
_strip ? _reactions.recent : std::vector<Data::Reaction>(),
_strip.get());
_scroll = Ui::CreateChild<Ui::ScrollArea>(this, st::reactPanelScroll);
_scroll->hide();
@ -821,8 +847,10 @@ void Selector::createList() {
.show = _show,
.mode = _listMode,
.paused = [] { return false; },
.customRecentList = std::move(recent),
.customRecentFactory = std::move(factory),
.customRecentList = (_strip
? _unifiedFactoryOwner->unifiedIdsList()
: _recent),
.customRecentFactory = _unifiedFactoryOwner->factory(),
.st = st,
})
).data();
@ -831,13 +859,8 @@ void Selector::createList() {
_list->customChosen(
) | rpl::start_with_next([=](ChatHelpers::FileChosen data) {
const auto id = DocumentId{ data.document->id };
const auto i = defaultReactionIds.find(id);
const auto reactionId = (i != end(defaultReactionIds))
? Data::ReactionId{ i->second }
: Data::ReactionId{ id };
_chosen.fire({
.id = reactionId,
.id = _unifiedFactoryOwner->lookupReactionId(data.document->id),
.icon = data.messageSendingFrom.frame,
.globalGeometry = data.messageSendingFrom.globalStartGeometry,
});

View file

@ -16,6 +16,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "ui/rp_widget.h"
namespace Data {
struct Reaction;
struct ReactionId;
} // namespace Data
@ -39,6 +40,39 @@ class PlainShadow;
namespace HistoryView::Reactions {
class UnifiedFactoryOwner final {
public:
using RecentFactory = Fn<std::unique_ptr<Ui::Text::CustomEmoji>(
DocumentId,
Fn<void()>)>;
UnifiedFactoryOwner(
not_null<Main::Session*> session,
const std::vector<Data::Reaction> &reactions,
Strip *strip = nullptr);
[[nodiscard]] const std::vector<DocumentId> &unifiedIdsList() const {
return _unifiedIdsList;
}
[[nodiscard]] Data::ReactionId lookupReactionId(
DocumentId unifiedId) const;
[[nodiscard]] RecentFactory factory();
private:
const not_null<Main::Session*> _session;
Strip *_strip = nullptr;
std::vector<DocumentId> _unifiedIdsList;
base::flat_map<DocumentId, QString> _defaultReactionIds;
base::flat_map<DocumentId, int> _defaultReactionInStripMap;
QPoint _defaultReactionShift;
QPoint _stripPaintOneShift;
};
class Selector final : public Ui::RpWidget {
public:
Selector(
@ -146,10 +180,7 @@ private:
const std::vector<DocumentId> _recent;
const ChatHelpers::EmojiListMode _listMode;
Fn<void()> _jumpedToPremium;
base::flat_map<DocumentId, int> _defaultReactionInStripMap;
Ui::RoundAreaWithShadow _cachedRound;
QPoint _defaultReactionShift;
QPoint _stripPaintOneShift;
std::unique_ptr<Strip> _strip;
rpl::event_stream<ChosenReaction> _chosen;
@ -160,6 +191,7 @@ private:
Ui::ScrollArea *_scroll = nullptr;
ChatHelpers::EmojiListWidget *_list = nullptr;
ChatHelpers::StickersListFooter *_footer = nullptr;
std::unique_ptr<UnifiedFactoryOwner> _unifiedFactoryOwner;
Ui::PlainShadow *_shadow = nullptr;
rpl::variable<int> _shadowTop = 0;
rpl::variable<int> _shadowSkip = 0;
@ -175,7 +207,7 @@ private:
QMargins _padding;
int _specialExpandTopSkip = 0;
int _collapsedTopSkip = 0;
int _size = 0;
const int _size = 0;
int _recentRows = 0;
int _columns = 0;
int _skipx = 0;

View file

@ -601,10 +601,19 @@ manageDeleteGroupButton: SettingsCountButton(manageGroupNoIconButton) {
}
}
manageGroupReactions: IconButton(defaultIconButton) {
width: 24px;
height: 36px;
icon: icon{{ "info/edit/stickers_add", historyComposeIconFg }};
iconOver: icon{{ "info/edit/stickers_add", historyComposeIconFgOver }};
}
manageGroupReactionsField: InputField(defaultInputField) {
textMargins: margins(1px, 26px, 31px, 4px);
textMargins: margins(1px, 38px, 24px, 8px);
placeholderShift: -32px;
heightMin: 66px;
heightMax: 158px;
}
manageGroupReactionsTextSkip: 16px;
infoEmptyFg: windowSubTextFg;
infoEmptyPhoto: icon {{ "info/info_media_photo_empty", infoEmptyFg }};

View file

@ -467,12 +467,25 @@ void AskBoostBox(
box->addTopButton(st::boxTitleClose, [=] { box->closeBox(); });
auto title = tr::lng_boost_channel_title_color();
auto text = rpl::combine(
tr::lng_boost_channel_needs_level_color(
auto title = v::is<AskBoostChannelColor>(data.reason.data)
? tr::lng_boost_channel_title_color()
: tr::lng_boost_channel_title_reactions();
auto reasonText = v::match(data.reason.data, [&](
AskBoostChannelColor data) {
return tr::lng_boost_channel_needs_level_color(
lt_count,
rpl::single(float64(data.requiredLevel)),
Ui::Text::RichLangValue),
Ui::Text::RichLangValue);
}, [&](AskBoostCustomReactions data) {
return tr::lng_boost_channel_needs_level_reactions(
lt_count,
rpl::single(float64(data.count)),
lt_same_count,
rpl::single(TextWithEntities{ QString::number(data.count) }),
Ui::Text::RichLangValue);
});
auto text = rpl::combine(
std::move(reasonText),
tr::lng_boost_channel_ask(Ui::Text::RichLangValue)
) | rpl::map([](TextWithEntities &&text, TextWithEntities &&ask) {
return text.append(u"\n\n"_q).append(std::move(ask));

View file

@ -50,10 +50,24 @@ void GiftForBoostsBox(
void GiftedNoBoostsBox(not_null<GenericBox*> box);
void PremiumForBoostsBox(not_null<GenericBox*> box, Fn<void()> buyPremium);
struct AskBoostChannelColor {
int requiredLevel = 0;
};
struct AskBoostCustomReactions {
int count = 0;
};
struct AskBoostReason {
std::variant<
AskBoostChannelColor,
AskBoostCustomReactions> data;
};
struct AskBoostBoxData {
QString link;
BoostCounters boost;
int requiredLevel = 0;
AskBoostReason reason;
};
void AskBoostBox(