/* This file is part of Telegram Desktop, the official desktop application for the Telegram messaging service. For license and copyright information please follow this link: https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "data/data_message_reactions.h" #include "history/history.h" #include "history/history_item.h" #include "main/main_session.h" #include "main/main_account.h" #include "main/main_app_config.h" #include "data/data_session.h" #include "data/data_changes.h" #include "data/data_document.h" #include "data/data_document_media.h" #include "lottie/lottie_icon.h" #include "base/timer_rpl.h" #include "apiwrap.h" #include "styles/style_chat.h" namespace Data { namespace { constexpr auto kRefreshFullListEach = 60 * 60 * crl::time(1000); constexpr auto kPollEach = 20 * crl::time(1000); constexpr auto kSizeForDownscale = 64; } // namespace Reactions::Reactions(not_null owner) : _owner(owner) , _repaintTimer([=] { repaintCollected(); }) { refresh(); base::timer_each( kRefreshFullListEach ) | rpl::start_with_next([=] { refresh(); }, _lifetime); _owner->session().changes().messageUpdates( MessageUpdate::Flag::Destroyed ) | rpl::start_with_next([=](const MessageUpdate &update) { const auto item = update.item; _pollingItems.remove(item); _pollItems.remove(item); _repaintItems.remove(item); }, _lifetime); const auto appConfig = &_owner->session().account().appConfig(); appConfig->value( ) | rpl::start_with_next([=] { const auto favorite = appConfig->get( u"reactions_default"_q, QString::fromUtf8("\xf0\x9f\x91\x8d")); if (_favorite != favorite && !_saveFaveRequestId) { _favorite = favorite; _updated.fire({}); } }, _lifetime); } Reactions::~Reactions() = default; void Reactions::refresh() { request(); } const std::vector &Reactions::list(Type type) const { switch (type) { case Type::Active: return _active; case Type::All: return _available; } Unexpected("Type in Reactions::list."); } QString Reactions::favorite() const { return _favorite; } void Reactions::setFavorite(const QString &emoji) { const auto api = &_owner->session().api(); if (_saveFaveRequestId) { api->request(_saveFaveRequestId).cancel(); } _saveFaveRequestId = api->request(MTPmessages_SetDefaultReaction( MTP_string(emoji) )).done([=] { _saveFaveRequestId = 0; }).fail([=] { _saveFaveRequestId = 0; }).send(); if (_favorite != emoji) { _favorite = emoji; _updated.fire({}); } } rpl::producer<> Reactions::updates() const { return _updated.events(); } void Reactions::preloadImageFor(const QString &emoji) { if (_images.contains(emoji)) { return; } auto &set = _images.emplace(emoji).first->second; const auto i = ranges::find(_available, emoji, &Reaction::emoji); const auto document = (i == end(_available)) ? nullptr : i->centerIcon ? i->centerIcon : i->appearAnimation.get(); if (document) { loadImage(set, document, !i->centerIcon); } else if (!_waitingForList) { _waitingForList = true; refresh(); } } void Reactions::preloadAnimationsFor(const QString &emoji) { const auto i = ranges::find(_available, emoji, &Reaction::emoji); if (i == end(_available)) { return; } const auto preload = [&](DocumentData *document) { const auto view = document ? document->activeMediaView() : nullptr; if (view) { view->checkStickerLarge(); } }; preload(i->centerIcon); preload(i->aroundAnimation); } QImage Reactions::resolveImageFor( const QString &emoji, ImageSize size) { const auto i = _images.find(emoji); if (i == end(_images)) { preloadImageFor(emoji); } auto &set = (i != end(_images)) ? i->second : _images[emoji]; const auto resolve = [&](QImage &image, int size) { const auto factor = style::DevicePixelRatio(); const auto frameSize = set.fromAppearAnimation ? (size / 2) : size; image = set.icon->frame().scaled( frameSize * factor, frameSize * factor, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); if (set.fromAppearAnimation) { auto result = QImage( size * factor, size * factor, QImage::Format_ARGB32_Premultiplied); result.fill(Qt::transparent); auto p = QPainter(&result); p.drawImage( (size - frameSize) * factor / 2, (size - frameSize) * factor / 2, image); p.end(); std::swap(result, image); } image.setDevicePixelRatio(factor); }; if (set.bottomInfo.isNull() && set.icon) { resolve(set.bottomInfo, st::reactionInfoImage); resolve(set.inlineList, st::reactionInlineImage); crl::async([icon = std::move(set.icon)]{}); } switch (size) { case ImageSize::BottomInfo: return set.bottomInfo; case ImageSize::InlineList: return set.inlineList; } Unexpected("ImageSize in Reactions::resolveImageFor."); } void Reactions::resolveImages() { for (auto &[emoji, set] : _images) { if (!set.bottomInfo.isNull() || set.icon || set.media) { continue; } const auto i = ranges::find(_available, emoji, &Reaction::emoji); const auto document = (i == end(_available)) ? nullptr : i->centerIcon ? i->centerIcon : i->appearAnimation.get(); if (document) { loadImage(set, document, !i->centerIcon); } else { LOG(("API Error: Reaction for emoji '%1' not found!" ).arg(emoji)); } } } void Reactions::loadImage( ImageSet &set, not_null document, bool fromAppearAnimation) { if (!set.bottomInfo.isNull() || set.icon) { return; } else if (!set.media) { set.fromAppearAnimation = fromAppearAnimation; set.media = document->createMediaView(); set.media->checkStickerLarge(); } if (set.media->loaded()) { setLottie(set); } else if (!_imagesLoadLifetime) { document->session().downloaderTaskFinished( ) | rpl::start_with_next([=] { downloadTaskFinished(); }, _imagesLoadLifetime); } } void Reactions::setLottie(ImageSet &set) { const auto size = style::ConvertScale(kSizeForDownscale); set.icon = std::make_unique(Lottie::IconDescriptor{ .path = set.media->owner()->filepath(true), .json = set.media->bytes(), .sizeOverride = QSize(size, size), .frame = -1, }); set.media = nullptr; } void Reactions::downloadTaskFinished() { auto hasOne = false; for (auto &[emoji, set] : _images) { if (!set.media) { continue; } else if (set.media->loaded()) { setLottie(set); } else { hasOne = true; } } if (!hasOne) { _imagesLoadLifetime.destroy(); } } base::flat_set Reactions::ParseAllowed( const MTPVector *list) { if (!list) { return {}; } const auto parsed = ranges::views::all( list->v ) | ranges::views::transform([](const MTPstring &string) { return qs(string); }) | ranges::to_vector; return { begin(parsed), end(parsed) }; } void Reactions::request() { auto &api = _owner->session().api(); if (_requestId) { return; } _requestId = api.request(MTPmessages_GetAvailableReactions( MTP_int(_hash) )).done([=](const MTPmessages_AvailableReactions &result) { _requestId = 0; result.match([&](const MTPDmessages_availableReactions &data) { updateFromData(data); }, [&](const MTPDmessages_availableReactionsNotModified &) { }); }).fail([=] { _requestId = 0; _hash = 0; }).send(); } void Reactions::updateFromData(const MTPDmessages_availableReactions &data) { _hash = data.vhash().v; const auto &list = data.vreactions().v; const auto oldCache = base::take(_iconsCache); const auto toCache = [&](DocumentData *document) { if (document) { _iconsCache.emplace(document, document->createMediaView()); } }; _active.clear(); _available.clear(); _active.reserve(list.size()); _available.reserve(list.size()); _iconsCache.reserve(list.size() * 4); for (const auto &reaction : list) { if (const auto parsed = parse(reaction)) { _available.push_back(*parsed); if (parsed->active) { _active.push_back(*parsed); toCache(parsed->appearAnimation); toCache(parsed->selectAnimation); toCache(parsed->centerIcon); toCache(parsed->aroundAnimation); } } } if (_waitingForList) { _waitingForList = false; resolveImages(); } _updated.fire({}); } std::optional Reactions::parse(const MTPAvailableReaction &entry) { return entry.match([&](const MTPDavailableReaction &data) { const auto emoji = qs(data.vreaction()); const auto known = (Ui::Emoji::Find(emoji) != nullptr); if (!known) { LOG(("API Error: Unknown emoji in reactions: %1").arg(emoji)); } const auto selectAnimation = _owner->processDocument( data.vselect_animation()); return known ? std::make_optional(Reaction{ .emoji = emoji, .title = qs(data.vtitle()), .staticIcon = _owner->processDocument(data.vstatic_icon()), .appearAnimation = _owner->processDocument( data.vappear_animation()), .selectAnimation = selectAnimation, //.activateAnimation = _owner->processDocument( // data.vactivate_animation()), //.activateEffects = _owner->processDocument( // data.veffect_animation()), .centerIcon = (data.vcenter_icon() ? _owner->processDocument(*data.vcenter_icon()).get() : nullptr), .aroundAnimation = (data.varound_animation() ? _owner->processDocument( *data.varound_animation()).get() : nullptr), .active = !data.is_inactive(), }) : std::nullopt; }); } void Reactions::send(not_null item, const QString &chosen) { const auto id = item->fullId(); auto &api = _owner->session().api(); auto i = _sentRequests.find(id); if (i != end(_sentRequests)) { api.request(i->second).cancel(); } else { i = _sentRequests.emplace(id).first; } const auto flags = chosen.isEmpty() ? MTPmessages_SendReaction::Flag(0) : MTPmessages_SendReaction::Flag::f_reaction; i->second = api.request(MTPmessages_SendReaction( MTP_flags(flags), item->history()->peer->input, MTP_int(id.msg), MTP_string(chosen) )).done([=](const MTPUpdates &result) { _sentRequests.remove(id); _owner->session().api().applyUpdates(result); }).fail([=](const MTP::Error &error) { _sentRequests.remove(id); }).send(); } void Reactions::poll(not_null item, crl::time now) { // Group them by one second. const auto last = item->lastReactionsRefreshTime(); const auto grouped = ((last + 999) / 1000) * 1000; if (!grouped || item->history()->peer->isUser()) { // First reaction always edits message. return; } else if (const auto left = grouped + kPollEach - now; left > 0) { if (!_repaintItems.contains(item)) { _repaintItems.emplace(item, grouped + kPollEach); if (!_repaintTimer.isActive() || _repaintTimer.remainingTime() > left) { _repaintTimer.callOnce(left); } } } else if (!_pollingItems.contains(item)) { if (_pollItems.empty() && !_pollRequestId) { crl::on_main(&_owner->session(), [=] { pollCollected(); }); } _pollItems.emplace(item); } } void Reactions::updateAllInHistory(not_null peer, bool enabled) { if (const auto history = _owner->historyLoaded(peer)) { history->reactionsEnabledChanged(enabled); } } void Reactions::repaintCollected() { const auto now = crl::now(); auto closest = crl::time(); for (auto i = begin(_repaintItems); i != end(_repaintItems);) { if (i->second <= now) { _owner->requestItemRepaint(i->first); i = _repaintItems.erase(i); } else { if (!closest || i->second < closest) { closest = i->second; } ++i; } } if (closest) { _repaintTimer.callOnce(closest - now); } } void Reactions::pollCollected() { auto toRequest = base::flat_map, QVector>(); _pollingItems = std::move(_pollItems); for (const auto &item : _pollingItems) { toRequest[item->history()->peer].push_back(MTP_int(item->id)); } auto &api = _owner->session().api(); for (const auto &[peer, ids] : toRequest) { const auto finalize = [=] { const auto now = crl::now(); for (const auto &item : base::take(_pollingItems)) { const auto last = item->lastReactionsRefreshTime(); if (last && last + kPollEach <= now) { item->updateReactions(nullptr); } } _pollRequestId = 0; if (!_pollItems.empty()) { crl::on_main(&_owner->session(), [=] { pollCollected(); }); } }; _pollRequestId = api.request(MTPmessages_GetMessagesReactions( peer->input, MTP_vector(ids) )).done([=](const MTPUpdates &result) { _owner->session().api().applyUpdates(result); finalize(); }).fail([=] { finalize(); }).send(); } } bool Reactions::sending(not_null item) const { return _sentRequests.contains(item->fullId()); } MessageReactions::MessageReactions(not_null item) : _item(item) { } void MessageReactions::add(const QString &reaction) { if (_chosen == reaction) { return; } const auto history = _item->history(); const auto self = history->session().user(); if (!_chosen.isEmpty()) { const auto i = _list.find(_chosen); Assert(i != end(_list)); --i->second; const auto removed = !i->second; if (removed) { _list.erase(i); } const auto j = _recent.find(_chosen); if (j != end(_recent)) { j->second.erase(ranges::remove(j->second, self), end(j->second)); if (j->second.empty() || removed) { _recent.erase(j); } } } _chosen = reaction; if (!reaction.isEmpty()) { if (_item->canViewReactions()) { auto &list = _recent[reaction]; list.insert(begin(list), self); } ++_list[reaction]; } auto &owner = history->owner(); owner.reactions().send(_item, _chosen); owner.notifyItemDataChange(_item); } void MessageReactions::remove() { add(QString()); } void MessageReactions::set( const QVector &list, const QVector &recent, bool ignoreChosen) { auto &owner = _item->history()->owner(); if (owner.reactions().sending(_item)) { // We'll apply non-stale data from the request response. return; } auto changed = false; auto existing = base::flat_set(); for (const auto &count : list) { count.match([&](const MTPDreactionCount &data) { const auto reaction = qs(data.vreaction()); if (data.is_chosen() && !ignoreChosen) { if (_chosen != reaction) { _chosen = reaction; changed = true; } } const auto nowCount = data.vcount().v; auto &wasCount = _list[reaction]; if (wasCount != nowCount) { wasCount = nowCount; changed = true; } existing.emplace(reaction); }); } if (_list.size() != existing.size()) { changed = true; for (auto i = begin(_list); i != end(_list);) { if (!existing.contains(i->first)) { i = _list.erase(i); } else { ++i; } } if (!_chosen.isEmpty() && !_list.contains(_chosen)) { _chosen = QString(); } } auto parsed = base::flat_map< QString, std::vector>>(); for (const auto &reaction : recent) { reaction.match([&](const MTPDmessageUserReaction &data) { const auto emoji = qs(data.vreaction()); if (_list.contains(emoji)) { parsed[emoji].push_back(owner.user(data.vuser_id())); } }); } if (_recent != parsed) { _recent = std::move(parsed); changed = true; } if (changed) { owner.notifyItemDataChange(_item); } } const base::flat_map &MessageReactions::list() const { return _list; } auto MessageReactions::recent() const -> const base::flat_map>> & { return _recent; } bool MessageReactions::empty() const { return _list.empty(); } QString MessageReactions::chosen() const { return _chosen; } } // namespace Data