/* This file is part of Telegram Desktop, the official desktop application for the Telegram messaging service. For license and copyright information please follow this link: https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "boxes/peers/edit_peer_reactions.h" #include "base/event_filter.h" #include "chat_helpers/tabbed_panel.h" #include "chat_helpers/tabbed_selector.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/layers/generic_box.h" #include "ui/text/text_utilities.h" #include "ui/widgets/checkbox.h" #include "ui/wrap/slide_wrap.h" #include "ui/vertical_list.h" #include "window/window_session_controller.h" #include "window/window_session_controller_link_info.h" #include "styles/style_chat_helpers.h" #include "styles/style_info.h" #include "styles/style_settings.h" #include "styles/style_layers.h" #include #include #include namespace { constexpr auto kDisabledEmojiOpacity = 0.4; struct UniqueCustomEmojiContext { std::vector ids; Fn applyHardLimit; int hardLimit = 0; int hardLimitChecked = 0; bool hardLimitHit = false; }; class MaybeDisabledEmoji final : public Ui::Text::CustomEmoji { public: MaybeDisabledEmoji( std::unique_ptr wrapped, Fn 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 _wrapped; const Fn _enabled; }; MaybeDisabledEmoji::MaybeDisabledEmoji( std::unique_ptr wrapped, Fn 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();) { if (Ui::InputField::IsCustomEmojiLink(*i)) { ++i; } else { i = all.erase(i); } } return TextUtilities::JoinTag(all); } [[nodiscard]] bool AllowOnlyCustomEmojiMimeDataHook( not_null data, Ui::InputField::MimeAction action) { if (action == Ui::InputField::MimeAction::Check) { const auto textMime = TextUtilities::TagsTextMimeType(); const auto tagsMime = TextUtilities::TagsMimeType(); if (!data->hasFormat(textMime) || !data->hasFormat(tagsMime)) { return false; } auto text = QString::fromUtf8(data->data(textMime)); auto tags = TextUtilities::DeserializeTags( data->data(tagsMime), text.size()); auto checkedTill = 0; ranges::sort(tags, ranges::less(), &TextWithTags::Tag::offset); for (const auto &tag : tags) { if (tag.offset != checkedTill || AllowOnlyCustomEmojiProcessor(tag.id) != tag.id) { return false; } checkedTill += tag.length; } return true; } else if (action == Ui::InputField::MimeAction::Insert) { return false; } Unexpected("Action in MimeData hook."); } [[nodiscard]] std::vector 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 document, UniqueCustomEmojiContext &context) { context.ids.clear(); context.hardLimitChecked = 0; auto removeFrom = 0; auto removeTill = 0; auto block = document->begin(); for (auto j = block.begin(); !j.atEnd(); ++j) { const auto fragment = j.fragment(); Assert(fragment.isValid()); removeTill = removeFrom = fragment.position(); const auto format = fragment.charFormat(); if (format.objectType() != Ui::InputField::kCustomEmojiFormat) { removeTill += fragment.length(); break; } const auto id = format.property(Ui::InputField::kCustomEmojiId); const auto documentId = id.toULongLong(); const auto applyHardLimit = context.applyHardLimit(documentId); if (ranges::contains(context.ids, documentId)) { removeTill += fragment.length(); break; } else if (applyHardLimit && context.hardLimitChecked >= context.hardLimit) { context.hardLimitHit = true; removeTill += fragment.length(); break; } context.ids.push_back(documentId); if (applyHardLimit) { ++context.hardLimitChecked; } } while (removeTill == removeFrom) { block = block.next(); if (block == document->end()) { return false; } removeTill = block.position(); } Ui::PrepareFormattingOptimization(document); auto cursor = QTextCursor(document); cursor.setPosition(removeFrom); cursor.setPosition(removeTill, QTextCursor::KeepAnchor); cursor.removeSelectedText(); return true; } bool RemoveNonCustomEmoji( not_null document, UniqueCustomEmojiContext &context) { if (!RemoveNonCustomEmojiFragment(document, context)) { return false; } while (RemoveNonCustomEmojiFragment(document, context)) { } return true; } void SetupOnlyCustomEmojiField( not_null field, Fn, bool)> callback, Fn applyHardLimit, int customHardLimit) { field->setTagMimeProcessor(AllowOnlyCustomEmojiProcessor); field->setMimeDataHook(AllowOnlyCustomEmojiMimeDataHook); struct State { bool processing = false; bool pending = false; }; const auto state = field->lifetime().make_state(); field->changes( ) | rpl::start_with_next([=] { state->pending = true; if (state->processing) { return; } auto context = UniqueCustomEmojiContext{ .applyHardLimit = applyHardLimit, .hardLimit = customHardLimit, }; auto changed = false; state->processing = true; while (state->pending) { state->pending = false; const auto document = field->rawTextEdit()->document(); const auto pageSize = document->pageSize(); QTextCursor(document).joinPreviousEditBlock(); if (RemoveNonCustomEmoji(document, context)) { changed = true; } state->processing = false; QTextCursor(document).endEditBlock(); if (document->pageSize() != pageSize) { document->setPageSize(pageSize); } } callback(context.ids, context.hardLimitHit); if (changed) { field->forceProcessContentsChanges(); } }, field->lifetime()); } [[nodiscard]] TextWithTags ComposeEmojiList( not_null reactions, const std::vector &list) { auto result = TextWithTags(); const auto size = [&] { return int(result.text.size()); }; auto added = base::flat_set(); 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 outer; not_null controller; rpl::producer title; std::vector list; std::vector selected; Fn, bool)> callback; rpl::producer stateValue; int customAllowed = 0; int customHardLimit = 0; bool all = false; }; object_ptr AddReactionsSelector( not_null parent, ReactionsSelectorArgs &&args) { using namespace ChatHelpers; using HistoryView::Reactions::UnifiedFactoryOwner; auto result = object_ptr( parent, st::manageGroupReactionsField, 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 overlay; std::unique_ptr unifiedFactoryOwner; UnifiedFactoryOwner::RecentFactory factory; base::flat_set allowed; rpl::lifetime focusLifetime; }; const auto state = raw->lifetime().make_state(); state->unifiedFactoryOwner = std::make_unique( 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([=](QStringView data, Fn update) -> std::unique_ptr { 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( std::move(result), [=] { return state->allowed.contains(id); }); } using namespace Ui::Text; return std::make_unique(std::move(result)); }, std::move(customEmojiPaused)); const auto callback = args.callback; const auto isCustom = [=](DocumentId id) { return state->unifiedFactoryOwner->lookupReactionId(id).custom(); }; SetupOnlyCustomEmojiField(raw, [=]( std::vector ids, bool hardLimitHit) { auto allowed = base::flat_set(); auto reactions = std::vector(); 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), hardLimitHit); }, isCustom, args.customHardLimit); raw->setTextWithTags(ComposeEmojiList(reactions, args.selected)); 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(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( parent.get(), st::manageGroupReactions); const auto panel = Ui::CreateChild( args.outer.get(), args.controller, object_ptr( nullptr, args.controller->uiShow(), Window::GifPauseReason::Layer, (args.all ? TabbedSelector::Mode::FullReactions : TabbedSelector::Mode::RecentReactions))); panel->selector()->provideRecentEmoji( state->unifiedFactoryOwner->unifiedIdsList()); panel->setDesiredHeightValues( 1., st::emojiPanMinHeight / 2, st::emojiPanMinHeight); panel->hide(); panel->selector()->customEmojiChosen( ) | rpl::start_with_next([=](ChatHelpers::FileChosen data) { Data::InsertCustomEmoji(raw, data.document); }, panel->lifetime()); const auto updateEmojiPanelGeometry = [=] { const auto parent = panel->parentWidget(); const auto global = toggle->mapToGlobal({ 0, 0 }); const auto local = parent->mapFromGlobal(global); panel->moveBottomRight( local.y(), local.x() + toggle->width() * 3); }; const auto scheduleUpdateEmojiPanelGeometry = [=] { // updateEmojiPanelGeometry uses not only container geometry, but // also container children geometries that will be updated later. crl::on_main(raw, updateEmojiPanelGeometry); }; const auto filterCallback = [=](not_null event) { const auto type = event->type(); if (type == QEvent::Move || type == QEvent::Resize) { scheduleUpdateEmojiPanelGeometry(); } return base::EventFilterResult::Continue; }; 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([=] { panel->toggleAnimated(); }); raw->geometryValue() | rpl::start_with_next([=](QRect geometry) { toggle->move( geometry.x() + geometry.width() - toggle->width(), geometry.y() + geometry.height() - toggle->height()); updateEmojiPanelGeometry(); }, toggle->lifetime()); return result; } void AddReactionsText( not_null container, not_null navigation, int allowedCustomReactions, rpl::producer customCountValue, Fn askForBoosts) { auto ownedInner = object_ptr(container); const auto inner = ownedInner.data(); const auto count = inner->lifetime().make_state>( std::move(customCountValue)); container->add( object_ptr( container, std::move(ownedInner), st::defaultBoxDividerLabelPadding), QMargins(0, st::manageGroupReactionsTextSkip, 0, 0)); const auto label = inner->add( object_ptr( 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()) { strong->showPeerByLink(Window::PeerByLinkInfo{ .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>( inner, object_ptr( 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 box, EditAllowedReactionsArgs &&args) { using namespace Data; using namespace rpl::mappers; box->setTitle(tr::lng_manage_peer_reactions()); box->setWidth(st::boxWideWidth); enum class Option { All, Some, None, }; using SelectorState = ReactionsSelectorState; struct State { rpl::variable