diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 98f8361052..6651b2477c 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -163,6 +163,8 @@ PRIVATE api/api_premium.h api/api_premium_option.cpp api/api_premium_option.h + api/api_read_metrics.cpp + api/api_read_metrics.h api/api_report.cpp api/api_report.h api/api_ringtones.cpp @@ -965,6 +967,8 @@ PRIVATE history/view/history_view_quick_action.h history/view/history_view_reaction_preview.cpp history/view/history_view_reaction_preview.h + history/view/history_view_read_metrics_tracker.cpp + history/view/history_view_read_metrics_tracker.h history/view/history_view_reply.cpp history/view/history_view_reply.h history/view/history_view_reply_button.cpp diff --git a/Telegram/SourceFiles/api/api_read_metrics.cpp b/Telegram/SourceFiles/api/api_read_metrics.cpp new file mode 100644 index 0000000000..8d2f03c3ef --- /dev/null +++ b/Telegram/SourceFiles/api/api_read_metrics.cpp @@ -0,0 +1,73 @@ +/* +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 "api/api_read_metrics.h" + +#include "apiwrap.h" +#include "data/data_peer.h" + +namespace Api { +namespace { + +constexpr auto kSendTimeout = crl::time(5000); + +} // namespace + +ReadMetrics::ReadMetrics(not_null api) +: _api(&api->instance()) +, _timer([=] { send(); }) { +} + +void ReadMetrics::add( + not_null peer, + FinalizedReadMetric metric) { + _pending[peer].push_back(metric); + if (!_timer.isActive()) { + _timer.callOnce(kSendTimeout); + } +} + +void ReadMetrics::send() { + for (auto i = _pending.begin(); i != _pending.end();) { + if (_requests.contains(i->first)) { + ++i; + continue; + } + + auto metrics = QVector(); + metrics.reserve(i->second.size()); + for (const auto &m : i->second) { + metrics.push_back(MTP_inputMessageReadMetric( + MTP_int(m.msgId.bare), + MTP_long(m.viewId), + MTP_int(m.timeInViewMs), + MTP_int(m.activeTimeInViewMs), + MTP_int(m.heightToViewportRatioPermille), + MTP_int(m.seenRangeRatioPermille))); + } + const auto peer = i->first; + const auto finish = [=] { + _requests.erase(peer); + if (!_pending.empty() && !_timer.isActive()) { + _timer.callOnce(kSendTimeout); + } + }; + const auto requestId = _api.request(MTPmessages_ReportReadMetrics( + peer->input(), + MTP_vector(std::move(metrics)) + )).done([=](const MTPBool &) { + finish(); + }).fail([=](const MTP::Error &) { + finish(); + }).send(); + + _requests.emplace(peer, requestId); + i = _pending.erase(i); + } +} + +} // namespace Api diff --git a/Telegram/SourceFiles/api/api_read_metrics.h b/Telegram/SourceFiles/api/api_read_metrics.h new file mode 100644 index 0000000000..dcac63db29 --- /dev/null +++ b/Telegram/SourceFiles/api/api_read_metrics.h @@ -0,0 +1,45 @@ +/* +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 +*/ +#pragma once + +#include "mtproto/sender.h" +#include "base/timer.h" + +class ApiWrap; +class PeerData; + +namespace Api { + +struct FinalizedReadMetric { + MsgId msgId = 0; + uint64 viewId = 0; + int timeInViewMs = 0; + int activeTimeInViewMs = 0; + int heightToViewportRatioPermille = 0; + int seenRangeRatioPermille = 0; +}; + +class ReadMetrics final { +public: + explicit ReadMetrics(not_null api); + + void add(not_null peer, FinalizedReadMetric metric); + +private: + void send(); + + MTP::Sender _api; + base::flat_map< + not_null, + std::vector> _pending; + base::flat_map, mtpRequestId> _requests; + base::Timer _timer; + +}; + +} // namespace Api diff --git a/Telegram/SourceFiles/apiwrap.cpp b/Telegram/SourceFiles/apiwrap.cpp index eff428a778..000120ca19 100644 --- a/Telegram/SourceFiles/apiwrap.cpp +++ b/Telegram/SourceFiles/apiwrap.cpp @@ -27,6 +27,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_global_privacy.h" #include "api/api_updates.h" #include "api/api_user_privacy.h" +#include "api/api_read_metrics.h" #include "api/api_views.h" #include "api/api_confirm_phone.h" #include "api/api_unread_things.h" @@ -181,6 +182,7 @@ ApiWrap::ApiWrap(not_null session) , _inviteLinks(std::make_unique(this)) , _chatLinks(std::make_unique(this)) , _views(std::make_unique(this)) +, _readMetrics(std::make_unique(this)) , _confirmPhone(std::make_unique(this)) , _peerPhoto(std::make_unique(this)) , _polls(std::make_unique(this)) @@ -4976,6 +4978,10 @@ Api::ViewsManager &ApiWrap::views() { return *_views; } +Api::ReadMetrics &ApiWrap::readMetrics() { + return *_readMetrics; +} + Api::ConfirmPhone &ApiWrap::confirmPhone() { return *_confirmPhone; } diff --git a/Telegram/SourceFiles/apiwrap.h b/Telegram/SourceFiles/apiwrap.h index 52e846525b..19e43e5d3a 100644 --- a/Telegram/SourceFiles/apiwrap.h +++ b/Telegram/SourceFiles/apiwrap.h @@ -83,6 +83,7 @@ class UnreadThings; class Ringtones; class Transcribes; class Premium; +class ReadMetrics; class Usernames; class Websites; @@ -414,6 +415,7 @@ public: [[nodiscard]] Api::InviteLinks &inviteLinks(); [[nodiscard]] Api::ChatLinks &chatLinks(); [[nodiscard]] Api::ViewsManager &views(); + [[nodiscard]] Api::ReadMetrics &readMetrics(); [[nodiscard]] Api::ConfirmPhone &confirmPhone(); [[nodiscard]] Api::PeerPhoto &peerPhoto(); [[nodiscard]] Api::Polls &polls(); @@ -772,6 +774,7 @@ private: const std::unique_ptr _inviteLinks; const std::unique_ptr _chatLinks; const std::unique_ptr _views; + const std::unique_ptr _readMetrics; const std::unique_ptr _confirmPhone; const std::unique_ptr _peerPhoto; const std::unique_ptr _polls; diff --git a/Telegram/SourceFiles/core/application.cpp b/Telegram/SourceFiles/core/application.cpp index 9b5835b6c7..46508d3da6 100644 --- a/Telegram/SourceFiles/core/application.cpp +++ b/Telegram/SourceFiles/core/application.cpp @@ -645,6 +645,7 @@ bool Application::eventFilter(QObject *object, QEvent *e) { switch (e->type()) { case QEvent::KeyPress: { updateNonIdle(); + _inAppKeyPressed.fire({}); const auto event = static_cast(e); if (base::Platform::GlobalShortcuts::IsToggleFullScreenKey(event) && toggleActiveWindowFullScreen()) { @@ -1224,6 +1225,10 @@ crl::time Application::lastNonIdleTime() const { _lastNonIdleTime); } +rpl::producer<> Application::inAppKeyPressed() const { + return _inAppKeyPressed.events(); +} + rpl::producer Application::passcodeLockChanges() const { return _passcodeLock.changes(); } diff --git a/Telegram/SourceFiles/core/application.h b/Telegram/SourceFiles/core/application.h index 15f514582e..4ce78c8074 100644 --- a/Telegram/SourceFiles/core/application.h +++ b/Telegram/SourceFiles/core/application.h @@ -329,6 +329,7 @@ public: void handleAppActivated(); void handleAppDeactivated(); [[nodiscard]] rpl::producer appDeactivatedValue() const; + [[nodiscard]] rpl::producer<> inAppKeyPressed() const; void materializeLocalDrafts(); [[nodiscard]] rpl::producer<> materializeLocalDraftsRequests() const; @@ -464,6 +465,7 @@ private: base::flat_map, LeaveFilter> _leaveFilters; rpl::event_stream _openInMediaViewRequests; + rpl::event_stream<> _inAppKeyPressed; rpl::event_stream<> _materializeLocalDraftsRequests; diff --git a/Telegram/SourceFiles/history/history_inner_widget.cpp b/Telegram/SourceFiles/history/history_inner_widget.cpp index c513b268fd..79dff4163f 100644 --- a/Telegram/SourceFiles/history/history_inner_widget.cpp +++ b/Telegram/SourceFiles/history/history_inner_widget.cpp @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history_inner_widget.h" #include "chat_helpers/stickers_emoji_pack.h" +#include "core/application.h" #include "core/file_utilities.h" #include "core/click_handler_types.h" #include "core/phone_click_handler.h" @@ -69,6 +70,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "chat_helpers/emoji_interactions.h" #include "history/history_widget.h" #include "history/view/history_view_translate_tracker.h" +#include "history/view/history_view_read_metrics_tracker.h" #include "base/platform/base_platform_info.h" #include "base/qt/qt_common_adapters.h" #include "base/qt/qt_key_modifiers.h" @@ -111,6 +113,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include #include +#include #include namespace { @@ -325,6 +328,8 @@ HistoryInner::HistoryInner( [=](not_null view) { return itemTop(view); })) , _migrated(history->migrateFrom()) , _translateTracker(std::make_unique(history)) +, _readMetricsTracker(std::make_unique( + _peer)) , _pathGradient( HistoryView::MakePathShiftGradient( controller->chatStyle(), @@ -365,6 +370,10 @@ HistoryInner::HistoryInner( refreshAboutView(); setMouseTracking(true); + Core::App().inAppKeyPressed( + ) | rpl::on_next([=] { + registerReadMetricsActivity(); + }, lifetime()); _controller->gifPauseLevelChanged( ) | rpl::on_next([=] { if (!elementAnimationsPaused()) { @@ -419,9 +428,12 @@ HistoryInner::HistoryInner( }, lifetime()); session().data().viewLayoutChanged( ) | rpl::filter([=](not_null view) { - return (view == viewByItem(view->data())) && view->isUnderCursor(); + return (view == viewByItem(view->data())); }) | rpl::on_next([=](not_null view) { - mouseActionUpdate(); + markReadMetricsStale(); + if (view->isUnderCursor()) { + mouseActionUpdate(); + } }, lifetime()); session().data().itemDataChanges( @@ -1134,8 +1146,12 @@ void HistoryInner::startEffectOnRead(not_null item) { } void HistoryInner::paintEvent(QPaintEvent *e) { - if (_controller->contentOverlapped(this, e) - || hasPendingResizedItems()) { + const auto overlapped = _controller->contentOverlapped(this, e); + const auto pendingResized = hasPendingResizedItems(); + _readMetricsTracker->setScreenActive(!overlapped + && !pendingResized + && _widget->markingContentsRead()); + if (overlapped || pendingResized) { return; } else if (_recountedAfterPendingResizedItems) { _recountedAfterPendingResizedItems = false; @@ -1179,6 +1195,10 @@ void HistoryInner::paintEvent(QPaintEvent *e) { } _translateTracker->startBunch(); + const auto metricsStale = base::take(_readMetricsStale); + if (metricsStale) { + _readMetricsTracker->startBatch(_visibleAreaTop, _visibleAreaBottom); + } auto readTill = (HistoryItem*)nullptr; auto readContents = base::flat_set>(); auto startEffects = base::flat_set>(); @@ -1187,6 +1207,9 @@ void HistoryInner::paintEvent(QPaintEvent *e) { if (_pinnedItem) { _translateTracker->add(_pinnedItem); } + if (metricsStale) { + _readMetricsTracker->endBatch(); + } _translateTracker->finishBunch(); if (!startEffects.empty()) { for (const auto &view : startEffects) { @@ -1208,6 +1231,9 @@ void HistoryInner::paintEvent(QPaintEvent *e) { int height) { _translateTracker->add(view); const auto item = view->data(); + if (metricsStale && height > 0) { + _readMetricsTracker->push(item, top, height); + } const auto isSponsored = item->isSponsored(); const auto isUnread = !item->out() && item->unread(_history) @@ -1670,6 +1696,7 @@ void HistoryInner::touchEvent(QTouchEvent *e) { _touchPrevPos = _touchPos; _touchPos = e->touchPoints().cbegin()->screenPos().toPoint(); } + registerReadMetricsActivity(); switch (e->type()) { case QEvent::TouchBegin: { @@ -1792,6 +1819,7 @@ void HistoryInner::mouseMoveEvent(QMouseEvent *e) { } if (reallyMoved) { _mouseActive = true; + registerReadMetricsActivity(); lastGlobalPosition = e->globalPos(); if (!buttonsPressed || (_scrollDateLink && ClickHandler::getPressed() == _scrollDateLink)) { keepScrollDateForNow(); @@ -1851,6 +1879,7 @@ void HistoryInner::mousePressEvent(QMouseEvent *e) { return; } _mouseActive = true; + registerReadMetricsActivity(); mouseActionStart(e->globalPos(), e->button()); } @@ -2279,6 +2308,7 @@ void HistoryInner::mouseReleaseEvent(QMouseEvent *e) { e->accept(); return; } + registerReadMetricsActivity(); mouseActionFinish(e->globalPos(), e->button()); if (!rect().contains(e->pos())) { leaveEvent(e); @@ -2286,6 +2316,7 @@ void HistoryInner::mouseReleaseEvent(QMouseEvent *e) { } void HistoryInner::mouseDoubleClickEvent(QMouseEvent *e) { + registerReadMetricsActivity(); mouseActionStart(e->globalPos(), e->button()); const auto mouseActionView = viewByItem(_mouseActionItem); @@ -3641,6 +3672,17 @@ void HistoryInner::keyPressEvent(QKeyEvent *e) { } } +void HistoryInner::markReadMetricsStale() { + _readMetricsStale = true; + update(); +} + +void HistoryInner::registerReadMetricsActivity() { + if (_widget->markingContentsRead()) { + _readMetricsTracker->registerActivity(); + } +} + void HistoryInner::checkActivation() { if (!_widget->markingMessagesRead()) { return; @@ -3771,6 +3813,8 @@ void HistoryInner::visibleAreaUpdated(int top, int bottom) { auto scrolledUp = (top < _visibleAreaTop); _visibleAreaTop = top; _visibleAreaBottom = bottom; + markReadMetricsStale(); + registerReadMetricsActivity(); const auto visibleAreaHeight = bottom - top; // if history has pending resize events we should not update scrollTopItem diff --git a/Telegram/SourceFiles/history/history_inner_widget.h b/Telegram/SourceFiles/history/history_inner_widget.h index de07761685..60781aaa05 100644 --- a/Telegram/SourceFiles/history/history_inner_widget.h +++ b/Telegram/SourceFiles/history/history_inner_widget.h @@ -40,6 +40,7 @@ enum class ElementChatMode : char; class EmptyPainter; class Element; class TranslateTracker; +class ReadMetricsTracker; struct PinnedId; struct SelectedQuote; class AboutView; @@ -256,6 +257,8 @@ protected: private: void onTouchSelect(); void onTouchScrollTimer(); + void markReadMetricsStale(); + void registerReadMetricsActivity(); [[nodiscard]] static int SelectionViewOffset( not_null inner, @@ -497,6 +500,8 @@ private: std::unique_ptr _aboutView; std::unique_ptr _emptyPainter; std::unique_ptr _translateTracker; + std::unique_ptr _readMetricsTracker; + bool _readMetricsStale = false; rpl::event_stream> _sendIntroSticker; mutable History *_curHistory = nullptr; diff --git a/Telegram/SourceFiles/history/view/history_view_list_widget.cpp b/Telegram/SourceFiles/history/view/history_view_list_widget.cpp index 01d49dfaa5..4ef8b40ab3 100644 --- a/Telegram/SourceFiles/history/view/history_view_list_widget.cpp +++ b/Telegram/SourceFiles/history/view/history_view_list_widget.cpp @@ -10,6 +10,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/unixtime.h" #include "base/qt/qt_key_modifiers.h" #include "base/qt/qt_common_adapters.h" +#include "history/history.h" #include "history/history_item.h" #include "history/history_item_components.h" #include "history/history_item_helpers.h" @@ -27,6 +28,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/view/history_view_service_message.h" #include "history/view/history_view_cursor_state.h" #include "history/view/history_view_translate_tracker.h" +#include "history/view/history_view_read_metrics_tracker.h" #include "history/view/history_view_top_peers_selector.h" #include "history/view/history_view_quick_action.h" #include "chat_helpers/message_field.h" @@ -99,6 +101,16 @@ constexpr auto kClearUserpicsAfter = 50; return history ? std::make_unique(history) : nullptr; } +[[nodiscard]] std::unique_ptr MaybeReadMetricsTracker( + Context context, + History *history) { + if (!history + || (context != Context::History && context != Context::Replies)) { + return nullptr; + } + return std::make_unique(history->peer); +} + } // namespace WindowListDelegate::WindowListDelegate( @@ -409,6 +421,9 @@ ListWidget::ListWidget( , _replyButtonManager(std::make_unique( [=](QRect updated) { update(updated); })) , _translateTracker(MaybeTranslateTracker(_delegate->listTranslateHistory())) +, _readMetricsTracker(MaybeReadMetricsTracker( + _delegate->listContext(), + _delegate->listTranslateHistory())) , _scrollDateCheck([this] { scrollDateCheck(); }) , _applyUpdatedScrollState([this] { applyUpdatedScrollState(); }) , _selectEnabled(_delegate->listAllowsMultiSelect()) @@ -419,12 +434,19 @@ ListWidget::ListWidget( , _touchSelectTimer([=] { onTouchSelect(); }) , _touchScrollTimer([=] { onTouchScrollTimer(); }) , _middleClickAutoscroll( - [=](int d) { _delegate->listScrollTo(_visibleTop + d, false); }, - [=](const QCursor &cursor) { setCursor(cursor); }, - [=] { mouseActionUpdate(QCursor::pos()); setCursor(_cursor); }, - [=] { return window()->isActiveWindow(); }) { + [=](int d) { _delegate->listScrollTo(_visibleTop + d, false); }, + [=](const QCursor &cursor) { setCursor(cursor); }, + [=] { mouseActionUpdate(QCursor::pos()); setCursor(_cursor); }, + [=] { return window()->isActiveWindow(); }) { setAttribute(Qt::WA_AcceptTouchEvents); setMouseTracking(true); + if (_readMetricsTracker) { + Core::App().inAppKeyPressed( + ) | rpl::on_next([=] { + registerReadMetricsActivity(); + }, lifetime()); + } + _scrollDateHideTimer.setCallback([this] { scrollDateHideByTimer(); }); _session->data().viewRepaintRequest( ) | rpl::on_next([this](Data::RequestViewRepaint data) { @@ -451,6 +473,7 @@ ListWidget::ListWidget( _session->data().viewLayoutChanged( ) | rpl::on_next([this](auto view) { if (view->delegate() == this) { + markReadMetricsStale(); if (view->isUnderCursor()) { mouseActionUpdate(); } @@ -1112,29 +1135,6 @@ int ListWidget::findNearestItem(Data::MessagePosition position) const { : int(after - begin(_items)); } -HistoryItemsList ListWidget::collectVisibleItems() const { - auto result = HistoryItemsList(); - const auto from = std::lower_bound( - begin(_items), - end(_items), - _visibleTop, - [this](auto &elem, int top) { - return this->itemTop(elem) + elem->height() <= top; - }); - const auto to = std::lower_bound( - begin(_items), - end(_items), - _visibleBottom, - [this](auto &elem, int bottom) { - return this->itemTop(elem) < bottom; - }); - result.reserve(to - from); - for (auto i = from; i != to; ++i) { - result.push_back((*i)->data()); - } - return result; -} - void ListWidget::visibleTopBottomUpdated( int visibleTop, int visibleBottom) { @@ -1146,6 +1146,8 @@ void ListWidget::visibleTopBottomUpdated( const auto scrolledUp = (visibleTop < _visibleTop); _visibleTop = visibleTop; _visibleBottom = visibleBottom; + markReadMetricsStale(); + registerReadMetricsActivity(); // Unload userpics. if (_userpics.size() > kClearUserpicsAfter) { @@ -2274,11 +2276,21 @@ void ListWidget::checkActivation() { } void ListWidget::paintEvent(QPaintEvent *e) { - if (_delegate->listIgnorePaintEvent(this, e)) { + const auto overlapped = _delegate->listIgnorePaintEvent(this, e); + if (_readMetricsTracker) { + _readMetricsTracker->setScreenActive( + !overlapped && markingContentsRead()); + } + if (overlapped) { return; } else if (_translateTracker) { _translateTracker->startBunch(); } + const auto metricsStale = _readMetricsTracker + && base::take(_readMetricsStale); + if (metricsStale) { + _readMetricsTracker->startBatch(_visibleTop, _visibleBottom); + } auto readTill = (HistoryItem*)nullptr; auto readContents = base::flat_set>(); const auto markingAsViewed = markingMessagesRead(); @@ -2287,6 +2299,9 @@ void ListWidget::paintEvent(QPaintEvent *e) { _delegate->listAddTranslatedItems(_translateTracker.get()); _translateTracker->finishBunch(); } + if (metricsStale) { + _readMetricsTracker->endBatch(); + } if (markingAsViewed && readTill) { _delegate->listMarkReadTill(readTill); } @@ -2345,6 +2360,9 @@ void ListWidget::paintEvent(QPaintEvent *e) { if (_translateTracker) { _translateTracker->add(view); } + if (metricsStale && height > 0) { + _readMetricsTracker->push(item, top, height); + } const auto isSponsored = item->isSponsored(); const auto isUnread = _delegate->listElementShownUnread(view) && item->isRegular(); @@ -2770,6 +2788,7 @@ auto ListWidget::scrollKeyEvents() const } void ListWidget::mouseDoubleClickEvent(QMouseEvent *e) { + registerReadMetricsActivity(); mouseActionStart(e->globalPos(), e->button()); trySwitchToWordSelection(); if (!ClickHandler::getActive() @@ -3057,6 +3076,7 @@ void ListWidget::mousePressEvent(QMouseEvent *e) { return; } _mouseActive = true; + registerReadMetricsActivity(); mouseActionStart(e->globalPos(), e->button()); } @@ -3160,6 +3180,7 @@ void ListWidget::touchEvent(QTouchEvent *e) { _touchPrevPos = _touchPos; _touchPos = e->touchPoints().cbegin()->screenPos().toPoint(); } + registerReadMetricsActivity(); switch (e->type()) { case QEvent::TouchBegin: { @@ -3273,6 +3294,7 @@ void ListWidget::mouseMoveEvent(QMouseEvent *e) { } if (reallyMoved) { _mouseActive = true; + registerReadMetricsActivity(); lastGlobalPosition = e->globalPos(); if (!buttonsPressed || (_scrollDateLink @@ -3292,12 +3314,24 @@ void ListWidget::mouseReleaseEvent(QMouseEvent *e) { e->accept(); return; } + registerReadMetricsActivity(); mouseActionFinish(e->globalPos(), e->button()); if (!rect().contains(e->pos())) { leaveEvent(e); } } +void ListWidget::markReadMetricsStale() { + _readMetricsStale = true; + update(); +} + +void ListWidget::registerReadMetricsActivity() { + if (_readMetricsTracker && markingContentsRead()) { + _readMetricsTracker->registerActivity(); + } +} + void ListWidget::touchScrollUpdated(const QPoint &screenPos) { _touchPos = screenPos; _delegate->listScrollTo( diff --git a/Telegram/SourceFiles/history/view/history_view_list_widget.h b/Telegram/SourceFiles/history/view/history_view_list_widget.h index 8de6f9682b..c8a4834194 100644 --- a/Telegram/SourceFiles/history/view/history_view_list_widget.h +++ b/Telegram/SourceFiles/history/view/history_view_list_widget.h @@ -68,6 +68,7 @@ struct TextState; struct StateRequest; class EmojiInteractions; class TranslateTracker; +class ReadMetricsTracker; enum class CursorState : char; enum class PointState : char; enum class Context : char; @@ -541,6 +542,8 @@ private: void onTouchSelect(); void onTouchScrollTimer(); + void markReadMetricsStale(); + void registerReadMetricsActivity(); void updateAroundPositionFromNearest(int nearestIndex); void refreshRows(const Data::MessagesSlice &old); @@ -599,7 +602,6 @@ private: [[nodiscard]] Element *strictFindItemByY(int y) const; [[nodiscard]] int findNearestItem(Data::MessagePosition position) const; void viewReplaced(not_null was, Element *now); - [[nodiscard]] HistoryItemsList collectVisibleItems() const; void checkMoveToOtherViewer(); void updateVisibleTopItem(); @@ -794,6 +796,8 @@ private: std::unique_ptr _replyButtonManager; std::unique_ptr _translateTracker; + std::unique_ptr _readMetricsTracker; + bool _readMetricsStale = false; int _minHeight = 0; int _visibleTop = 0; diff --git a/Telegram/SourceFiles/history/view/history_view_read_metrics_tracker.cpp b/Telegram/SourceFiles/history/view/history_view_read_metrics_tracker.cpp new file mode 100644 index 0000000000..3956be11f9 --- /dev/null +++ b/Telegram/SourceFiles/history/view/history_view_read_metrics_tracker.cpp @@ -0,0 +1,353 @@ +/* +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 "history/view/history_view_read_metrics_tracker.h" + +#include "api/api_read_metrics.h" +#include "apiwrap.h" +#include "base/random.h" +#include "core/application.h" +#include "data/data_peer.h" +#include "history/history_item.h" +#include "main/main_session.h" + +namespace HistoryView { +namespace { + +constexpr auto kGracePeriod = crl::time(300); +constexpr auto kActivityTimeout = 15 * crl::time(1000); +constexpr auto kMaxTrackingDuration = 5 * 60 * crl::time(1000); +constexpr auto kMinReportThreshold = crl::time(300); + +} // namespace + +ReadMetricsTracker::ReadMetricsTracker(not_null peer) +: _peer(peer) +, _timer([=] { onTimeout(); }) { + Core::App().appDeactivatedValue( + ) | rpl::on_next([=](bool deactivated) { + const auto appActive = !deactivated; + if (_appActive == appActive) { + return; + } + _appActive = appActive; + refreshPaused(crl::now()); + }, _lifetime); +} + +ReadMetricsTracker::~ReadMetricsTracker() { + finalizeAll(); +} + +void ReadMetricsTracker::startBatch(int visibleTop, int visibleBottom) { + _batchNow = crl::now(); + sync(_batchNow); + _batchViewportHeight = visibleBottom - visibleTop; + _batchVisibleTop = visibleTop; + _batchVisibleBottom = visibleBottom; + _batchVisible.clear(); +} + +void ReadMetricsTracker::push( + not_null item, + int itemTop, + int itemHeight) { + if (!ShouldTrack(item)) { + return; + } + const auto msgId = item->id; + _batchVisible.emplace(msgId); + + const auto clippedTop = std::max(itemTop, _batchVisibleTop) - itemTop; + const auto clippedBottom = std::min( + itemTop + itemHeight, + _batchVisibleBottom) - itemTop; + + const auto addTracked = [&] { + auto tracked = TrackedItem(); + tracked.entryGracePending = true; + tracked.entryGraceStart = _batchNow; + tracked.maxItemHeight = itemHeight; + tracked.maxViewportHeight = _batchViewportHeight; + tracked.seenTop = clippedTop; + tracked.seenBottom = clippedBottom; + _tracked.emplace(msgId, tracked); + }; + + auto it = _tracked.find(msgId); + if (it == end(_tracked)) { + addTracked(); + return; + } + auto &tracked = it->second; + if (tracked.exitGracePending) { + if (_batchNow - tracked.exitGraceStart >= kGracePeriod) { + finalize(msgId, tracked); + _tracked.erase(it); + addTracked(); + return; + } + tracked.exitGracePending = false; + tracked.exitGraceStart = 0; + tracked.lastUpdate = _batchNow; + } + tracked.seenTop = std::min(tracked.seenTop, clippedTop); + tracked.seenBottom = std::max(tracked.seenBottom, clippedBottom); + accumulate_max(tracked.maxItemHeight, itemHeight); + accumulate_max(tracked.maxViewportHeight, _batchViewportHeight); +} + +void ReadMetricsTracker::endBatch() { + for (auto it = _tracked.begin(); it != _tracked.end();) { + if (_batchVisible.contains(it->first)) { + ++it; + continue; + } + auto &tracked = it->second; + if (tracked.entryGracePending) { + it = _tracked.erase(it); + continue; + } + if (!tracked.exitGracePending) { + tracked.exitGracePending = true; + tracked.exitGraceStart = _batchNow; + } + ++it; + } + + _currentlyVisible = std::move(_batchVisible); + restartTimer(); +} + +void ReadMetricsTracker::registerActivity() { + if (!_appActive || !_screenActive) { + return; + } + const auto now = crl::now(); + const auto activityDeadline = activeUntil(); + if (activityDeadline && activityDeadline < now) { + sync(now); + } + _lastActivity = now; + restartTimer(); +} + +void ReadMetricsTracker::setScreenActive(bool active) { + if (_screenActive == active) { + return; + } + _screenActive = active; + refreshPaused(crl::now()); +} + +void ReadMetricsTracker::pauseTracking() { + setScreenActive(false); +} + +void ReadMetricsTracker::resumeTracking() { + setScreenActive(true); +} + +void ReadMetricsTracker::onTimeout() { + sync(crl::now()); +} + +void ReadMetricsTracker::sync(crl::time now) { + const auto activeUntil = this->activeUntil(); + processTransitions(now, activeUntil); + accumulate(now, activeUntil); + restartTimer(); +} + +void ReadMetricsTracker::processTransitions( + crl::time now, + crl::time activeUntil) { + for (auto it = _tracked.begin(); it != _tracked.end();) { + auto &tracked = it->second; + if (tracked.entryGracePending + && !_paused + && now - tracked.entryGraceStart >= kGracePeriod) { + tracked.entryGracePending = false; + do { + tracked.viewId = base::RandomValue(); + } while (!tracked.viewId); + tracked.trackingStarted = tracked.entryGraceStart; + tracked.lastUpdate = tracked.trackingStarted; + } + if (tracked.exitGracePending + && !_paused + && now - tracked.exitGraceStart >= kGracePeriod) { + finalize(it->first, tracked); + it = _tracked.erase(it); + continue; + } + if (tracked.viewId && !tracked.entryGracePending && !tracked.exitGracePending) { + const auto deadline = tracked.trackingStarted + kMaxTrackingDuration; + if (now >= deadline) { + if (!_paused + && _currentlyVisible.contains(it->first) + && tracked.lastUpdate < deadline) { + addElapsed(tracked, tracked.lastUpdate, deadline, activeUntil); + tracked.lastUpdate = deadline; + } + finalize(it->first, tracked); + it = _tracked.erase(it); + continue; + } + } + ++it; + } +} + +void ReadMetricsTracker::accumulate(crl::time now, crl::time activeUntil) { + for (auto &[msgId, tracked] : _tracked) { + if (!tracked.viewId + || tracked.entryGracePending + || tracked.exitGracePending + || tracked.lastUpdate <= 0) { + continue; + } + if (_paused || !_currentlyVisible.contains(msgId)) { + tracked.lastUpdate = now; + continue; + } + addElapsed(tracked, tracked.lastUpdate, now, activeUntil); + tracked.lastUpdate = now; + } +} + +void ReadMetricsTracker::refreshPaused(crl::time now) { + const auto paused = !_appActive || !_screenActive; + if (_paused == paused) { + return; + } else if (paused) { + const auto activeUntil = this->activeUntil(); + processTransitions(now, activeUntil); + accumulate(now, activeUntil); + _pausedSince = now; + for (auto &[msgId, tracked] : _tracked) { + if (tracked.entryGracePending || tracked.exitGracePending) { + continue; + } + tracked.lastUpdate = now; + } + } else { + const auto delta = now - _pausedSince; + for (auto &[msgId, tracked] : _tracked) { + if (tracked.entryGracePending) { + tracked.entryGraceStart = (tracked.entryGraceStart < _pausedSince) + ? (tracked.entryGraceStart + delta) + : now; + } else if (tracked.exitGracePending) { + tracked.exitGraceStart = (tracked.exitGraceStart < _pausedSince) + ? (tracked.exitGraceStart + delta) + : now; + } else if (tracked.viewId) { + tracked.lastUpdate = now; + } + } + _pausedSince = 0; + } + _paused = paused; + restartTimer(); +} + +void ReadMetricsTracker::restartTimer() { + if (_tracked.empty()) { + _timer.cancel(); + return; + } + const auto now = crl::now(); + auto nearest = crl::time(0); + const auto updateNearest = [&](crl::time deadline) { + if (!deadline) { + return; + } + if (!nearest || deadline < nearest) { + nearest = deadline; + } + }; + + const auto activityDeadline = activeUntil(); + for (const auto &[msgId, tracked] : _tracked) { + if (tracked.entryGracePending && !_paused) { + updateNearest(tracked.entryGraceStart + kGracePeriod); + } else if (tracked.exitGracePending && !_paused) { + updateNearest(tracked.exitGraceStart + kGracePeriod); + } else if (tracked.viewId) { + updateNearest(tracked.trackingStarted + kMaxTrackingDuration); + if (!_paused && _currentlyVisible.contains(msgId) && activityDeadline > now) { + updateNearest(activityDeadline); + } + } + } + if (!nearest) { + _timer.cancel(); + return; + } + _timer.callOnce(std::max(nearest - now, crl::time(0))); +} + +crl::time ReadMetricsTracker::activeUntil() const { + return _lastActivity ? (_lastActivity + kActivityTimeout) : 0; +} + +void ReadMetricsTracker::addElapsed( + TrackedItem &tracked, + crl::time from, + crl::time till, + crl::time activeUntil) const { + if (till <= from) { + return; + } + tracked.totalInView += (till - from); + if (activeUntil > from) { + tracked.activeInView += std::max(std::min(till, activeUntil) - from, crl::time(0)); + tracked.activeInView = std::min(tracked.activeInView, tracked.totalInView); + } +} + +void ReadMetricsTracker::finalize( + MsgId msgId, + const TrackedItem &tracked) { + if (tracked.viewId == 0 || tracked.totalInView < kMinReportThreshold) { + return; + } + const auto heightRatio = (tracked.maxViewportHeight > 0) + ? qRound((tracked.maxItemHeight * 1000.0) / tracked.maxViewportHeight) + : 0; + const auto seenRange = (tracked.maxItemHeight > 0) + ? std::clamp( + qRound(((tracked.seenBottom - tracked.seenTop) * 1000.0) + / tracked.maxItemHeight), + 0, + 1000) + : 0; + _peer->session().api().readMetrics().add(_peer, { + .msgId = msgId, + .viewId = tracked.viewId, + .timeInViewMs = int(tracked.totalInView), + .activeTimeInViewMs = int(tracked.activeInView), + .heightToViewportRatioPermille = heightRatio, + .seenRangeRatioPermille = seenRange, + }); +} + +void ReadMetricsTracker::finalizeAll() { + sync(crl::now()); + for (const auto &[msgId, tracked] : _tracked) { + finalize(msgId, tracked); + } + _tracked.clear(); + _timer.cancel(); +} + +bool ReadMetricsTracker::ShouldTrack(not_null item) { + return item->isRegular() && item->hasViews(); +} + +} // namespace HistoryView diff --git a/Telegram/SourceFiles/history/view/history_view_read_metrics_tracker.h b/Telegram/SourceFiles/history/view/history_view_read_metrics_tracker.h new file mode 100644 index 0000000000..1be6087a7e --- /dev/null +++ b/Telegram/SourceFiles/history/view/history_view_read_metrics_tracker.h @@ -0,0 +1,81 @@ +/* +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 +*/ +#pragma once + +#include "base/timer.h" + +class PeerData; +class HistoryItem; + +namespace HistoryView { + +class ReadMetricsTracker final { +public: + explicit ReadMetricsTracker(not_null peer); + ~ReadMetricsTracker(); + + void startBatch(int visibleTop, int visibleBottom); + void push(not_null item, int itemTop, int itemHeight); + void endBatch(); + void registerActivity(); + void setScreenActive(bool active); + void pauseTracking(); + void resumeTracking(); + +private: + struct TrackedItem { + uint64 viewId = 0; + crl::time entryGraceStart = 0; + crl::time trackingStarted = 0; + crl::time lastUpdate = 0; + crl::time totalInView = 0; + crl::time activeInView = 0; + int seenTop = std::numeric_limits::max(); + int seenBottom = 0; + int maxItemHeight = 0; + int maxViewportHeight = 0; + bool entryGracePending = false; + bool exitGracePending = false; + crl::time exitGraceStart = 0; + }; + + void onTimeout(); + void sync(crl::time now); + void processTransitions(crl::time now, crl::time activeUntil); + void accumulate(crl::time now, crl::time activeUntil); + void refreshPaused(crl::time now); + void restartTimer(); + [[nodiscard]] crl::time activeUntil() const; + void addElapsed( + TrackedItem &tracked, + crl::time from, + crl::time till, + crl::time activeUntil) const; + void finalize(MsgId msgId, const TrackedItem &tracked); + void finalizeAll(); + [[nodiscard]] static bool ShouldTrack(not_null item); + + const not_null _peer; + base::flat_map _tracked; + base::flat_set _currentlyVisible; + crl::time _batchNow = 0; + int _batchViewportHeight = 0; + int _batchVisibleTop = 0; + int _batchVisibleBottom = 0; + base::flat_set _batchVisible; + crl::time _lastActivity = 0; + bool _appActive = true; + bool _screenActive = true; + bool _paused = false; + crl::time _pausedSince = 0; + base::Timer _timer; + rpl::lifetime _lifetime; + +}; + +} // namespace HistoryView