/* 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 "media/stories/media_stories_reactions.h" #include "base/event_filter.h" #include "boxes/premium_preview_box.h" #include "chat_helpers/compose/compose_show.h" #include "data/data_changes.h" #include "data/data_document.h" #include "data/data_document_media.h" #include "data/data_message_reactions.h" #include "data/data_peer.h" #include "data/data_session.h" #include "history/view/reactions/history_view_reactions_selector.h" #include "main/main_session.h" #include "media/stories/media_stories_controller.h" #include "ui/effects/emoji_fly_animation.h" #include "ui/effects/reaction_fly_animation.h" #include "ui/widgets/popup_menu.h" #include "ui/animated_icon.h" #include "ui/painter.h" #include "styles/style_chat_helpers.h" #include "styles/style_media_view.h" #include "styles/style_widgets.h" namespace Media::Stories { namespace { constexpr auto kReactionScaleOutTarget = 0.7; constexpr auto kReactionScaleOutDuration = crl::time(1000); constexpr auto kMessageReactionScaleOutDuration = crl::time(400); [[nodiscard]] Data::ReactionId HeartReactionId() { return { QString() + QChar(10084) }; } [[nodiscard]] Data::PossibleItemReactionsRef LookupPossibleReactions( not_null session) { auto result = Data::PossibleItemReactionsRef(); const auto reactions = &session->data().reactions(); const auto &full = reactions->list(Data::Reactions::Type::Active); const auto &top = reactions->list(Data::Reactions::Type::Top); const auto &recent = reactions->list(Data::Reactions::Type::Recent); const auto premiumPossible = session->premiumPossible(); auto added = base::flat_set(); result.recent.reserve(full.size()); for (const auto &reaction : ranges::views::concat(top, recent, full)) { if (premiumPossible || !reaction.id.custom()) { if (added.emplace(reaction.id).second) { result.recent.push_back(&reaction); } } } result.customAllowed = premiumPossible; const auto i = ranges::find( result.recent, reactions->favoriteId(), &Data::Reaction::id); if (i != end(result.recent) && i != begin(result.recent)) { std::rotate(begin(result.recent), i, i + 1); } return result; } } // namespace class Reactions::Panel final { public: explicit Panel(not_null controller); ~Panel(); [[nodiscard]] rpl::producer expandedValue() const { return _expanded.value(); } [[nodiscard]] rpl::producer shownValue() const { return _shown.value(); } [[nodiscard]] rpl::producer chosen() const; void show(Mode mode); void hide(Mode mode); void hideIfCollapsed(Mode mode); void collapse(Mode mode); void attachToReactionButton(not_null button); private: struct Hiding; void create(); void updateShowState(); void fadeOutSelector(); void startAnimation(); const not_null _controller; std::unique_ptr _parent; std::unique_ptr _selector; std::vector> _hiding; rpl::event_stream _chosen; Ui::Animations::Simple _showing; rpl::variable _shownValue; rpl::variable _expanded; rpl::variable _mode; rpl::variable _shown = false; }; struct Reactions::Panel::Hiding { explicit Hiding(not_null parent) : widget(parent) { } Ui::RpWidget widget; Ui::Animations::Simple animation; QImage frame; }; Reactions::Panel::Panel(not_null controller) : _controller(controller) { } Reactions::Panel::~Panel() = default; auto Reactions::Panel::chosen() const -> rpl::producer { return _chosen.events(); } void Reactions::Panel::show(Mode mode) { const auto was = _mode.current(); if (_shown.current() && was == mode) { return; } else if (_shown.current()) { hide(was); } _mode = mode; create(); if (!_selector) { return; } const auto duration = st::defaultPanelAnimation.heightDuration * st::defaultPopupMenu.showDuration; _shown = true; _showing.start([=] { updateShowState(); }, 0., 1., duration); updateShowState(); _parent->show(); } void Reactions::Panel::hide(Mode mode) { if (!_selector || _mode.current() != mode) { return; } _selector->beforeDestroy(); if (!anim::Disabled()) { fadeOutSelector(); } _shown = false; _expanded = false; _showing.stop(); _selector = nullptr; _parent = nullptr; } void Reactions::Panel::hideIfCollapsed(Mode mode) { if (!_expanded.current() && _mode.current() == mode) { hide(mode); } } void Reactions::Panel::collapse(Mode mode) { if (_expanded.current() && _mode.current() == mode) { hide(mode); show(mode); } } void Reactions::Panel::attachToReactionButton(not_null button) { base::install_event_filter(button, [=](not_null e) { if (e->type() == QEvent::ContextMenu && !button->isHidden()) { show(Reactions::Mode::Reaction); return base::EventFilterResult::Cancel; } else if (e->type() == QEvent::Hide) { hide(Reactions::Mode::Reaction); } return base::EventFilterResult::Continue; }); } void Reactions::Panel::create() { auto reactions = LookupPossibleReactions( &_controller->uiShow()->session()); if (reactions.recent.empty() && !reactions.morePremiumAvailable) { return; } _parent = std::make_unique(_controller->wrap().get()); _parent->show(); const auto mode = _mode.current(); _parent->events() | rpl::start_with_next([=](not_null e) { if (e->type() == QEvent::MouseButtonPress) { const auto event = static_cast(e.get()); if (event->button() == Qt::LeftButton) { if (!_selector || !_selector->geometry().contains(event->pos())) { if (mode == Mode::Message) { collapse(mode); } else { hide(mode); } } } } }, _parent->lifetime()); _selector = std::make_unique( _parent.get(), st::storiesReactionsPan, _controller->uiShow(), std::move(reactions), _controller->cachedReactionIconFactory().createMethod(), [=](bool fast) { hide(mode); }); _selector->chosen( ) | rpl::start_with_next([=]( HistoryView::Reactions::ChosenReaction reaction) { _chosen.fire({ .reaction = reaction, .mode = mode }); hide(mode); }, _selector->lifetime()); _selector->premiumPromoChosen() | rpl::start_with_next([=] { hide(mode); ShowPremiumPreviewBox( _controller->uiShow(), PremiumPreview::InfiniteReactions); }, _selector->lifetime()); const auto desiredWidth = st::storiesReactionsWidth; const auto maxWidth = desiredWidth * 2; const auto width = _selector->countWidth(desiredWidth, maxWidth); const auto margins = _selector->marginsForShadow(); const auto categoriesTop = _selector->extendTopForCategories(); const auto full = margins.left() + width + margins.right(); _shownValue = 0.; rpl::combine( _controller->layoutValue(), _shownValue.value() ) | rpl::start_with_next([=](const Layout &layout, float64 shown) { const auto width = margins.left() + _selector->countAppearedWidth(shown) + margins.right(); const auto height = layout.reactions.height(); const auto shift = (width / 2); const auto right = (mode == Mode::Message) ? (layout.reactions.x() + layout.reactions.width() / 2 + shift) : (layout.controlsBottomPosition.x() + layout.controlsWidth - st::storiesLikeReactionsPosition.x()); const auto top = (mode == Mode::Message) ? layout.reactions.y() : (layout.controlsBottomPosition.y() - height - st::storiesLikeReactionsPosition.y()); _parent->setGeometry(QRect((right - width), top, full, height)); const auto innerTop = height - st::storiesReactionsBottomSkip - st::reactStripHeight; const auto maxAdded = innerTop - margins.top() - categoriesTop; const auto added = std::min(maxAdded, st::storiesReactionsAddedTop); _selector->setSpecialExpandTopSkip(added); _selector->initGeometry(innerTop); }, _selector->lifetime()); _selector->willExpand( ) | rpl::start_with_next([=] { _expanded = true; }, _selector->lifetime()); _selector->escapes() | rpl::start_with_next([=] { if (mode == Mode::Message) { collapse(mode); } else { hide(mode); } }, _selector->lifetime()); } void Reactions::Panel::fadeOutSelector() { const auto wrap = _controller->wrap().get(); const auto geometry = Ui::MapFrom( wrap, _parent.get(), _selector->geometry()); _hiding.push_back(std::make_unique(wrap)); const auto raw = _hiding.back().get(); raw->frame = Ui::GrabWidgetToImage(_selector.get()); raw->widget.setGeometry(geometry); raw->widget.show(); raw->widget.paintRequest( ) | rpl::start_with_next([=] { if (const auto opacity = raw->animation.value(0.)) { auto p = QPainter(&raw->widget); p.setOpacity(opacity); p.drawImage(0, 0, raw->frame); } }, raw->widget.lifetime()); Ui::PostponeCall(&raw->widget, [=] { raw->animation.start([=] { if (raw->animation.animating()) { raw->widget.update(); } else { const auto i = ranges::find( _hiding, raw, &std::unique_ptr::get); if (i != end(_hiding)) { _hiding.erase(i); } } }, 1., 0., st::slideWrapDuration); }); } void Reactions::Panel::updateShowState() { const auto progress = _showing.value(_shown.current() ? 1. : 0.); const auto opacity = 1.; const auto appearing = _showing.animating(); const auto toggling = false; _shownValue = progress; _selector->updateShowState(progress, opacity, appearing, toggling); } Reactions::Reactions(not_null controller) : _controller(controller) , _panel(std::make_unique(_controller)) { _panel->chosen() | rpl::start_with_next([=](Chosen &&chosen) { animateAndProcess(std::move(chosen)); }, _lifetime); } Reactions::~Reactions() = default; rpl::producer Reactions::activeValue() const { using namespace rpl::mappers; return rpl::combine( _panel->expandedValue(), _panel->shownValue(), _1 || _2); } auto Reactions::chosen() const -> rpl::producer { return _chosen.events(); } void Reactions::setReplyFieldState( rpl::producer focused, rpl::producer hasSendText) { std::move( focused ) | rpl::start_with_next([=](bool focused) { _replyFocused = focused; if (!_replyFocused) { _panel->hideIfCollapsed(Reactions::Mode::Message); } else if (!_hasSendText) { _panel->show(Reactions::Mode::Message); } }, _lifetime); std::move( hasSendText ) | rpl::start_with_next([=](bool has) { _hasSendText = has; if (_replyFocused) { if (_hasSendText) { _panel->hide(Reactions::Mode::Message); } else { _panel->show(Reactions::Mode::Message); } } }, _lifetime); } void Reactions::attachToReactionButton(not_null button) { _likeButton = button; _panel->attachToReactionButton(button); } auto Reactions::attachToMenu( not_null menu, QPoint desiredPosition) -> AttachStripResult { using namespace HistoryView::Reactions; const auto story = _controller->story(); if (!story || story->peer()->isSelf()) { return AttachStripResult::Skipped; } const auto show = _controller->uiShow(); const auto result = AttachSelectorToMenu( menu, desiredPosition, st::storiesReactionsPan, show, LookupPossibleReactions(&show->session()), _controller->cachedReactionIconFactory().createMethod()); if (!result) { return result.error(); } const auto selector = *result; selector->chosen() | rpl::start_with_next([=](ChosenReaction reaction) { menu->hideMenu(); animateAndProcess({ reaction, ReactionsMode::Reaction }); }, selector->lifetime()); return AttachSelectorResult::Attached; } Data::ReactionId Reactions::liked() const { return _liked.current(); } rpl::producer Reactions::likedValue() const { return _liked.value(); } void Reactions::showLikeFrom(Data::Story *story) { setLikedIdFrom(story); if (!story) { _likeFromLifetime.destroy(); return; } _likeFromLifetime = story->session().changes().storyUpdates( story, Data::StoryUpdate::Flag::Reaction ) | rpl::start_with_next([=](const Data::StoryUpdate &update) { setLikedIdFrom(update.story); }); } void Reactions::hide() { _panel->hide(Reactions::Mode::Message); _panel->hide(Reactions::Mode::Reaction); } void Reactions::outsidePressed() { _panel->hide(Reactions::Mode::Reaction); _panel->collapse(Reactions::Mode::Message); } void Reactions::toggleLiked() { const auto liked = !_liked.current().empty(); const auto now = liked ? Data::ReactionId() : HeartReactionId(); if (_liked.current() != now) { animateAndProcess({ { .id = now }, ReactionsMode::Reaction }); } } void Reactions::ready() { if (const auto story = _controller->story()) { story->owner().reactions().preloadAnimationsFor(HeartReactionId()); } } void Reactions::animateAndProcess(Chosen &&chosen) { const auto like = (chosen.mode == Mode::Reaction); const auto wrap = _controller->wrap(); const auto target = like ? _likeButton : wrap.get(); const auto story = _controller->story(); if (!story || !target) { return; } auto done = like ? setLikedIdIconInit(&story->owner(), chosen.reaction.id) : Fn(); const auto scaleOutDuration = like ? kReactionScaleOutDuration : kMessageReactionScaleOutDuration; const auto scaleOutTarget = like ? kReactionScaleOutTarget : 0.; if (!chosen.reaction.id.empty()) { startReactionAnimation({ .id = chosen.reaction.id, .flyIcon = chosen.reaction.icon, .flyFrom = (chosen.reaction.globalGeometry.isEmpty() ? QRect() : wrap->mapFromGlobal(chosen.reaction.globalGeometry)), .scaleOutDuration = scaleOutDuration, .scaleOutTarget = scaleOutTarget, }, target, std::move(done)); } _chosen.fire(std::move(chosen)); } void Reactions::assignLikedId(Data::ReactionId id) { invalidate_weak_ptrs(&_likeIconGuard); _likeIcon = nullptr; _liked = id; } Fn Reactions::setLikedIdIconInit( not_null owner, Data::ReactionId id, bool force) { if (_liked.current() != id) { _likeIconMedia = nullptr; } else if (!force) { return nullptr; } assignLikedId(id); if (id.empty() || !_likeButton) { return nullptr; } return crl::guard(&_likeIconGuard, [=](Ui::ReactionFlyCenter center) { if (!id.custom() && !center.icon && !_likeIconMedia) { waitForLikeIcon(owner, id); } else { initLikeIcon(owner, id, std::move(center)); } }); } void Reactions::initLikeIcon( not_null owner, Data::ReactionId id, Ui::ReactionFlyCenter center) { Expects(_likeButton != nullptr); _likeIcon = std::make_unique(_likeButton); const auto icon = _likeIcon.get(); icon->show(); _likeButton->sizeValue() | rpl::start_with_next([=](QSize size) { icon->setGeometry(QRect(QPoint(), size)); }, icon->lifetime()); if (!id.custom() && !center.icon) { return; } struct State { Ui::ReactionFlyCenter center; QImage cache; }; const auto fly = icon->lifetime().make_state(State{ .center = std::move(center), }); if (const auto customId = id.custom()) { auto withCorrectCallback = owner->customEmojiManager().create( customId, [=] { icon->update(); }, Data::CustomEmojiSizeTag::Isolated); [[maybe_unused]] const auto load = withCorrectCallback->ready(); fly->center.custom = std::move(withCorrectCallback); fly->center.icon = nullptr; } else { fly->center.icon->jumpToStart(nullptr); fly->center.custom = nullptr; } const auto paintNonCached = [=](QPainter &p) { auto hq = PainterHighQualityEnabler(p); const auto size = fly->center.size; const auto target = QRect( (icon->width() - size) / 2, (icon->height() - size) / 2, size, size); const auto scale = fly->center.scale; if (scale < 1.) { const auto shift = QRectF(target).center(); p.translate(shift); p.scale(scale, scale); p.translate(-shift); } const auto multiplier = fly->center.centerSizeMultiplier; const auto inner = int(base::SafeRound(size * multiplier)); if (const auto icon = fly->center.icon.get()) { const auto rect = QRect( target.x() + (target.width() - inner) / 2, target.y() + (target.height() - inner) / 2, inner, inner); p.drawImage(rect, icon->frame(st::storiesComposeWhiteText->c)); } else { const auto customSize = fly->center.customSize; const auto scaled = (inner != customSize); fly->center.custom->paint(p, { .textColor = st::storiesComposeWhiteText->c, .size = { customSize, customSize }, .now = crl::now(), .scale = (scaled ? (inner / float64(customSize)) : 1.), .position = QPoint( target.x() + (target.width() - customSize) / 2, target.y() + (target.height() - customSize) / 2), .scaled = scaled, }); } }; icon->paintRequest() | rpl::start_with_next([=] { auto p = QPainter(icon); if (!fly->cache.isNull()) { p.drawImage(0, 0, fly->cache); } else if (fly->center.icon || fly->center.custom->readyInDefaultState()) { const auto ratio = style::DevicePixelRatio(); fly->cache = QImage( icon->size() * ratio, QImage::Format_ARGB32_Premultiplied); fly->cache.setDevicePixelRatio(ratio); fly->cache.fill(Qt::transparent); auto q = QPainter(&fly->cache); paintNonCached(q); q.end(); fly->center.icon = nullptr; fly->center.custom = nullptr; p.drawImage(0, 0, fly->cache); } else { paintNonCached(p); } }, icon->lifetime()); } void Reactions::waitForLikeIcon( not_null owner, Data::ReactionId id) { _likeIconWaitLifetime = rpl::single( rpl::empty ) | rpl::then( owner->reactions().defaultUpdates() ) | rpl::map([=]() -> rpl::producer { const auto &list = owner->reactions().list( Data::Reactions::Type::All); const auto i = ranges::find(list, id, &Data::Reaction::id); if (i == end(list)) { return rpl::single(false); } const auto document = i->centerIcon ? not_null(i->centerIcon) : i->selectAnimation; _likeIconMedia = document->createMediaView(); _likeIconMedia->checkStickerLarge(); return rpl::single( rpl::empty ) | rpl::then( document->session().downloaderTaskFinished() ) | rpl::map([=] { return _likeIconMedia->loaded(); }); }) | rpl::flatten_latest( ) | rpl::filter( rpl::mappers::_1 ) | rpl::take(1) | rpl::start_with_next([=] { setLikedId(owner, id, true); crl::on_main(&_likeIconGuard, [=] { _likeIconMedia = nullptr; _likeIconWaitLifetime.destroy(); }); }); } void Reactions::setLikedIdFrom(Data::Story *story) { if (!story) { assignLikedId({}); } else { setLikedId(&story->owner(), story->sentReactionId()); } } void Reactions::setLikedId( not_null owner, Data::ReactionId id, bool force) { if (const auto done = setLikedIdIconInit(owner, id, force)) { const auto reactions = &owner->reactions(); const auto colored = [] { return st::storiesComposeWhiteText->c; }; const auto sizeTag = Data::CustomEmojiSizeTag::Isolated; done(Ui::EmojiFlyAnimation(_controller->wrap(), reactions, { .id = id, .scaleOutDuration = kReactionScaleOutDuration, .scaleOutTarget = kReactionScaleOutTarget, }, [] {}, colored, sizeTag).grabBadgeCenter()); } } void Reactions::startReactionAnimation( Ui::ReactionFlyAnimationArgs args, not_null target, Fn done) { const auto wrap = _controller->wrap(); const auto story = _controller->story(); _reactionAnimation = std::make_unique( wrap, &story->owner().reactions(), std::move(args), [=] { _reactionAnimation->repaint(); }, [] { return st::storiesComposeWhiteText->c; }, Data::CustomEmojiSizeTag::Isolated); const auto layer = _reactionAnimation->layer(); wrap->paintRequest() | rpl::start_with_next([=] { if (!_reactionAnimation->paintBadgeFrame(target)) { InvokeQueued(layer, [=] { _reactionAnimation = nullptr; wrap->update(); }); if (done) { done(_reactionAnimation->grabBadgeCenter()); } } }, layer->lifetime()); wrap->update(); } } // namespace Media::Stories