/* 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/reactions_settings_box.h" #include "chat_helpers/emoji_list_widget.h" #include "chat_helpers/tabbed_selector.h" #include "data/data_document.h" #include "data/data_document_media.h" #include "data/data_message_reactions.h" #include "data/data_session.h" #include "data/stickers/data_custom_emoji.h" #include "history/view/reactions/history_view_reactions_strip.h" #include "lang/lang_keys.h" #include "main/main_session.h" #include "main/main_session_settings.h" #include "ui/chat/chat_theme.h" #include "ui/effects/animations.h" #include "ui/effects/reaction_fly_animation.h" #include "ui/effects/round_area_with_shadow.h" #include "ui/layers/generic_box.h" #include "ui/widgets/labels.h" #include "ui/wrap/vertical_layout.h" #include "ui/animated_icon.h" #include "ui/painter.h" #include "ui/ui_utility.h" #include "ui/vertical_list.h" #include "window/section_widget.h" #include "window/themes/window_theme.h" #include "window/window_session_controller.h" #include "styles/style_boxes.h" #include "styles/style_chat.h" #include "styles/style_chat_helpers.h" #include "styles/style_layers.h" #include "styles/style_settings.h" #include "styles/style_widgets.h" namespace { constexpr auto kCenterSizeMultiplier = 1.6; using Strip = HistoryView::Reactions::Strip; class StripPreview final : public Ui::RpWidget { public: explicit StripPreview(QWidget *parent); void setList(std::vector list, int selectedCount); void setHiddenIndex(int index); [[nodiscard]] int slotCount() const; [[nodiscard]] QRect slotGeometry(int index) const; [[nodiscard]] rpl::producer clicks() const; protected: void paintEvent(QPaintEvent *e) override; int resizeGetHeight(int newWidth) override; void mouseMoveEvent(QMouseEvent *e) override; void mousePressEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; void leaveEventHook(QEvent *e) override; private: [[nodiscard]] static int MaxSlotCount(); [[nodiscard]] QRect islandRect() const; [[nodiscard]] QRect innerRect() const; [[nodiscard]] int indexAt(QPoint position) const; void validateIslandCache(); void validatePillCache(); const style::EmojiPan &_st; Ui::RoundAreaWithShadow _cachedRound; Strip _strip; std::unique_ptr _theme; std::vector _list; QImage _islandCache; QImage _pillCache; int _selectedCount = 0; int _columns = 1; int _hiddenIndex = -1; int _pressed = -1; rpl::event_stream _clicks; }; StripPreview::StripPreview(QWidget *parent) : Ui::RpWidget(parent) , _st(st::reactPanelEmojiPan) , _cachedRound( QSize(2 * st::reactStripSkip + st::reactStripSize, st::reactStripHeight), st::reactionCornerShadow, st::reactStripHeight) , _strip( _st, QRect(0, 0, st::reactStripSize, st::reactStripSize), int(base::SafeRound(st::reactStripImage * kCenterSizeMultiplier)), [=] { update(); }) , _theme(Window::Theme::DefaultChatThemeOn(lifetime())) { setMouseTracking(true); style::PaletteChanged( ) | rpl::on_next([=] { _pillCache = QImage(); _islandCache = QImage(); update(); }, lifetime()); _theme->repaintBackgroundRequests( ) | rpl::on_next([=] { _islandCache = QImage(); update(); }, lifetime()); } int StripPreview::MaxSlotCount() { const auto max = st::menuWithIcons.widthMax + st::reactStripExtend.left() + st::reactStripExtend.right(); return ((max - 2 * st::reactStripSkip) / st::reactStripSize) - 1; } void StripPreview::setList( std::vector list, int selectedCount) { _list = std::move(list); _selectedCount = selectedCount; auto pointers = std::vector>(); pointers.reserve(_list.size()); for (auto &reaction : _list) { if (const auto center = reaction.centerIcon) { reaction.appearAnimation = center; reaction.selectAnimation = center; } pointers.push_back(&reaction); } _strip.applyList(pointers, Strip::AddedButton::None); update(); } void StripPreview::setHiddenIndex(int index) { if (_hiddenIndex != index) { _hiddenIndex = index; update(); } } int StripPreview::slotCount() const { return _columns; } QRect StripPreview::islandRect() const { const auto &margin = st::localStorageIslandMargin; return QRect( margin.left(), margin.top(), width() - margin.left() - margin.right(), height() - margin.top() - margin.bottom()); } QRect StripPreview::innerRect() const { const auto island = islandRect(); const auto innerWidth = 2 * st::reactStripSkip + _columns * st::reactStripSize; return QRect( island.x() + (island.width() - innerWidth) / 2, island.y() + st::settingsReactionsIslandPadding.top() + st::reactionCornerShadow.top(), innerWidth, st::reactStripHeight); } QRect StripPreview::slotGeometry(int index) const { const auto inner = innerRect(); return QRect( inner.x() + st::reactStripSkip + index * st::reactStripSize, inner.y() + (st::reactStripHeight - st::reactStripSize) / 2, st::reactStripSize, st::reactStripSize); } rpl::producer StripPreview::clicks() const { return _clicks.events(); } int StripPreview::resizeGetHeight(int newWidth) { const auto &margin = st::localStorageIslandMargin; const auto &padding = st::settingsReactionsIslandPadding; const auto available = newWidth - margin.left() - margin.right() - padding.left() - padding.right() - 2 * st::reactStripSkip; _columns = std::max( std::min(available / st::reactStripSize, MaxSlotCount()), 1); return margin.top() + padding.top() + st::reactionCornerShadow.top() + st::reactStripHeight + st::reactionCornerShadow.bottom() + _st.icons.stripBubble.height() + padding.bottom() + margin.bottom(); } int StripPreview::indexAt(QPoint position) const { const auto inner = innerRect(); const auto count = std::min(int(_list.size()), _columns); const auto left = inner.x() + st::reactStripSkip; const auto rect = QRect( left, inner.y(), count * st::reactStripSize, inner.height()); return rect.contains(position) ? ((position.x() - left) / st::reactStripSize) : -1; } void StripPreview::validatePillCache() { const auto inner = innerRect(); const auto outer = inner.marginsAdded(st::reactionCornerShadow); const auto ratio = style::DevicePixelRatio(); if (_pillCache.size() == outer.size() * ratio) { return; } _pillCache = _cachedRound.PrepareImage(outer.size()); _pillCache.fill(Qt::transparent); auto q = QPainter(&_pillCache); _cachedRound.setBackgroundColor(_st.bg->c); _cachedRound.setShadowColor(st::shadowFg->c); const auto radius = inner.height() / 2.; const auto frame = _cachedRound.validateFrame(0, 1., radius); const auto fill = _cachedRound.FillWithImage( q, QRect(QPoint(), outer.size()), frame); if (!fill.isEmpty()) { q.fillRect(fill, _st.bg); } } void StripPreview::validateIslandCache() { const auto island = islandRect(); const auto ratio = style::DevicePixelRatio(); if (_islandCache.size() == island.size() * ratio) { return; } auto background = QImage( island.size() * ratio, QImage::Format_ARGB32_Premultiplied); background.setDevicePixelRatio(ratio); { auto q = QPainter(&background); Window::SectionWidget::PaintBackground( q, _theme.get(), QSize(island.width(), window()->height()), QRect(QPoint(), island.size())); } _islandCache = QImage( island.size() * ratio, QImage::Format_ARGB32_Premultiplied); _islandCache.setDevicePixelRatio(ratio); _islandCache.fill(Qt::transparent); auto q = QPainter(&_islandCache); auto hq = PainterHighQualityEnabler(q); q.setPen(Qt::NoPen); q.setBrush(QBrush(background)); q.drawRoundedRect( QRect(QPoint(), island.size()), st::localStorageIslandRadius, st::localStorageIslandRadius); } void StripPreview::paintEvent(QPaintEvent *e) { auto p = QPainter(this); p.fillRect(e->rect(), st::windowBgOver); const auto island = islandRect(); validateIslandCache(); p.drawImage(island.topLeft(), _islandCache); validatePillCache(); const auto inner = innerRect(); const auto outer = inner.marginsAdded(st::reactionCornerShadow); p.drawImage(outer.topLeft(), _pillCache); const auto &bubble = _st.icons.stripBubble; const auto bubbleRight = std::min( st::reactStripBubbleRight, (inner.width() - bubble.width()) / 2); bubble.paint( p, inner.x() + inner.width() - bubbleRight - bubble.width(), inner.y() + inner.height(), width()); auto hq = PainterHighQualityEnabler(p); const auto count = std::min(int(_list.size()), _columns); auto position = QPoint( inner.x() + st::reactStripSkip, inner.y() + (st::reactStripHeight - st::reactStripSize) / 2); for (auto i = 0; i != count; ++i) { if (i != _hiddenIndex) { p.setOpacity((i < _selectedCount) ? 1. : st::settingsReactionsFillerOpacity); _strip.paintOne(p, i, position, 1.); } position += QPoint(st::reactStripSize, 0); } p.setOpacity(1.); } void StripPreview::mouseMoveEvent(QMouseEvent *e) { const auto index = indexAt(e->pos()); _strip.setSelected(index); setCursor((index >= 0) ? style::cur_pointer : style::cur_default); } void StripPreview::mousePressEvent(QMouseEvent *e) { _pressed = indexAt(e->pos()); } void StripPreview::mouseReleaseEvent(QMouseEvent *e) { const auto index = indexAt(e->pos()); if (index >= 0 && index == _pressed) { _clicks.fire_copy(index); } _pressed = -1; } void StripPreview::leaveEventHook(QEvent *e) { _strip.setSelected(-1); } not_null AddReactionIconWrap( not_null parent, rpl::producer iconPositionValue, int iconSize, Fn, QPainter&)> paintCallback, rpl::producer<> &&destroys, not_null stateLifetime) { struct State { base::unique_qptr widget; Ui::Animations::Simple finalAnimation; }; const auto state = stateLifetime->make_state(); state->widget = base::make_unique_q(parent); const auto widget = state->widget.get(); widget->resize(iconSize, iconSize); widget->setAttribute(Qt::WA_TransparentForMouseEvents); std::move( iconPositionValue ) | rpl::on_next([=](const QPoint &point) { widget->moveToLeft(point.x(), point.y()); }, widget->lifetime()); const auto update = crl::guard(widget, [=] { widget->update(); }); widget->paintRequest( ) | rpl::on_next([=] { auto p = QPainter(widget); if (state->finalAnimation.animating()) { const auto progress = 1. - state->finalAnimation.value(0.); const auto size = widget->size(); const auto scaledSize = size * progress; const auto scaledCenter = QPoint( (size.width() - scaledSize.width()) / 2., (size.height() - scaledSize.height()) / 2.); p.setOpacity(progress); p.translate(scaledCenter); p.scale(progress, progress); } paintCallback(widget, p); }, widget->lifetime()); std::move( destroys ) | rpl::take(1) | rpl::on_next([=, from = 0., to = 1.] { state->finalAnimation.start( [=](float64 value) { update(); if (value == to) { stateLifetime->destroy(); } }, from, to, st::defaultPopupMenu.showDuration); }, widget->lifetime()); widget->raise(); widget->show(); return widget; } } // namespace void AddReactionAnimatedIcon( not_null parent, rpl::producer iconPositionValue, int iconSize, const Data::Reaction &reaction, rpl::producer<> &&selects, rpl::producer<> &&destroys, not_null stateLifetime) { struct State { struct Entry { std::shared_ptr media; std::shared_ptr icon; }; Entry appear; Entry select; bool appearAnimated = false; rpl::lifetime loadingLifetime; }; const auto state = stateLifetime->make_state(); state->appear.media = reaction.appearAnimation->createMediaView(); state->select.media = reaction.selectAnimation->createMediaView(); state->appear.media->checkStickerLarge(); state->select.media->checkStickerLarge(); rpl::single() | rpl::then( reaction.appearAnimation->session().downloaderTaskFinished() ) | rpl::on_next([=] { const auto check = [&](State::Entry &entry) { if (!entry.media) { return true; } else if (!entry.media->loaded()) { return false; } entry.icon = HistoryView::Reactions::DefaultIconFactory( entry.media.get(), iconSize); entry.media = nullptr; return true; }; if (check(state->select) && check(state->appear)) { state->select.icon->setCustomEndFrame(1); state->select.icon->animate([] {}); state->loadingLifetime.destroy(); } }, state->loadingLifetime); const auto paintCallback = [=](not_null widget, QPainter &p) { const auto paintFrame = [&](not_null animation) { const auto frame = animation->frame(st::windowFg->c); p.drawImage( QRect( (widget->width() - iconSize) / 2, (widget->height() - iconSize) / 2, iconSize, iconSize), frame); }; const auto appear = state->appear.icon.get(); if (appear && !state->appearAnimated) { state->appearAnimated = true; appear->animate(crl::guard(widget, [=] { widget->update(); })); } if (appear && appear->animating()) { paintFrame(appear); if (appear->frameIndex() == appear->framesCount() - 1) { if (const auto select = state->select.icon.get()) { select->setCustomEndFrame(select->framesCount() - 1); } } } else if (const auto select = state->select.icon.get()) { paintFrame(select); } }; const auto widget = AddReactionIconWrap( parent, std::move(iconPositionValue), iconSize, paintCallback, std::move(destroys), stateLifetime); std::move( selects ) | rpl::on_next([=] { const auto select = state->select.icon.get(); if (select && !select->animating()) { select->animate(crl::guard(widget, [=] { widget->update(); })); } }, widget->lifetime()); } void AddReactionCustomIcon( not_null parent, rpl::producer iconPositionValue, int iconSize, not_null controller, DocumentId customId, rpl::producer<> &&destroys, not_null stateLifetime) { struct State { std::unique_ptr custom; Fn repaint; }; const auto state = stateLifetime->make_state(); static constexpr auto tag = Data::CustomEmojiManager::SizeTag::Normal; state->custom = controller->session().data().customEmojiManager().create( customId, [=] { state->repaint(); }, tag); const auto paintCallback = [=](not_null widget, QPainter &p) { const auto ratio = style::DevicePixelRatio(); const auto size = Data::FrameSizeFromTag(tag) / ratio; state->custom->paint(p, { .textColor = st::windowFg->c, .now = crl::now(), .position = QPoint( (widget->width() - size) / 2, (widget->height() - size) / 2), .paused = controller->isGifPausedAtLeastFor( Window::GifPauseReason::Layer), }); }; const auto widget = AddReactionIconWrap( parent, std::move(iconPositionValue), iconSize, paintCallback, std::move(destroys), stateLifetime); state->repaint = crl::guard(widget, [=] { widget->update(); }); } void ReactionsSettingsBox( not_null box, not_null controller) { struct State { std::vector extras; std::vector composedIds; int composedSelected = 0; base::flat_map docToReaction; base::flat_map reactionToDoc; StripPreview *preview = nullptr; std::unique_ptr fly; Ui::RpWidget *flyLayer = nullptr; int flyIndex = -1; Fn refresh; }; const auto session = &controller->session(); const auto reactions = &session->data().reactions(); const auto premium = session->premium(); const auto state = box->lifetime().make_state(); state->extras = session->settings().extraFavoriteReactions(); const auto pinnedToTop = box->setPinnedToTopContent( object_ptr(box)); pinnedToTop->paintRequest( ) | rpl::on_next([=](QRect clip) { QPainter(pinnedToTop).fillRect(clip, st::windowBgOver); }, pinnedToTop->lifetime()); state->preview = pinnedToTop->add(object_ptr(pinnedToTop)); pinnedToTop->add( object_ptr( pinnedToTop, tr::lng_settings_chat_reactions_subtitle(), st::settingsReactionsAboutLabel), st::settingsReactionsAboutPadding, style::al_top); box->setStyle(st::reactionsSettingsBox); box->setTitle(tr::lng_settings_chat_reactions_title()); box->setWidth(st::boxWideWidth); box->addButton(tr::lng_box_ok(), [=] { box->closeBox(); }); for (const auto &r : reactions->list(Data::Reactions::Type::Active)) { const auto docId = r.selectAnimation->id; state->docToReaction.emplace(docId, r.id); state->reactionToDoc.emplace(r.id, docId); reactions->preloadAnimationsFor(r.id); } const auto allowed = [=](const Data::ReactionId &id) { return id && !id.paid() && (premium || !id.custom()); }; const auto selectedIds = [=] { auto result = std::vector(); const auto favorite = reactions->favoriteId(); if (allowed(favorite)) { result.push_back(favorite); } for (const auto &id : state->extras) { if (allowed(id) && !ranges::contains(result, id)) { result.push_back(id); } } return result; }; const auto resolve = [=]( std::vector &to, const Data::ReactionId &id) { if (ranges::contains(to, id, &Data::Reaction::id)) { return false; } const auto &active = reactions->list(Data::Reactions::Type::Active); const auto i = ranges::find(active, id, &Data::Reaction::id); if (i != end(active)) { to.push_back(*i); return true; } else if (id.custom()) { if (const auto temp = reactions->lookupTemporary(id)) { to.push_back(*temp); return true; } } return false; }; const auto container = box->verticalLayout(); container->paintRequest( ) | rpl::on_next([=](QRect clip) { QPainter(container).fillRect(clip, st::windowBgOver); }, container->lifetime()); using TabbedSelector = ChatHelpers::TabbedSelector; const auto island = container->add( object_ptr(container), st::localStorageIslandMargin); island->paintRequest( ) | rpl::on_next([=] { auto p = QPainter(island); auto hq = PainterHighQualityEnabler(p); p.setPen(Qt::NoPen); p.setBrush(st::boxBg); p.drawRoundedRect( island->rect(), st::localStorageIslandRadius, st::localStorageIslandRadius); }, island->lifetime()); const auto selector = island->add( object_ptr( island, controller->uiShow(), Window::GifPauseReason::Layer, (premium ? TabbedSelector::Mode::FullReactions : TabbedSelector::Mode::RecentReactions)), QMargins( st::boxRadius, st::boxRadius, st::boxRadius, st::boxRadius)); if (premium) { selector->setAllowEmojiWithoutPremium(false); } selector->setRoundRadius(0); selector->resize( st::boxWideWidth - st::localStorageIslandMargin.left() - st::localStorageIslandMargin.right() - 2 * st::boxRadius, st::emojiPanMinHeight); Ui::AddSkip(container, st::localStorageIslandMargin.top()); { auto recentIds = std::vector(); const auto pushDoc = [&](const Data::ReactionId &id) { const auto customId = id.custom(); const auto docId = customId ? customId : [&] { const auto i = state->reactionToDoc.find(id); return (i != end(state->reactionToDoc)) ? i->second : DocumentId(); }(); if (docId && !ranges::contains(recentIds, docId)) { recentIds.push_back(docId); } }; for (const auto &id : selectedIds()) { pushDoc(id); } for (const auto &r : reactions->list(Data::Reactions::Type::Active)) { if (allowed(r.id)) { pushDoc(r.id); } } selector->provideRecentEmoji( ChatHelpers::DocumentListToRecent(recentIds)); } state->refresh = [=] { const auto selected = selectedIds(); auto list = std::vector(); auto count = 0; for (const auto &id : selected) { if (resolve(list, id)) { ++count; } } const auto slots = state->preview->slotCount(); const auto &top = reactions->list(Data::Reactions::Type::Top); const auto &recent = reactions->list(Data::Reactions::Type::Recent); const auto &active = reactions->list(Data::Reactions::Type::Active); for (const auto &reaction : ranges::views::concat(top, recent, active)) { if (int(list.size()) >= slots) { break; } else if (allowed(reaction.id)) { resolve(list, reaction.id); } } state->composedSelected = count; state->composedIds = list | ranges::views::transform(&Data::Reaction::id) | ranges::to_vector; state->preview->setList(std::move(list), count); auto marked = base::flat_set(); for (const auto &id : selected) { if (const auto customId = id.custom()) { marked.emplace(customId); } else { const auto i = state->reactionToDoc.find(id); if (i != end(state->reactionToDoc)) { marked.emplace(i->second); } } } selector->setMarkedCustomIds(marked); }; const auto finishFly = [=] { state->fly = nullptr; state->flyIndex = -1; if (state->flyLayer) { state->flyLayer->hide(); } state->preview->setHiddenIndex(-1); }; const auto startFly = [=]( Data::ReactionId id, Ui::MessageSendingAnimationFrom from, int index) { if (from.frame.isNull() || index < 0) { return; } if (!state->flyLayer) { const auto layer = Ui::CreateChild(box.get()); state->flyLayer = layer; layer->setAttribute(Qt::WA_TransparentForMouseEvents); box->sizeValue( ) | rpl::on_next([=](QSize size) { layer->setGeometry(QRect(QPoint(), size)); }, layer->lifetime()); layer->paintRequest( ) | rpl::on_next([=](QRect clip) { if (!state->fly) { return; } auto p = QPainter(layer); const auto slot = Ui::MapFrom( layer, state->preview, state->preview->slotGeometry(state->flyIndex)); const auto size = st::reactStripImage; const auto target = QRect( slot.topLeft() + QPoint( (slot.width() - size) / 2, (slot.height() - size) / 2), QSize(size, size)); const auto flying = state->fly->flying(); state->fly->paintGetArea( p, QPoint(), target, st::windowFg->c, clip, crl::now()); if (flying && !state->fly->flying()) { state->preview->setHiddenIndex(-1); } if (state->fly->finished()) { crl::on_main(layer, [=] { if (state->fly && state->fly->finished()) { finishFly(); } }); } }, layer->lifetime()); } state->flyIndex = index; state->preview->setHiddenIndex(index); state->flyLayer->raise(); state->flyLayer->show(); state->fly = std::make_unique( reactions, Ui::ReactionFlyAnimationArgs{ .id = id, .flyIcon = from.frame, .flyFrom = state->flyLayer->mapFromGlobal( from.globalStartGeometry), .flyUp = st::settingsReactionsFlyUp, .centerSizeMultiplier = kCenterSizeMultiplier, .flyKeepSize = true, }, [=] { state->flyLayer->update(); }, st::reactStripImage, Data::CustomEmojiSizeTag::Large); }; const auto toggle = [=]( Data::ReactionId id, std::optional from) { if (!allowed(id)) { return; } finishFly(); const auto favorite = reactions->favoriteId(); auto &extras = state->extras; const auto i = ranges::find(extras, id); if (id == favorite) { const auto j = ranges::find_if(extras, [&]( const Data::ReactionId &extra) { return allowed(extra) && (extra != favorite); }); if (j == end(extras)) { return; } const auto promoted = *j; extras.erase(j); extras.erase(ranges::remove(extras, id), end(extras)); reactions->setFavorite(promoted); } else if (i != end(extras)) { extras.erase(i); } else if (favorite.empty()) { reactions->setFavorite(id); } else if (state->composedSelected < state->preview->slotCount()) { extras.push_back(id); if (from) { startFly(id, *from, state->composedSelected); } } else { return; } session->settings().setExtraFavoriteReactions(extras); session->saveSettingsDelayed(); state->refresh(); }; selector->customEmojiChosen( ) | rpl::on_next([=](ChatHelpers::FileChosen data) { const auto docId = data.document->id; const auto i = state->docToReaction.find(docId); const auto id = (i != end(state->docToReaction)) ? i->second : Data::ReactionId{ docId }; toggle(id, data.messageSendingFrom); }, selector->lifetime()); state->preview->clicks( ) | rpl::on_next([=](int index) { if (index >= 0 && index < int(state->composedIds.size())) { toggle(state->composedIds[index], std::nullopt); } }, state->preview->lifetime()); reactions->favoriteUpdates( ) | rpl::on_next([=] { state->refresh(); }, box->lifetime()); state->preview->widthValue( ) | rpl::filter(rpl::mappers::_1 > 0) | rpl::on_next([=] { state->refresh(); }, box->lifetime()); }