diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index ee65bcb8dc..4daae9e2fa 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -130,6 +130,8 @@ PRIVATE api/api_updates.h api/api_user_privacy.cpp api/api_user_privacy.h + api/api_who_read.cpp + api/api_who_read.h boxes/filters/edit_filter_box.cpp boxes/filters/edit_filter_box.h boxes/filters/edit_filter_chats_list.cpp diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index fde25b3c7f..ad95c3e93e 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -1704,6 +1704,12 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_context_reschedule_selected" = "Reschedule Selected"; "lng_context_delete_selected" = "Delete Selected"; "lng_context_clear_selection" = "Clear Selection"; +"lng_context_seen_loading" = "Loading..."; +"lng_context_seen_text#one" = "{count} Seen"; +"lng_context_seen_text#other" = "{count} Seen"; +"lng_context_seen_listened#one" = "{count} Listened"; +"lng_context_seen_listened#other" = "{count} Listened"; + "lng_send_image_empty" = "Could not send an empty file: {name}"; "lng_send_image_too_large" = "Could not send a file, because it is larger than 1500 MB: {name}"; "lng_send_images_selected#one" = "{count} image selected"; diff --git a/Telegram/SourceFiles/api/api_who_read.cpp b/Telegram/SourceFiles/api/api_who_read.cpp new file mode 100644 index 0000000000..b6f79cbaf1 --- /dev/null +++ b/Telegram/SourceFiles/api/api_who_read.cpp @@ -0,0 +1,193 @@ +/* +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_who_read.h" + +#include "history/history_item.h" +#include "history/history.h" +#include "data/data_peer.h" +#include "data/data_chat.h" +#include "data/data_channel.h" +#include "data/data_user.h" +#include "data/data_changes.h" +#include "data/data_session.h" +#include "main/main_app_config.h" +#include "main/main_session.h" +#include "main/main_account.h" +#include "base/unixtime.h" +#include "ui/controls/who_read_context_action.h" +#include "apiwrap.h" + +namespace Api { +namespace { + +struct Cached { + rpl::variable> list; + mtpRequestId requestId = 0; +}; + +struct Context { + base::flat_map, Cached> cached; + base::flat_map, rpl::lifetime> subscriptions; +}; + +[[nodiscard]] auto Contexts() +-> base::flat_map, std::unique_ptr> & { + static auto result = base::flat_map< + not_null, + std::unique_ptr>(); + return result; +} + +[[nodiscard]] not_null ContextAt(not_null key) { + auto &contexts = Contexts(); + const auto i = contexts.find(key); + if (i != end(contexts)) { + return i->second.get(); + } + const auto result = contexts.emplace( + key, + std::make_unique()).first->second.get(); + QObject::connect(key.get(), &QObject::destroyed, [=] { + auto &contexts = Contexts(); + const auto i = contexts.find(key); + for (auto &[item, entry] : i->second->cached) { + if (const auto requestId = entry.requestId) { + item->history()->session().api().request(requestId).cancel(); + } + } + contexts.erase(i); + }); + return result; +} + +} // namespace + +bool WhoReadExists(not_null item) { + if (!item->out() || item->unread()) { + return false; + } + const auto history = item->history(); + const auto peer = history->peer; + const auto chat = peer->asChat(); + const auto megagroup = peer->asMegagroup(); + if (!chat && !megagroup) { + return false; + } else if (peer->migrateTo()) { + // They're all always marked as read. + // We don't know if there really are any readers. + return false; + } + const auto &appConfig = peer->session().account().appConfig(); + const auto expirePeriod = TimeId(appConfig.get( + "chat_read_mark_expire_period", + 7 * 86400.)); + if (item->date() + expirePeriod <= base::unixtime::now()) { + return false; + } + const auto maxCount = int(appConfig.get( + "chat_read_mark_size_threshold", + 50)); + const auto count = megagroup ? megagroup->membersCount() : chat->count; + if (count <= 0 || count > maxCount) { + return false; + } + return true; +} + +rpl::producer> WhoReadIds( + not_null item, + not_null context) { + auto weak = QPointer(context.get()); + const auto fullId = item->fullId(); + const auto session = &item->history()->session(); + return [=](auto consumer) { + if (!weak) { + return rpl::lifetime(); + } + const auto context = ContextAt(weak.data()); + if (!context->subscriptions.contains(session)) { + session->changes().messageUpdates( + Data::MessageUpdate::Flag::Destroyed + ) | rpl::start_with_next([=](const Data::MessageUpdate &update) { + const auto i = context->cached.find(update.item); + if (i == end(context->cached)) { + return; + } + session->api().request(i->second.requestId).cancel(); + context->cached.erase(i); + }, context->subscriptions[session]); + } + auto &cache = context->cached[item]; + if (!cache.requestId) { + const auto makeEmpty = [=] { + // Special value that marks a validated empty list. + return std::vector{ + item->history()->session().userId() + }; + }; + cache.requestId = session->api().request( + MTPmessages_GetMessageReadParticipants( + item->history()->peer->input, + MTP_int(item->id) + ) + ).done([=](const MTPVector &result) { + auto users = std::vector(); + users.reserve(std::max(result.v.size(), 1)); + for (const auto &id : result.v) { + users.push_back(UserId(id)); + } + if (users.empty()) { + + } + context->cached[item].list = users.empty() + ? makeEmpty() + : std::move(users); + }).fail([=](const MTP::Error &error) { + if (context->cached[item].list.current().empty()) { + context->cached[item].list = makeEmpty(); + } + }).send(); + } + return cache.list.value().start_existing(consumer); + }; +} + +rpl::producer WhoRead( + not_null item, + not_null context) { + return WhoReadIds( + item, + context + ) | rpl::map([=](const std::vector &users) { + const auto owner = &item->history()->owner(); + if (users.empty()) { + return Ui::WhoReadContent{ .unknown = true }; + } + const auto nobody = (users.size() == 1) + && (users.front() == owner->session().userId()); + if (nobody) { + return Ui::WhoReadContent(); + } + auto participants = ranges::views::all( + users + ) | ranges::views::transform([&](UserId id) { + return owner->userLoaded(id); + }) | ranges::views::filter([](UserData *user) { + return user != nullptr; + }) | ranges::views::transform([](UserData *user) { + return Ui::WhoReadParticipant{ + .name = user->name, + }; + }) | ranges::to_vector; + return Ui::WhoReadContent{ + .participants = std::move(participants), + }; + }); +} + +} // namespace Api diff --git a/Telegram/SourceFiles/api/api_who_read.h b/Telegram/SourceFiles/api/api_who_read.h new file mode 100644 index 0000000000..4e7fe7ed12 --- /dev/null +++ b/Telegram/SourceFiles/api/api_who_read.h @@ -0,0 +1,25 @@ +/* +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 + +class HistoryItem; + +namespace Ui { +struct WhoReadContent; +} // namespace Ui + +namespace Api { + +[[nodiscard]] bool WhoReadExists(not_null item); + +// The context must be destroyed before the session holding this item. +[[nodiscard]] rpl::producer WhoRead( + not_null item, + not_null context); // Cache results for this lifetime. + +} // namespace Api diff --git a/Telegram/SourceFiles/history/history_inner_widget.cpp b/Telegram/SourceFiles/history/history_inner_widget.cpp index d335d0f459..3f4df3b9a6 100644 --- a/Telegram/SourceFiles/history/history_inner_widget.cpp +++ b/Telegram/SourceFiles/history/history_inner_widget.cpp @@ -32,6 +32,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/boxes/report_box.h" #include "ui/layers/generic_box.h" #include "ui/controls/delete_message_context_action.h" +#include "ui/controls/who_read_context_action.h" #include "ui/ui_utility.h" #include "ui/cached_round_corners.h" #include "ui/inactive_press.h" @@ -55,6 +56,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "apiwrap.h" #include "api/api_attached_stickers.h" #include "api/api_toggling_media.h" +#include "api/api_who_read.h" #include "lang/lang_keys.h" #include "data/data_session.h" #include "data/data_media_types.h" @@ -1583,6 +1585,15 @@ void HistoryInner::showContextMenu(QContextMenuEvent *e, bool showFromTouch) { return; } const auto itemId = item->fullId(); + if (Api::WhoReadExists(item)) { + const auto participantChosen = [=](uint64 id) { + controller->showPeerInfo(PeerId(UserId(id))); + }; + _menu->addAction(Ui::WhoReadContextAction( + _menu.get(), + Api::WhoRead(item, this), + participantChosen)); + } if (canSendMessages) { _menu->addAction(tr::lng_context_reply_msg(tr::now), [=] { _widget->replyToMessage(itemId); diff --git a/Telegram/SourceFiles/ui/chat/chat.style b/Telegram/SourceFiles/ui/chat/chat.style index 95975d0697..3a5e0c060c 100644 --- a/Telegram/SourceFiles/ui/chat/chat.style +++ b/Telegram/SourceFiles/ui/chat/chat.style @@ -862,3 +862,10 @@ ttlDividerLabelPadding: margins(22px, 10px, 22px, 19px); ttlItemPadding: margins(0px, 4px, 0px, 4px); ttlItemTimerFont: font(12px); + +seenItemUserpics: GroupCallUserpics { + size: 32px; + shift: 12px; + stroke: 4px; + align: align(right); +} diff --git a/Telegram/SourceFiles/ui/controls/who_read_context_action.cpp b/Telegram/SourceFiles/ui/controls/who_read_context_action.cpp new file mode 100644 index 0000000000..65a6393db8 --- /dev/null +++ b/Telegram/SourceFiles/ui/controls/who_read_context_action.cpp @@ -0,0 +1,263 @@ +/* +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 "ui/controls/who_read_context_action.h" + +#include "ui/widgets/menu/menu_action.h" +#include "ui/widgets/popup_menu.h" +#include "ui/effects/ripple_animation.h" +#include "ui/chat/group_call_userpics.h" +#include "lang/lang_keys.h" +#include "styles/style_chat.h" + +namespace Ui { +namespace { + +constexpr auto kMaxUserpics = 3; + +class Action final : public Menu::ItemBase { +public: + Action( + not_null parentMenu, + rpl::producer content, + Fn participantChosen); + + bool isEnabled() const override; + not_null action() const override; + + void handleKeyPress(not_null e) override; + +protected: + QPoint prepareRippleStartPosition() const override; + QImage prepareRippleMask() const override; + + int contentHeight() const override; + +private: + void paint(Painter &p); + + void updateUserpicsFromContent(); + void setupSubMenu(); + void resolveMinWidth(); + void refreshText(); + void refreshDimensions(); + + const not_null _parentMenu; + const not_null _dummyAction; + const Fn _participantChosen; + const std::unique_ptr _userpics; + const style::Menu &_st; + + Text::String _text; + int _textWidth = 0; + const int _height = 0; + int _userpicsWidth = 0; + + WhoReadContent _content; + +}; + +TextParseOptions MenuTextOptions = { + TextParseLinks | TextParseRichText, // flags + 0, // maxw + 0, // maxh + Qt::LayoutDirectionAuto, // dir +}; + +Action::Action( + not_null parentMenu, + rpl::producer content, + Fn participantChosen) +: ItemBase(parentMenu->menu(), parentMenu->menu()->st()) +, _parentMenu(parentMenu) +, _dummyAction(new QAction(parentMenu->menu())) +, _participantChosen(std::move(participantChosen)) +, _userpics(std::make_unique( + st::historyGroupCallUserpics, + rpl::never(), + [=] { update(); })) +, _st(parentMenu->menu()->st()) +, _height(st::ttlItemPadding.top() + + _st.itemStyle.font->height + + st::ttlItemTimerFont->height + + st::ttlItemPadding.bottom()) { + const auto parent = parentMenu->menu(); + + setAcceptBoth(true); + initResizeHook(parent->sizeValue()); + setClickedCallback([=] { + if (!_content.participants.empty()) { + setupSubMenu(); + } + }); + resolveMinWidth(); + + auto copy = std::move( + content + ) | rpl::start_spawning(lifetime()); + + _userpics->widthValue( + ) | rpl::start_with_next([=](int width) { + _userpicsWidth = width; + refreshDimensions(); + update(); + }, lifetime()); + + std::move( + content + ) | rpl::start_with_next([=](WhoReadContent &&content) { + _content = content; + updateUserpicsFromContent(); + refreshText(); + refreshDimensions(); + update(); + }, lifetime()); + + paintRequest( + ) | rpl::start_with_next([=] { + Painter p(this); + paint(p); + }, lifetime()); + + enableMouseSelecting(); +} + +void Action::resolveMinWidth() { + const auto maxIconWidth = 0; + const auto width = [&](const QString &text) { + return _st.itemStyle.font->width(text); + }; + const auto maxTextWidth = std::max( + width(tr::lng_context_seen_text(tr::now, lt_count, 999)), + width(tr::lng_context_seen_listened(tr::now, lt_count, 999))); + const auto maxWidth = _st.itemPadding.left() + + maxIconWidth + + maxTextWidth + + _userpics->maxWidth() + + _st.itemPadding.right(); + setMinWidth(maxWidth); +} + +void Action::updateUserpicsFromContent() { + auto users = std::vector(); + if (!_content.participants.empty()) { + const auto count = std::min( + int(_content.participants.size()), + kMaxUserpics); + users.reserve(count); + for (auto i = 0; i != count; ++i) { + const auto &participant = _content.participants[i]; + users.push_back({ + .userpic = participant.userpic, + .userpicKey = participant.userpicKey, + .id = participant.id, + }); + } + } + _userpics->update(users, true); +} + +void Action::setupSubMenu() { + +} + +void Action::paint(Painter &p) { + const auto selected = isSelected(); + if (selected && _st.itemBgOver->c.alpha() < 255) { + p.fillRect(0, 0, width(), _height, _st.itemBg); + } + p.fillRect(0, 0, width(), _height, selected ? _st.itemBgOver : _st.itemBg); + if (isEnabled()) { + paintRipple(p, 0, 0); + } + p.setPen(selected ? _st.itemFgOver : _st.itemFg); + _text.drawLeftElided( + p, + _st.itemPadding.left(), + _st.itemPadding.top(), + _textWidth, + width()); + _userpics->paint( + p, + width() - _st.itemPadding.right(), + _st.itemPadding.top(), + st::historyGroupCallUserpics.size); +} + +void Action::refreshText() { + const auto count = int(_content.participants.size()); + _text.setMarkedText( + _st.itemStyle, + { (_content.unknown + ? tr::lng_context_seen_loading(tr::now) + : (count == 1) + ? _content.participants.front().name + : _content.listened + ? tr::lng_context_seen_listened(tr::now, lt_count, count) + : tr::lng_context_seen_text(tr::now, lt_count, count)) }, + MenuTextOptions); +} + +void Action::refreshDimensions() { + const auto textWidth = _text.maxWidth(); + const auto &padding = _st.itemPadding; + + const auto goodWidth = padding.left() + + textWidth + + _userpicsWidth + + padding.right(); + + const auto w = std::clamp( + goodWidth, + _st.widthMin, + _st.widthMax); + _textWidth = w - (goodWidth - textWidth); +} + +bool Action::isEnabled() const { + return true; +} + +not_null Action::action() const { + return _dummyAction; +} + +QPoint Action::prepareRippleStartPosition() const { + return mapFromGlobal(QCursor::pos()); +} + +QImage Action::prepareRippleMask() const { + return Ui::RippleAnimation::rectMask(size()); +} + +int Action::contentHeight() const { + return _height; +} + +void Action::handleKeyPress(not_null e) { + if (!isSelected()) { + return; + } + const auto key = e->key(); + if (key == Qt::Key_Enter || key == Qt::Key_Return) { + setClicked(Menu::TriggeredSource::Keyboard); + } +} + +} // namespace + +base::unique_qptr WhoReadContextAction( + not_null menu, + rpl::producer content, + Fn participantChosen) { + return base::make_unique_q( + menu, + std::move(content), + std::move(participantChosen)); +} + +} // namespace Ui diff --git a/Telegram/SourceFiles/ui/controls/who_read_context_action.h b/Telegram/SourceFiles/ui/controls/who_read_context_action.h new file mode 100644 index 0000000000..24f60669d3 --- /dev/null +++ b/Telegram/SourceFiles/ui/controls/who_read_context_action.h @@ -0,0 +1,37 @@ +/* +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/unique_qptr.h" + +namespace Ui { +namespace Menu { +class ItemBase; +} // namespace Menu + +class PopupMenu; + +struct WhoReadParticipant { + QString name; + QImage userpic; + std::pair userpicKey = {}; + uint64 id = 0; +}; + +struct WhoReadContent { + std::vector participants; + bool listened = false; + bool unknown = false; +}; + +[[nodiscard]] base::unique_qptr WhoReadContextAction( + not_null menu, + rpl::producer content, + Fn participantChosen); + +} // namespace Ui diff --git a/Telegram/cmake/td_ui.cmake b/Telegram/cmake/td_ui.cmake index e763bfbf17..b6efcc9794 100644 --- a/Telegram/cmake/td_ui.cmake +++ b/Telegram/cmake/td_ui.cmake @@ -165,6 +165,8 @@ PRIVATE ui/controls/invite_link_label.h ui/controls/send_button.cpp ui/controls/send_button.h + ui/controls/who_read_context_action.cpp + ui/controls/who_read_context_action.h ui/text/format_song_name.cpp ui/text/format_song_name.h ui/text/format_values.cpp