/* 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 "chat_helpers/emoji_interactions.h" #include "chat_helpers/stickers_emoji_pack.h" #include "history/history_item.h" #include "history/history.h" #include "history/view/history_view_element.h" #include "history/view/media/history_view_sticker.h" #include "main/main_session.h" #include "data/data_session.h" #include "data/data_changes.h" #include "data/data_peer.h" #include "data/data_document.h" #include "data/data_document_media.h" #include "ui/emoji_config.h" #include "base/random.h" #include "apiwrap.h" #include #include #include #include namespace ChatHelpers { namespace { constexpr auto kMinDelay = crl::time(200); constexpr auto kAccumulateDelay = crl::time(1000); constexpr auto kAccumulateSeenRequests = kAccumulateDelay; constexpr auto kAcceptSeenSinceRequest = 3 * crl::time(1000); constexpr auto kMaxDelay = 2 * crl::time(1000); constexpr auto kTimeNever = std::numeric_limits::max(); constexpr auto kJsonVersion = 1; } // namespace auto EmojiInteractions::Combine(CheckResult a, CheckResult b) -> CheckResult { return { .nextCheckAt = std::min(a.nextCheckAt, b.nextCheckAt), .waitingForDownload = a.waitingForDownload || b.waitingForDownload, }; } EmojiInteractions::EmojiInteractions(not_null session) : _session(session) , _checkTimer([=] { check(); }) { _session->changes().messageUpdates( Data::MessageUpdate::Flag::Destroyed | Data::MessageUpdate::Flag::Edited ) | rpl::start_with_next([=](const Data::MessageUpdate &update) { if (update.flags & Data::MessageUpdate::Flag::Destroyed) { _outgoing.remove(update.item); _incoming.remove(update.item); } else if (update.flags & Data::MessageUpdate::Flag::Edited) { checkEdition(update.item, _outgoing); checkEdition(update.item, _incoming); } }, _lifetime); } EmojiInteractions::~EmojiInteractions() = default; void EmojiInteractions::checkEdition( not_null item, base::flat_map, std::vector> &map) { const auto i = map.find(item); if (i != end(map) && (i->second.front().emoji != chooseInteractionEmoji(item))) { map.erase(i); } } EmojiPtr EmojiInteractions::chooseInteractionEmoji( not_null item) const { return chooseInteractionEmoji(item->originalText().text); } EmojiPtr EmojiInteractions::chooseInteractionEmoji( const QString &emoticon) const { const auto emoji = Ui::Emoji::Find(emoticon); if (!emoji) { return nullptr; } const auto &pack = _session->emojiStickersPack(); if (!pack.animationsForEmoji(emoji).empty()) { return emoji; } if (const auto original = emoji->original(); original != emoji) { if (!pack.animationsForEmoji(original).empty()) { return original; } } static const auto kHearts = { QString::fromUtf8("\xf0\x9f\x92\x9b"), QString::fromUtf8("\xf0\x9f\x92\x99"), QString::fromUtf8("\xf0\x9f\x92\x9a"), QString::fromUtf8("\xf0\x9f\x92\x9c"), QString::fromUtf8("\xf0\x9f\xa7\xa1"), QString::fromUtf8("\xf0\x9f\x96\xa4"), QString::fromUtf8("\xf0\x9f\xa4\x8e"), QString::fromUtf8("\xf0\x9f\xa4\x8d"), }; return ranges::contains(kHearts, emoji->id()) ? Ui::Emoji::Find(QString::fromUtf8("\xe2\x9d\xa4")) : emoji; } void EmojiInteractions::startOutgoing( not_null view) { const auto item = view->data(); if (!IsServerMsgId(item->id) || !item->history()->peer->isUser()) { return; } const auto emoji = chooseInteractionEmoji(item); if (!emoji) { return; } const auto &pack = _session->emojiStickersPack(); const auto &list = pack.animationsForEmoji(emoji); if (list.empty()) { return; } auto &animations = _outgoing[item]; if (!animations.empty() && animations.front().emoji != emoji) { // The message was edited, forget the old emoji. animations.clear(); } const auto last = !animations.empty() ? &animations.back() : nullptr; const auto listSize = int(list.size()); const auto chooseDifferent = (last && listSize > 1); const auto index = chooseDifferent ? base::RandomIndex(listSize - 1) : base::RandomIndex(listSize); const auto selected = (begin(list) + index)->second; const auto document = (chooseDifferent && selected == last->document) ? (begin(list) + index + 1)->second : selected; const auto media = document->createMediaView(); media->checkStickerLarge(); const auto now = crl::now(); animations.push_back({ .emoji = emoji, .document = document, .media = media, .scheduledAt = now, .index = index, }); check(now); } void EmojiInteractions::startIncoming( not_null peer, MsgId messageId, const QString &emoticon, EmojiInteractionsBunch &&bunch) { if (!peer->isUser() || bunch.interactions.empty() || !IsServerMsgId(messageId)) { return; } const auto item = _session->data().message(nullptr, messageId); if (!item) { return; } const auto emoji = chooseInteractionEmoji(item); if (!emoji || emoji != chooseInteractionEmoji(emoticon)) { return; } const auto &pack = _session->emojiStickersPack(); const auto &list = pack.animationsForEmoji(emoji); if (list.empty()) { return; } auto &animations = _incoming[item]; if (!animations.empty() && animations.front().emoji != emoji) { // The message was edited, forget the old emoji. animations.clear(); } const auto now = crl::now(); for (const auto &single : bunch.interactions) { const auto at = now + crl::time(std::round(single.time * 1000)); if (!animations.empty() && animations.back().scheduledAt >= at) { continue; } const auto last = !animations.empty() ? &animations.back() : nullptr; const auto listSize = int(list.size()); const auto index = (single.index - 1); if (index < listSize) { const auto document = (begin(list) + index)->second; const auto media = document->createMediaView(); media->checkStickerLarge(); animations.push_back({ .emoji = emoji, .document = document, .media = media, .scheduledAt = at, .incoming = true, .index = index, }); } } if (animations.empty()) { _incoming.remove(item); } else { check(now); } } void EmojiInteractions::seenOutgoing( not_null peer, const QString &emoticon) { if (const auto i = _playsSent.find(peer); i != end(_playsSent)) { if (const auto emoji = chooseInteractionEmoji(emoticon)) { if (const auto j = i->second.find(emoji); j != end(i->second)) { const auto last = j->second.lastDoneReceivedAt; if (!last || last + kAcceptSeenSinceRequest > crl::now()) { _seen.fire({ peer, emoji }); } } } } } auto EmojiInteractions::checkAnimations(crl::time now) -> CheckResult { return Combine( checkAnimations(now, _outgoing), checkAnimations(now, _incoming)); } auto EmojiInteractions::checkAnimations( crl::time now, base::flat_map, std::vector> &map ) -> CheckResult { auto nearest = kTimeNever; auto waitingForDownload = false; for (auto &[item, animations] : map) { auto lastStartedAt = crl::time(); // Erase too old requests. const auto i = ranges::find_if(animations, [&](const Animation &a) { return !a.startedAt && (a.scheduledAt + kMaxDelay <= now); }); if (i != end(animations)) { animations.erase(i, end(animations)); } for (auto &animation : animations) { if (animation.startedAt) { lastStartedAt = animation.startedAt; } else if (!animation.media->loaded()) { animation.media->checkStickerLarge(); waitingForDownload = true; break; } else if (!lastStartedAt || lastStartedAt + kMinDelay <= now) { animation.startedAt = now; _playRequests.fire({ animation.emoji->text(), item, animation.media, animation.scheduledAt, animation.incoming, }); break; } else { nearest = std::min(nearest, lastStartedAt + kMinDelay); break; } } } return { .nextCheckAt = nearest, .waitingForDownload = waitingForDownload, }; } void EmojiInteractions::sendAccumulatedOutgoing( crl::time now, not_null item, std::vector &animations) { Expects(!animations.empty()); const auto firstStartedAt = animations.front().startedAt; const auto intervalEnd = firstStartedAt + kAccumulateDelay; if (intervalEnd > now) { return; } const auto from = begin(animations); const auto till = ranges::find_if(animations, [&](const auto &animation) { return !animation.startedAt || (animation.startedAt >= intervalEnd); }); auto bunch = EmojiInteractionsBunch(); bunch.interactions.reserve(till - from); for (const auto &animation : ranges::make_subrange(from, till)) { bunch.interactions.push_back({ .index = animation.index + 1, .time = (animation.startedAt - firstStartedAt) / 1000., }); } if (bunch.interactions.empty()) { return; } const auto peer = item->history()->peer; const auto emoji = from->emoji; const auto requestId = _session->api().request(MTPmessages_SetTyping( MTP_flags(0), peer->input, MTPint(), // top_msg_id MTP_sendMessageEmojiInteraction( MTP_string(emoji->text()), MTP_int(item->id), MTP_dataJSON(MTP_bytes(ToJson(bunch)))) )).done([=](const MTPBool &result, mtpRequestId requestId) { auto &sent = _playsSent[peer][emoji]; if (sent.lastRequestId == requestId) { sent.lastDoneReceivedAt = crl::now(); if (!_checkTimer.isActive()) { _checkTimer.callOnce(kAcceptSeenSinceRequest); } } }).send(); _playsSent[peer][emoji] = PlaySent{ .lastRequestId = requestId }; animations.erase(from, till); } void EmojiInteractions::clearAccumulatedIncoming( crl::time now, std::vector &animations) { Expects(!animations.empty()); const auto from = begin(animations); const auto till = ranges::find_if(animations, [&](const auto &animation) { return !animation.startedAt || (animation.startedAt + kMinDelay) > now; }); animations.erase(from, till); } auto EmojiInteractions::checkAccumulated(crl::time now) -> CheckResult { auto nearest = kTimeNever; for (auto i = begin(_outgoing); i != end(_outgoing);) { auto &[item, animations] = *i; sendAccumulatedOutgoing(now, item, animations); if (animations.empty()) { i = _outgoing.erase(i); continue; } else if (const auto firstStartedAt = animations.front().startedAt) { nearest = std::min(nearest, firstStartedAt + kAccumulateDelay); Assert(nearest > now); } ++i; } for (auto i = begin(_incoming); i != end(_incoming);) { auto &[item, animations] = *i; clearAccumulatedIncoming(now, animations); if (animations.empty()) { i = _incoming.erase(i); continue; } else { // Doesn't really matter when, just clear them finally. nearest = std::min(nearest, now + kAccumulateDelay); } ++i; } return { .nextCheckAt = nearest, }; } void EmojiInteractions::check(crl::time now) { if (!now) { now = crl::now(); } checkSeenRequests(now); checkSentRequests(now); const auto result1 = checkAnimations(now); const auto result2 = checkAccumulated(now); const auto result = Combine(result1, result2); if (result.nextCheckAt < kTimeNever) { Assert(result.nextCheckAt > now); _checkTimer.callOnce(result.nextCheckAt - now); } else if (!_playStarted.empty()) { _checkTimer.callOnce(kAccumulateSeenRequests); } else if (!_playsSent.empty()) { _checkTimer.callOnce(kAcceptSeenSinceRequest); } setWaitingForDownload(result.waitingForDownload); } void EmojiInteractions::checkSeenRequests(crl::time now) { for (auto i = begin(_playStarted); i != end(_playStarted);) { for (auto j = begin(i->second); j != end(i->second);) { if (j->second + kAccumulateSeenRequests <= now) { j = i->second.erase(j); } else { ++j; } } if (i->second.empty()) { i = _playStarted.erase(i); } else { ++i; } } } void EmojiInteractions::checkSentRequests(crl::time now) { for (auto i = begin(_playsSent); i != end(_playsSent);) { for (auto j = begin(i->second); j != end(i->second);) { const auto last = j->second.lastDoneReceivedAt; if (last && last + kAcceptSeenSinceRequest <= now) { j = i->second.erase(j); } else { ++j; } } if (i->second.empty()) { i = _playsSent.erase(i); } else { ++i; } } } void EmojiInteractions::setWaitingForDownload(bool waiting) { if (_waitingForDownload == waiting) { return; } _waitingForDownload = waiting; if (_waitingForDownload) { _session->downloaderTaskFinished( ) | rpl::start_with_next([=] { check(); }, _downloadCheckLifetime); } else { _downloadCheckLifetime.destroy(); _downloadCheckLifetime.destroy(); } } void EmojiInteractions::playStarted(not_null peer, QString emoji) { auto &map = _playStarted[peer]; const auto i = map.find(emoji); const auto now = crl::now(); if (i != end(map) && now - i->second < kAccumulateSeenRequests) { return; } _session->api().request(MTPmessages_SetTyping( MTP_flags(0), peer->input, MTPint(), // top_msg_id MTP_sendMessageEmojiInteractionSeen(MTP_string(emoji)) )).send(); map[emoji] = now; if (!_checkTimer.isActive()) { _checkTimer.callOnce(kAccumulateSeenRequests); } } EmojiInteractionsBunch EmojiInteractions::Parse(const QByteArray &json) { auto error = QJsonParseError{ 0, QJsonParseError::NoError }; const auto document = QJsonDocument::fromJson(json, &error); if (error.error != QJsonParseError::NoError || !document.isObject()) { LOG(("API Error: Bad interactions json received.")); return {}; } const auto root = document.object(); const auto version = root.value("v").toInt(); if (version != kJsonVersion) { LOG(("API Error: Bad interactions version: %1").arg(version)); return {}; } const auto actions = root.value("a").toArray(); if (actions.empty()) { LOG(("API Error: Empty interactions list.")); return {}; } auto result = EmojiInteractionsBunch(); for (const auto &interaction : actions) { const auto object = interaction.toObject(); const auto index = object.value("i").toInt(); if (index < 0 || index > 10) { LOG(("API Error: Bad interaction index: %1").arg(index)); return {}; } const auto time = object.value("t").toDouble(); if (time < 0. || time > 1. || (!result.interactions.empty() && time <= result.interactions.back().time)) { LOG(("API Error: Bad interaction time: %1").arg(time)); continue; } result.interactions.push_back({ .index = index, .time = time }); } return result; } QByteArray EmojiInteractions::ToJson(const EmojiInteractionsBunch &bunch) { auto list = QJsonArray(); for (const auto &single : bunch.interactions) { list.push_back(QJsonObject{ { "i", single.index }, { "t", single.time }, }); } return QJsonDocument(QJsonObject{ { "v", kJsonVersion }, { "a", std::move(list) }, }).toJson(QJsonDocument::Compact); } } // namespace ChatHelpers