From 553ecdd5f7d605b0f2a06d22fc661c1210ed9166 Mon Sep 17 00:00:00 2001 From: John Preston Date: Thu, 23 Apr 2026 14:16:47 +0700 Subject: [PATCH] Delete reactions from menu / list. --- Telegram/SourceFiles/api/api_report.cpp | 28 ++ Telegram/SourceFiles/api/api_report.h | 15 + Telegram/SourceFiles/api/api_who_reacted.cpp | 3 + .../boxes/moderate_messages_box.cpp | 232 +++++++++--- .../boxes/peers/edit_peer_permissions_box.cpp | 2 +- .../chat_helpers/chat_helpers.style | 8 + .../data/data_chat_participant_status.cpp | 27 +- .../view/history_view_context_menu.cpp | 86 +++-- .../reactions/history_view_reactions_list.cpp | 119 ++++++- .../reactions/history_view_reactions_list.h | 18 + .../info/profile/info_profile_actions.cpp | 87 +++-- .../controls/who_reacted_context_action.cpp | 332 +++++++++++++++++- .../ui/controls/who_reacted_context_action.h | 28 +- 13 files changed, 855 insertions(+), 130 deletions(-) diff --git a/Telegram/SourceFiles/api/api_report.cpp b/Telegram/SourceFiles/api/api_report.cpp index 05b6d93591..62f2458d6c 100644 --- a/Telegram/SourceFiles/api/api_report.cpp +++ b/Telegram/SourceFiles/api/api_report.cpp @@ -144,6 +144,34 @@ auto CreateReportMessagesOrStoriesCallback( }; } +ReactionReportCapabilities GetReactionReportCapabilities( + not_null group, + not_null participant) { + const auto channel = group->asMegagroup(); + return channel + ? ReactionReportCapabilities{ + .canReport = channel->isPublic() && !participant->isSelf(), + .canBan = channel->canRestrictParticipant(participant), + } + : ReactionReportCapabilities(); +} + +void ReportReaction( + std::shared_ptr show, + not_null group, + MsgId messageId, + not_null participant) { + group->session().api().request(MTPmessages_ReportReaction( + group->input(), + MTP_int(messageId.bare), + participant->input() + )).done([=] { + if (show) { + show->showToast(tr::lng_report_thanks(tr::now)); + } + }).send(); +} + void ReportSpam( not_null sender, const MessageIdsList &ids) { diff --git a/Telegram/SourceFiles/api/api_report.h b/Telegram/SourceFiles/api/api_report.h index e503102ec5..0b35c408de 100644 --- a/Telegram/SourceFiles/api/api_report.h +++ b/Telegram/SourceFiles/api/api_report.h @@ -53,6 +53,21 @@ void SendPhotoReport( not_null peer) -> Fn)>; +struct ReactionReportCapabilities final { + bool canReport = false; + bool canBan = false; +}; + +[[nodiscard]] ReactionReportCapabilities GetReactionReportCapabilities( + not_null group, + not_null participant); + +void ReportReaction( + std::shared_ptr show, + not_null group, + MsgId messageId, + not_null participant); + void ReportSpam( not_null sender, const MessageIdsList &ids); diff --git a/Telegram/SourceFiles/api/api_who_reacted.cpp b/Telegram/SourceFiles/api/api_who_reacted.cpp index f3af5312fb..ea4a3cd4f6 100644 --- a/Telegram/SourceFiles/api/api_who_reacted.cpp +++ b/Telegram/SourceFiles/api/api_who_reacted.cpp @@ -512,11 +512,13 @@ void RegenerateParticipants(not_null state, int small, int large) { const auto peer = userpic.peer; const auto date = userpic.date; const auto id = peer->id.value; + const auto self = peer->isSelf(); const auto was = ranges::find(old, id, &Ui::WhoReadParticipant::id); if (was != end(old)) { was->name = peer->name(); was->date = FormatReadDate(date, currentDate); was->dateReacted = userpic.dateReacted; + was->self = self; now.push_back(std::move(*was)); continue; } @@ -524,6 +526,7 @@ void RegenerateParticipants(not_null state, int small, int large) { .name = peer->name(), .date = FormatReadDate(date, currentDate), .dateReacted = userpic.dateReacted, + .self = self, .customEntityData = userpic.customEntityData, .userpicLarge = GenerateUserpic(userpic, large), .userpicKey = userpic.uniqueKey, diff --git a/Telegram/SourceFiles/boxes/moderate_messages_box.cpp b/Telegram/SourceFiles/boxes/moderate_messages_box.cpp index 0685274b33..02aa6a8eee 100644 --- a/Telegram/SourceFiles/boxes/moderate_messages_box.cpp +++ b/Telegram/SourceFiles/boxes/moderate_messages_box.cpp @@ -82,22 +82,43 @@ const char kModerateCommonGroups[] = "moderate-common-groups"; namespace { struct ModerateOptions final { - bool allCanBan = false; - bool allCanDelete = false; + bool reportSpam = false; + bool deleteAllMessages = false; + bool deleteAllReactions = false; + bool banOrRestrict = false; Participants participants; }; +[[nodiscard]] bool PeerCanDeleteMessages(not_null peer) { + if (const auto chat = peer->asChat()) { + return chat->canDeleteMessages(); + } + const auto channel = peer->asChannel(); + return channel && channel->canDeleteMessages(); +} + +[[nodiscard]] bool IsExcludedModerateParticipant( + not_null peer, + not_null participant) { + if ((participant == peer) || participant->isSelf()) { + return true; + } else if (const auto channel = participant->asChannel()) { + return (channel->discussionLink() == peer); + } + return false; +} + ModerateOptions CalculateModerateOptions(const HistoryItemsList &items) { Expects(!items.empty()); auto result = ModerateOptions{ - .allCanBan = true, - .allCanDelete = true, + .deleteAllMessages = true, + .banOrRestrict = true, }; const auto peer = items.front()->history()->peer; for (const auto &item : items) { - if (!result.allCanBan && !result.allCanDelete) { + if (!result.deleteAllMessages && !result.banOrRestrict) { return {}; } if (peer != item->history()->peer) { @@ -114,10 +135,10 @@ ModerateOptions CalculateModerateOptions(const HistoryItemsList &items) { } } if (!item->suggestBanReport()) { - result.allCanBan = false; + result.banOrRestrict = false; } if (!item->suggestDeleteAllReport()) { - result.allCanDelete = false; + result.deleteAllMessages = false; } if (const auto p = item->from()) { if (!ranges::contains(result.participants, not_null{ p })) { @@ -125,9 +146,38 @@ ModerateOptions CalculateModerateOptions(const HistoryItemsList &items) { } } } + result.deleteAllReactions = result.deleteAllMessages; + result.reportSpam = result.deleteAllMessages || result.banOrRestrict; return result; } +ModerateOptions CalculateModerateOptions(const ModerateReactionEntry &reaction) { + auto result = ModerateOptions{ + .participants = { reaction.participant }, + }; + if (IsExcludedModerateParticipant(reaction.peer, reaction.participant)) { + return result; + } + result.reportSpam = Api::GetReactionReportCapabilities( + reaction.peer, + reaction.participant + ).canReport || (reaction.peer->asChannel() != nullptr); + result.deleteAllReactions = PeerCanDeleteMessages(reaction.peer); + if (const auto channel = reaction.peer->asChannel()) { + result.deleteAllMessages = channel->canDeleteMessages(); + result.banOrRestrict = channel->canRestrictParticipant( + reaction.participant); + } + return result; +} + +[[nodiscard]] bool HasModerateActions(const ModerateOptions &options) { + return options.reportSpam + || options.deleteAllMessages + || options.deleteAllReactions + || options.banOrRestrict; +} + [[nodiscard]] rpl::producer> MessagesCountValue( not_null history, std::vector> from) { @@ -310,7 +360,7 @@ void ProccessCommonGroups( Fn)> processHas) { const auto moderateOptions = CalculateModerateOptions(items); if (moderateOptions.participants.size() != 1 - || !moderateOptions.allCanBan) { + || !moderateOptions.banOrRestrict) { return; } const auto participant = moderateOptions.participants.front(); @@ -364,13 +414,11 @@ void CreateModerateMessagesBox( const auto moderateOptions = hasItems ? CalculateModerateOptions(items) - : ModerateOptions{ - .allCanBan = false, - .allCanDelete = false, - .participants = { reaction->participant }, - }; - const auto allCanBan = moderateOptions.allCanBan; - const auto allCanDelete = moderateOptions.allCanDelete; + : CalculateModerateOptions(*reaction); + const auto reportSpam = moderateOptions.reportSpam; + const auto deleteAllMessages = moderateOptions.deleteAllMessages; + const auto deleteAllReactions = moderateOptions.deleteAllReactions; + const auto banOrRestrict = moderateOptions.banOrRestrict; const auto &participants = moderateOptions.participants; const auto inner = box->verticalLayout(); @@ -393,31 +441,36 @@ void CreateModerateMessagesBox( const auto session = hasItems ? &firstItem->history()->session() : &reaction->peer->session(); + const auto peer = hasItems + ? firstItem->history()->peer + : reaction->peer; const auto history = hasItems ? firstItem->history().get() - : session->data().historyLoaded(reaction->peer); - const auto historyPeerId = hasItems - ? history->peer->id - : reaction->peer->id; + : session->data().historyLoaded(peer); + const auto historyPeerId = peer->id; const auto ids = hasItems ? session->data().itemsToIds(items) - : MessageIdsList(); + : MessageIdsList{ FullMsgId(reaction->peer->id, reaction->msgId) }; const auto selectedMessagesByParticipant = [&] { auto result = base::flat_map(); - if (!hasItems) { + if (!hasItems && !hasReaction) { return result; } - for (const auto &item : items) { - const auto from = item->from(); - if (!from) { - continue; - } - const auto i = result.find(from->id); - if (i == result.end()) { - result.emplace(from->id, 1); - } else { - ++i->second; + if (hasItems) { + for (const auto &item : items) { + const auto from = item->from(); + if (!from) { + continue; + } + const auto i = result.find(from->id); + if (i == result.end()) { + result.emplace(from->id, 1); + } else { + ++i->second; + } } + } else { + result.emplace(reaction->participant->id, 1); } return result; }(); @@ -654,7 +707,7 @@ void CreateModerateMessagesBox( subtitle->entity()->setTextColorOverride(st::windowSubTextFg->c); subtitle->hide(anim::type::instant); Ui::AddSkip(inner); - if (hasItems) { + if (reportSpam) { const auto report = box->addRow( object_ptr( box, @@ -670,13 +723,24 @@ void CreateModerateMessagesBox( handleConfirmation(report, controller, [=]( not_null p, not_null c) { - Api::ReportSpam(p, ids); + if (reaction.has_value() + && Api::GetReactionReportCapabilities( + reaction->peer, + p + ).canReport) { + Api::ReportReaction( + box->uiShow(), + reaction->peer, + reaction->msgId, + p); + } else { + Api::ReportSpam(p, ids); + } }); } - const auto showMessagesCheckbox = allCanDelete && hasItems; - const auto showReactionsCheckbox = (allCanDelete && hasItems) - || (hasReaction && !hasItems); + const auto showMessagesCheckbox = deleteAllMessages; + const auto showReactionsCheckbox = deleteAllReactions; if (showMessagesCheckbox || showReactionsCheckbox) { Ui::AddSkip(inner); Ui::AddSkip(inner); @@ -685,6 +749,7 @@ void CreateModerateMessagesBox( : std::vector(); if (showMessagesCheckbox) { + Assert(history != nullptr); deleteMessagesCounts = box->lifetime().make_state< rpl::variable>>( base::flat_map()); @@ -751,9 +816,6 @@ void CreateModerateMessagesBox( && !effectiveCheckedParticipants( deleteReactions, deleteReactionsController).empty()) { - const auto peer = hasItems - ? history->peer.get() - : reaction->peer.get(); for (const auto &participant : deleteReactionsController->collectRequests()) { peer->session().api() @@ -779,6 +841,7 @@ void CreateModerateMessagesBox( int count = 0; bool resolved = false; }; + const auto baseMessagesCount = int(ids.size()); const auto langUpdated = rpl::single( 0 ) | rpl::then(Lang::Updated() | rpl::map([] { @@ -788,7 +851,7 @@ void CreateModerateMessagesBox( const base::flat_map &messagesCounts, const Participants &checked) { auto result = MessageTitleData{ - .count = itemsCount, + .count = baseMessagesCount, .resolved = true, }; for (const auto &peer : checked) { @@ -806,7 +869,7 @@ void CreateModerateMessagesBox( return result; }; auto title = [&]() -> rpl::producer { - if (hasItems && showMessagesCheckbox) { + if (showMessagesCheckbox && !(hasReaction && !hasItems)) { auto messageTitleData = rpl::combine( deleteMessagesCounts->value(), checkedParticipantsValue( @@ -837,6 +900,51 @@ void CreateModerateMessagesBox( makeTitleLoadingDescriptor())) .append(text.mid(zeroIndex + 1)); }); + } else if (hasReaction && showMessagesCheckbox) { + auto messageTitleData = rpl::combine( + deleteMessagesCounts->value(), + checkedParticipantsValue( + not_null{ deleteMessages }, + not_null{ deleteMessagesController }) + ) | rpl::map(makeMessageTitleData); + auto deleteReactionsChecked = deleteReactions + ? deleteReactions->checkedValue() + : rpl::single(false); + return rpl::combine( + deleteMessages->checkedValue(), + std::move(messageTitleData), + std::move(deleteReactionsChecked), + rpl::duplicate(langUpdated) + ) | rpl::map([=]( + bool deleteMessagesChecked, + const MessageTitleData &data, + bool deleteReactionsChecked, + int) { + if (!deleteMessagesChecked) { + return TextWithEntities{ deleteReactionsChecked + ? tr::lng_delete_title_reaction_all(tr::now) + : tr::lng_delete_title_reaction_this(tr::now) }; + } + const auto count = data.count; + const auto resolved = data.resolved; + const auto text = (count == 1) + ? tr::lng_delete_title_message_one(tr::now) + : tr::lng_delete_title_message_many( + tr::now, + lt_count, + count); + if (resolved || count != 0) { + return TextWithEntities{ text }; + } + const auto zeroIndex = text.indexOf('0'); + return (zeroIndex == -1) + ? TextWithEntities{ text } + : TextWithEntities() + .append(text.mid(0, zeroIndex)) + .append(Ui::Text::LottieEmoji( + makeTitleLoadingDescriptor())) + .append(text.mid(zeroIndex + 1)); + }); } else if (hasItems) { return rpl::duplicate(langUpdated) | rpl::map([=](int) { return (itemsCount == 1) @@ -888,7 +996,7 @@ void CreateModerateMessagesBox( SomeReactions, AllReactions, }; - if (hasItems) { + if (hasItems || (hasReaction && showMessagesCheckbox)) { const auto subtitleKind = box->lifetime().make_state< rpl::variable>(SubtitleKind::None); auto reactionsCheckedValue = showReactionsCheckbox @@ -896,6 +1004,13 @@ void CreateModerateMessagesBox( not_null{ deleteReactions }, not_null{ deleteReactionsController }) : rpl::single(Participants()); + auto messageTitleShownValue = [&] { + return hasItems + ? rpl::single(true) + : (hasReaction && showMessagesCheckbox) + ? deleteMessages->checkedValue() + : rpl::single(false); + }(); rpl::combine( subtitleKind->value(), rpl::duplicate(langUpdated) @@ -916,22 +1031,25 @@ void CreateModerateMessagesBox( }, subtitle->lifetime()); rpl::combine( std::move(reactionsCheckedValue), - rpl::single(hasReaction) - ) | rpl::on_next([=](const Participants &checked, bool hasReaction) { + std::move(messageTitleShownValue) + ) | rpl::on_next([=]( + const Participants &checked, + bool messageTitleShown) { auto kind = SubtitleKind::None; - if (!checked.empty()) { - kind = (checked.size() == participants.size()) - ? SubtitleKind::AllReactions - : SubtitleKind::SomeReactions; - } else if (hasReaction) { - kind = SubtitleKind::ThisReaction; + if (messageTitleShown) { + if (!checked.empty()) { + kind = (checked.size() == participants.size()) + ? SubtitleKind::AllReactions + : SubtitleKind::SomeReactions; + } else if (hasReaction) { + kind = SubtitleKind::ThisReaction; + } } subtitleKind->force_assign(kind); subtitle->toggle(kind != SubtitleKind::None, anim::type::normal); }, subtitle->lifetime()); } - if (hasItems && allCanBan) { - const auto peer = items.front()->history()->peer; + if (banOrRestrict) { auto ownedWrap = peer->isMonoforum() ? nullptr : object_ptr>( @@ -1137,7 +1255,13 @@ void CreateModerateMessagesBox( session->data().histories().deleteMessages(ids, true); session->data().sendHistoryChangeNotifications(); } - if (reaction) { + const auto deleteThisReaction = reaction + && !ranges::contains( + effectiveCheckedParticipants( + deleteReactions, + deleteReactionsController), + reaction->participant); + if (deleteThisReaction) { session->api().deleteParticipantReaction( reaction->peer, reaction->msgId, @@ -1150,7 +1274,7 @@ void CreateModerateMessagesBox( bool CanCreateModerateMessagesBox(const HistoryItemsList &items) { const auto options = CalculateModerateOptions(items); - return (options.allCanBan || options.allCanDelete) + return HasModerateActions(options) && !options.participants.empty(); } diff --git a/Telegram/SourceFiles/boxes/peers/edit_peer_permissions_box.cpp b/Telegram/SourceFiles/boxes/peers/edit_peer_permissions_box.cpp index 3a89affff2..78eb5c2435 100644 --- a/Telegram/SourceFiles/boxes/peers/edit_peer_permissions_box.cpp +++ b/Telegram/SourceFiles/boxes/peers/edit_peer_permissions_box.cpp @@ -90,7 +90,6 @@ constexpr auto kDefaultChargeStars = 10; | Flag::SendGames | Flag::SendInline, tr::lng_rights_chat_stickers(tr::now) }, { Flag::EmbedLinks, tr::lng_rights_chat_send_links(tr::now) }, - { Flag::SendReactions, tr::lng_rights_chat_send_reactions(tr::now) }, { Flag::SendPolls, tr::lng_rights_chat_send_polls(tr::now) }, }; auto second = std::vector{ @@ -101,6 +100,7 @@ constexpr auto kDefaultChargeStars = 10; ? tr::lng_rights_group_edit_rank_single : tr::lng_rights_group_edit_rank)(tr::now) }, { Flag::ChangeInfo, tr::lng_rights_group_info(tr::now) }, + { Flag::SendReactions, tr::lng_rights_chat_send_reactions(tr::now) }, }; if (!options.isForum) { second.erase( diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index 92daf29f68..4568df08a2 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -609,6 +609,14 @@ stickerPanRemoveSet: IconButton(hashtagClose) { iconPosition: point(-1px, -1px); rippleAreaPosition: point(0px, 0px); } +whoReadClose: IconButton(stickerPanRemoveSet) { + width: 20px; + height: 20px; + icon: smallCloseIconOver; + rippleAreaSize: 20px; +} +whoReadCloseVisibleRadius: 7px; +whoReadCloseBlurPadding: 5px; stickerIconMove: 400; stickerPreviewDuration: 150; stickerPreviewMin: 0.1; diff --git a/Telegram/SourceFiles/data/data_chat_participant_status.cpp b/Telegram/SourceFiles/data/data_chat_participant_status.cpp index d63c0acade..79c1fa0047 100644 --- a/Telegram/SourceFiles/data/data_chat_participant_status.cpp +++ b/Telegram/SourceFiles/data/data_chat_participant_status.cpp @@ -249,12 +249,9 @@ bool CanSendAnyOf( if (!chat->amIn()) { return false; } - for (const auto right : AllSendRestrictionsList()) { - if ((rights & right) && !chat->amRestricted(right)) { - return true; - } - } - return false; + return chat->amCreator() + || chat->hasAdminRights() + || (rights & ~chat->defaultRestrictions()); } else if (const auto channel = peer->asChannel()) { if (channel->monoforumDisabled()) { return false; @@ -266,17 +263,15 @@ bool CanSendAnyOf( || channel->isMonoforum(); if (!allowed || (forbidInForums && channel->isForum())) { return false; - } else if (channel->canPostMessages()) { - return true; - } else if (channel->isBroadcast()) { - return false; } - for (const auto right : AllSendRestrictionsList()) { - if ((rights & right) && !channel->amRestricted(right)) { - return true; - } - } - return false; + const auto restricted = channel->restrictions() + | (channel->unrestrictedByBoosts() + ? ChatRestrictions() + : channel->defaultRestrictions()); + return channel->canPostMessages() + || (!channel->isBroadcast() + && (channel->hasAdminRights() + || (rights & ~restricted))); } Unexpected("Peer type in CanSendAnyOf."); } diff --git a/Telegram/SourceFiles/history/view/history_view_context_menu.cpp b/Telegram/SourceFiles/history/view/history_view_context_menu.cpp index bc77c7bac7..df17b091ad 100644 --- a/Telegram/SourceFiles/history/view/history_view_context_menu.cpp +++ b/Telegram/SourceFiles/history/view/history_view_context_menu.cpp @@ -32,7 +32,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/view/media/history_view_web_page.h" #include "history/view/reactions/history_view_reactions_list.h" #include "info/info_memento.h" -#include "info/profile/info_profile_widget.h" #include "ui/widgets/popup_menu.h" #include "ui/widgets/menu/menu_action.h" #include "ui/widgets/menu/menu_add_action_callback_factory.h" @@ -1172,26 +1171,36 @@ void EditTagBox( }); } -void ShowWhoReadInfo( +[[nodiscard]] Fn MakeModerateReactionChosen( not_null controller, FullMsgId itemId, - Ui::WhoReadParticipant who) { - const auto peer = controller->session().data().peer(itemId.peer); - const auto participant = peer->owner().peer(PeerId(who.id)); - const auto migrated = participant->migrateFrom(); - const auto origin = who.dateReacted - ? Info::Profile::Origin{ - Info::Profile::GroupReactionOrigin{ peer, itemId.msg }, + not_null peer, + Fn hideMenu) { + if (!Reactions::CanModerateReactionByDeleteMessages(peer)) { + return {}; + } + return [=, hideMenu = std::move(hideMenu)](Ui::WhoReadParticipant who) { + if (who.id == 0 || who.customEntityData.isEmpty()) { + return; } - : Info::Profile::Origin(); - auto memento = std::make_shared( - std::vector>{ - std::make_shared( - participant, - migrated ? migrated->id : PeerId(), - origin), - }); - controller->showSection(std::move(memento)); + const auto item = controller->session().data().message(itemId); + if (!item) { + return; + } + const auto participant = item->history()->peer->owner().peer( + PeerId(who.id)); + if (participant->isSelf()) { + return; + } + if (hideMenu) { + hideMenu(); + } + Reactions::ShowModerateReactionBox( + controller, + item->history()->peer, + itemId.msg, + participant); + }; } [[nodiscard]] rpl::producer> LookupMessageAuthor( @@ -2078,8 +2087,23 @@ void AddWhoReactedAction( if (const auto strong = weak.get()) { strong->hideMenu(); } - ShowWhoReadInfo(controller, itemId, who); + const auto participant = user->owner().peer(PeerId(who.id)); + Reactions::ShowReactionParticipantInfo( + controller, + participant, + user, + itemId.msg, + who.dateReacted); }; + const auto moderateReactionChosen = MakeModerateReactionChosen( + controller, + itemId, + user, + [=] { + if (const auto strong = weak.get()) { + strong->hideMenu(); + } + }); const auto showAllChosen = [=, itemId = item->fullId()]{ // Pressing on an item that has a submenu doesn't hide it :( if (const auto strong = weak.get()) { @@ -2114,7 +2138,8 @@ void AddWhoReactedAction( Api::WhoReacted(item, context, st::defaultWhoRead, whoReadIds), Data::ReactedMenuFactory(&controller->session()), participantChosen, - showAllChosen)); + showAllChosen, + moderateReactionChosen)); AddWhenEditedForwardedAuthorActionHelper( menu, item, @@ -2269,8 +2294,24 @@ void ShowWhoReactedMenu( }; const auto itemId = item->fullId(); const auto participantChosen = [=](Ui::WhoReadParticipant who) { - ShowWhoReadInfo(controller, itemId, who); + const auto originPeer = item->history()->peer; + const auto participant = originPeer->owner().peer(PeerId(who.id)); + Reactions::ShowReactionParticipantInfo( + controller, + participant, + originPeer, + itemId.msg, + who.dateReacted); }; + const auto moderateReactionChosen = MakeModerateReactionChosen( + controller, + itemId, + item->history()->peer, + [=] { + if (*menu) { + (*menu)->hideMenu(); + } + }); const auto showAllChosen = [=, itemId = item->fullId()]{ if (const auto item = controller->session().data().message(itemId)) { controller->showSection(std::make_shared( @@ -2290,7 +2331,8 @@ void ShowWhoReactedMenu( const auto filler = lifetime.make_state( Data::ReactedMenuFactory(&controller->session()), participantChosen, - showAllChosen); + showAllChosen, + moderateReactionChosen); const auto state = lifetime.make_state(); Api::WhoReacted( item, diff --git a/Telegram/SourceFiles/history/view/reactions/history_view_reactions_list.cpp b/Telegram/SourceFiles/history/view/reactions/history_view_reactions_list.cpp index c48b0ed6cd..5605a11f74 100644 --- a/Telegram/SourceFiles/history/view/reactions/history_view_reactions_list.cpp +++ b/Telegram/SourceFiles/history/view/reactions/history_view_reactions_list.cpp @@ -8,21 +8,32 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/view/reactions/history_view_reactions_list.h" #include "history/view/reactions/history_view_reactions_tabs.h" +#include "boxes/moderate_messages_box.h" #include "boxes/peer_list_box.h" #include "boxes/peers/prepare_short_info_box.h" +#include "info/info_memento.h" +#include "info/profile/info_profile_widget.h" #include "window/window_session_controller.h" #include "history/history_item.h" #include "history/history.h" #include "api/api_who_reacted.h" #include "ui/controls/who_reacted_context_action.h" +#include "ui/layers/generic_box.h" #include "ui/text/text_custom_emoji.h" +#include "ui/widgets/menu/menu_add_action_callback.h" +#include "ui/widgets/menu/menu_add_action_callback_factory.h" +#include "ui/widgets/popup_menu.h" #include "ui/painter.h" #include "data/stickers/data_custom_emoji.h" #include "data/data_message_reaction_id.h" #include "main/main_session.h" #include "data/data_session.h" #include "data/data_peer.h" +#include "data/data_chat.h" +#include "data/data_channel.h" #include "lang/lang_keys.h" +#include "styles/style_boxes.h" +#include "styles/style_menu_icons.h" namespace HistoryView::Reactions { namespace { @@ -38,10 +49,14 @@ public: uint64 id, not_null peer, const Ui::Text::CustomEmojiFactory &factory, + ReactionId reaction, QStringView reactionEntityData, Fn repaint, Fn paused); + [[nodiscard]] const ReactionId &reaction() const; + [[nodiscard]] bool isReactionRow() const; + QSize rightActionSize() const override; QMargins rightActionMargins() const override; bool rightActionDisabled() const override; @@ -54,6 +69,7 @@ public: bool actionSelected) override; private: + ReactionId _reaction; std::unique_ptr _custom; Fn _paused; @@ -71,6 +87,9 @@ public: Main::Session &session() const override; void prepare() override; void rowClicked(not_null row) override; + base::unique_qptr rowContextMenu( + QWidget *parent, + not_null row) override; void loadMoreRows() override; std::unique_ptr createRestoredRow( @@ -148,16 +167,26 @@ Row::Row( uint64 id, not_null peer, const Ui::Text::CustomEmojiFactory &factory, + ReactionId reaction, QStringView reactionEntityData, Fn repaint, Fn paused) : PeerListRow(peer, id) +, _reaction(std::move(reaction)) , _custom(reactionEntityData.isEmpty() ? nullptr : factory(reactionEntityData, { .repaint = [=] { repaint(this); } })) , _paused(std::move(paused)) { } +const ReactionId &Row::reaction() const { + return _reaction; +} + +bool Row::isReactionRow() const { + return !_reaction.empty(); +} + QSize Row::rightActionSize() const { const auto size = Ui::Emoji::GetSizeNormal() / style::DevicePixelRatio(); return _custom ? QSize(size, size) : QSize(); @@ -413,11 +442,48 @@ void Controller::loadMore(const ReactionId &reaction) { void Controller::rowClicked(not_null row) { const auto window = _window; const auto peer = row->peer(); + const auto originPeer = _peer; + const auto originMsgId = _itemId.msg; + const auto reactionRow = static_cast(row.get())->isReactionRow(); crl::on_main(window, [=] { - window->showPeerInfo(peer); + ShowReactionParticipantInfo( + window, + peer, + originPeer, + originMsgId, + reactionRow); }); } +base::unique_qptr Controller::rowContextMenu( + QWidget *parent, + not_null row) { + const auto reactionRow = static_cast(row.get()); + const auto participant = row->peer(); + if (!reactionRow->isReactionRow() + || participant->isSelf() + || !CanModerateReactionByDeleteMessages(_peer)) { + return nullptr; + } + + auto result = base::make_unique_q( + parent, + st::popupMenuWithIcons); + Ui::Menu::CreateAddActionCallback(result.get())({ + .text = tr::lng_context_delete_this_reaction(tr::now), + .handler = [=] { + ShowModerateReactionBox( + _window->parentController(), + _peer, + _itemId.msg, + participant); + }, + .icon = &st::menuIconDeleteAttention, + .isAttention = true, + }); + return result; +} + bool Controller::appendRow(not_null peer, ReactionId reaction) { if (delegate()->peerListFindRow(id(peer, reaction))) { return false; @@ -433,6 +499,7 @@ std::unique_ptr Controller::createRow( id(peer, reaction), peer, _factory, + reaction, Data::ReactionEntityData(reaction), [=](Row *row) { delegate()->peerListUpdateRow(row); }, [=] { return _window->parentController()->isGifPausedAtLeastFor( @@ -441,6 +508,56 @@ std::unique_ptr Controller::createRow( } // namespace +bool CanModerateReactionByDeleteMessages(not_null originPeer) { + if (const auto chat = originPeer->asChat()) { + return chat->canDeleteMessages(); + } else if (const auto channel = originPeer->asChannel()) { + return channel->canDeleteMessages(); + } + return false; +} + +void ShowModerateReactionBox( + not_null controller, + not_null originPeer, + MsgId originMsgId, + not_null participant) { + controller->show(Box( + CreateModerateMessagesBox, + ModerateMessagesBoxEntry{ + .reaction = ModerateReactionEntry{ + .peer = originPeer, + .msgId = originMsgId, + .participant = participant, + }, + }, + nullptr, + DefaultModerateMessagesBoxOptions())); +} + +void ShowReactionParticipantInfo( + not_null window, + not_null participant, + not_null originPeer, + MsgId originMsgId, + bool reactionRow) { + if (!reactionRow) { + window->showPeerInfo(participant); + return; + } + const auto migrated = participant->migrateFrom(); + auto memento = std::make_shared( + std::vector>{ + std::make_shared( + participant, + migrated ? migrated->id : PeerId(), + Info::Profile::Origin{ + Info::Profile::GroupReactionOrigin{ originPeer, originMsgId }, + }), + }); + window->showSection(std::move(memento)); +} + Data::ReactionId DefaultSelectedTab( not_null item, std::shared_ptr whoReadIds) { diff --git a/Telegram/SourceFiles/history/view/reactions/history_view_reactions_list.h b/Telegram/SourceFiles/history/view/reactions/history_view_reactions_list.h index be6dc59fec..745915bc32 100644 --- a/Telegram/SourceFiles/history/view/reactions/history_view_reactions_list.h +++ b/Telegram/SourceFiles/history/view/reactions/history_view_reactions_list.h @@ -10,6 +10,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/object_ptr.h" class HistoryItem; +class PeerData; class PeerListController; namespace Data { @@ -52,6 +53,23 @@ struct PreparedFullList { std::unique_ptr controller; Fn switchTab; }; + +[[nodiscard]] bool CanModerateReactionByDeleteMessages( + not_null originPeer); + +void ShowModerateReactionBox( + not_null controller, + not_null originPeer, + MsgId originMsgId, + not_null participant); + +void ShowReactionParticipantInfo( + not_null window, + not_null participant, + not_null originPeer, + MsgId originMsgId, + bool reactionRow); + [[nodiscard]] PreparedFullList FullListController( not_null window, FullMsgId itemId, diff --git a/Telegram/SourceFiles/info/profile/info_profile_actions.cpp b/Telegram/SourceFiles/info/profile/info_profile_actions.cpp index 8cdc5c2f14..41da53404d 100644 --- a/Telegram/SourceFiles/info/profile/info_profile_actions.cpp +++ b/Telegram/SourceFiles/info/profile/info_profile_actions.cpp @@ -10,6 +10,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_blocked_peers.h" #include "api/api_chat_participants.h" #include "api/api_credits.h" +#include "api/api_report.h" #include "api/api_statistics.h" #include "apiwrap.h" #include "base/call_delayed.h" @@ -51,6 +52,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history_item_components.h" #include "history/history_item_helpers.h" #include "history/view/history_view_item_preview.h" +#include "history/view/reactions/history_view_reactions_list.h" #include "info/bot/earn/info_bot_earn_widget.h" #include "info/bot/starref/info_bot_starref_common.h" #include "info/channel_statistics/earn/earn_format.h" @@ -1235,6 +1237,10 @@ private: void addReportReaction( Ui::MultiSlideTracker &tracker, Ui::MultiSlideTracker *buttonTracker); + void addDeleteReaction( + GroupReactionOrigin data, + Ui::MultiSlideTracker &tracker, + Ui::MultiSlideTracker *buttonTracker); void addReportReaction( GroupReactionOrigin data, bool ban, @@ -1332,13 +1338,11 @@ void ReportReactionBox( ChatRestrictionsInfo()); } } - data.group->session().api().request(MTPmessages_ReportReaction( - data.group->input(), - MTP_int(data.messageId.bare), - participant->input() - )).done(crl::guard(controller, [=] { - controller->showToast(tr::lng_report_thanks(tr::now)); - })).send(); + Api::ReportReaction( + controller->uiShow(), + data.group, + data.messageId, + participant); sent(); box->closeBox(); }, st::attentionBoxButton); @@ -2211,27 +2215,64 @@ void DetailsFiller::addReportReaction( Ui::MultiSlideTracker &tracker, Ui::MultiSlideTracker *buttonTracker) { v::match(_origin.data, [&](GroupReactionOrigin data) { - const auto user = _peer->asUser(); if (_peer->isSelf()) { return; -#if 0 // Only public groups allow reaction reports for now. - } else if (const auto chat = data.group->asChat()) { - const auto ban = chat->canBanMembers() - && (!user || !chat->admins.contains(_peer)) - && (!user || chat->creator != user->id); - addReportReaction(data, ban, tracker); -#endif - } else if (const auto channel = data.group->asMegagroup()) { - if (channel->isPublic()) { - const auto ban = channel->canBanMembers() - && (!user || !channel->mgInfo->admins.contains(user->id)) - && (!user || channel->mgInfo->creator != user); - addReportReaction(data, ban, tracker, buttonTracker); - } + } + if (HistoryView::Reactions::CanModerateReactionByDeleteMessages( + data.group)) { + addDeleteReaction(data, tracker, buttonTracker); + return; + } + const auto capabilities = Api::GetReactionReportCapabilities( + data.group, + _peer); + if (capabilities.canReport) { + addReportReaction( + data, + capabilities.canBan, + tracker, + buttonTracker); } }, [](const auto &) {}); } +void DetailsFiller::addDeleteReaction( + GroupReactionOrigin data, + Ui::MultiSlideTracker &tracker, + Ui::MultiSlideTracker *buttonTracker) { + const auto peer = _peer; + if (!peer) { + return; + } + const auto controller = _controller->parentController(); + const auto wrap = _wrap->add( + object_ptr>( + _wrap.data(), + object_ptr(_wrap.data()))); + Ui::AddSkip(wrap->entity()); + auto shown = rpl::single(true); + wrap->toggleOn(rpl::duplicate(shown)); + rpl::duplicate(shown) | rpl::on_next([=](bool shown) { + if (shown) { + _dividerOverridden.force_assign(false); + } + }, wrap->lifetime()); + AddMainButton( + _wrap, + tr::lng_context_delete_this_reaction(), + std::move(shown), + [=] { + HistoryView::Reactions::ShowModerateReactionBox( + controller, + data.group, + data.messageId, + peer); + }, + tracker, + buttonTracker, + st::infoMainButtonAttention); +} + void DetailsFiller::addReportReaction( GroupReactionOrigin data, bool ban, @@ -2422,7 +2463,7 @@ object_ptr DetailsFiller::fill() { } } } - if (!user->isSelf() && !_sublist) { + if (!_sublist) { addReportReaction(_mainTracker, &lastButtonTracker); } } else if (const auto channel = _peer->asChannel()) { diff --git a/Telegram/SourceFiles/ui/controls/who_reacted_context_action.cpp b/Telegram/SourceFiles/ui/controls/who_reacted_context_action.cpp index c0aa82fd18..08062aa21a 100644 --- a/Telegram/SourceFiles/ui/controls/who_reacted_context_action.cpp +++ b/Telegram/SourceFiles/ui/controls/who_reacted_context_action.cpp @@ -12,11 +12,14 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/popup_menu.h" #include "ui/effects/ripple_animation.h" #include "ui/chat/group_call_userpics.h" +#include "ui/image/image_prepare.h" +#include "ui/round_rect.h" #include "ui/text/text_custom_emoji.h" #include "ui/emoji_config.h" #include "ui/painter.h" #include "ui/ui_utility.h" #include "lang/lang_keys.h" +#include "styles/style_basic.h" #include "styles/style_chat.h" #include "styles/style_chat_helpers.h" #include "styles/style_menu_icons.h" @@ -85,7 +88,8 @@ public: rpl::producer content, CustomEmojiFactory factory, Fn participantChosen, - Fn showAllChosen); + Fn showAllChosen, + Fn moderateReactionChosen); bool isEnabled() const override; not_null action() const override; @@ -111,6 +115,7 @@ private: const not_null _dummyAction; const Fn _participantChosen; const Fn _showAllChosen; + const Fn _moderateReactionChosen; const std::unique_ptr _userpics; const style::Menu &_st; const CustomEmojiFactory _customEmojiFactory; @@ -174,6 +179,83 @@ TextParseOptions MenuTextOptions = { Qt::LayoutDirectionAuto, // dir }; +struct CloseBadgeCache { + QImage badge; + QImage mask; +}; + +[[nodiscard]] QPainterPath WhoReactedCloseBadgePath(const QRect &rect) { + return Ui::ComplexRoundedRectPath( + rect, + 0, + 0, + st::whoReadCloseVisibleRadius, + 0); +} + +[[nodiscard]] QPoint WhoReactedCloseIconPosition( + const QRect &rect, + const style::icon &icon) { + auto position = st::whoReadClose.iconPosition; + if (position.x() < 0) { + position.setX((rect.width() - icon.width()) / 2); + } + if (position.y() < 0) { + position.setY((rect.height() - icon.height()) / 2); + } + return rect.topLeft() + position; +} + +[[nodiscard]] CloseBadgeCache GenerateWhoReactedCloseBadgeCache( + QSize closeSize, + const style::color &fill, + const style::color &shadowColor) { + if (closeSize.isEmpty()) { + return {}; + } + const auto blur = st::whoReadCloseBlurPadding; + const auto ratio = style::DevicePixelRatio(); + const auto badgeRect = QRect(QPoint(blur, blur), closeSize); + const auto maskRect = QRect(QPoint(), closeSize); + const auto outer = badgeRect.marginsAdded(QMargins(blur, blur, blur, blur)); + + auto badge = QImage( + outer.size() * ratio, + QImage::Format_ARGB32_Premultiplied); + badge.setDevicePixelRatio(ratio); + badge.fill(Qt::transparent); + { + Painter p(&badge); + auto hq = PainterHighQualityEnabler(p); + auto shadow = shadowColor->c; + shadow.setAlphaF(shadow.alphaF() * 0.18); + p.setPen(Qt::NoPen); + p.setBrush(shadow); + p.drawPath(WhoReactedCloseBadgePath(badgeRect)); + } + badge = Images::Blur(std::move(badge), true); + badge.setDevicePixelRatio(ratio); + { + Painter p(&badge); + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(fill); + p.drawPath(WhoReactedCloseBadgePath(badgeRect)); + } + auto mask = Ui::RippleAnimation::MaskByDrawer( + closeSize, + false, + [&](QPainter &p) { + p.setPen(Qt::NoPen); + p.setBrush(Qt::white); + p.drawPath(WhoReactedCloseBadgePath(maskRect)); + }); + return { + .badge = std::move(badge), + .mask = std::move(mask), + }; +} + [[nodiscard]] QString FormatReactedString(int reacted, int seen) { const auto projection = [&](const QString &text) { return Lang::StringWithReacted{ text, seen }; @@ -198,19 +280,25 @@ Action::Action( rpl::producer content, Text::CustomEmojiFactory factory, Fn participantChosen, - Fn showAllChosen) + Fn showAllChosen, + Fn moderateReactionChosen) : ItemBase(parentMenu->menu(), parentMenu->menu()->st()) , _parentMenu(parentMenu) , _dummyAction(CreateChild(parentMenu->menu().get())) , _participantChosen(std::move(participantChosen)) , _showAllChosen(std::move(showAllChosen)) +, _moderateReactionChosen(std::move(moderateReactionChosen)) , _userpics(std::make_unique( st::defaultWhoRead.userpics, rpl::never(), [=] { update(); })) , _st(parentMenu->menu()->st()) , _customEmojiFactory(std::move(factory)) -, _submenu(_customEmojiFactory, _participantChosen, _showAllChosen) +, _submenu( + _customEmojiFactory, + _participantChosen, + _showAllChosen, + _moderateReactionChosen) , _height(st::defaultWhoRead.itemPadding.top() + _st.itemStyle.font->height + st::defaultWhoRead.itemPadding.bottom()) { @@ -758,6 +846,31 @@ WhoReactedEntryAction::WhoReactedEntryAction( }, lifetime()); enableMouseSelecting(); + selects( + ) | rpl::on_next([=](const auto &) { + refreshCloseMouseTracking(); + updateCloseHovered(QCursor::pos()); + }, lifetime()); + + style::PaletteChanged() | rpl::on_next([=] { + invalidateCloseCache(); + update(); + }, lifetime()); + + events() | rpl::on_next([=](not_null e) { + if (e->type() != QEvent::Leave) { + return; + } + if (!_closeHovered && !_closePressed) { + return; + } + _closeHovered = false; + _closePressed = false; + _closeRippleActive = false; + finishAnimating(); + invalidateCloseCache(); + update(); + }, lifetime()); } not_null WhoReactedEntryAction::action() const { @@ -772,8 +885,162 @@ int WhoReactedEntryAction::contentHeight() const { return _height; } +void WhoReactedEntryAction::mousePressEvent(QMouseEvent *e) { + updateCloseHovered(e->globalPos()); + const auto menu = static_cast(parentWidget()); + if (!menu->hasMouseMoved(e->globalPos())) { + return; + } + const auto closePressed = closeAffordanceActive() + && (e->button() == Qt::LeftButton) + && _closeRect.contains(e->pos()); + if (!closePressed) { + _closePressed = false; + _closeRippleActive = false; + ItemBase::mousePressEvent(e); + return; + } + if (!_closeHovered) { + _closeHovered = true; + finishAnimating(); + invalidateCloseCache(); + update(); + } + _closePressed = true; + _closeRippleActive = true; + RippleButton::mousePressEvent(e); +} + +void WhoReactedEntryAction::mouseMoveEvent(QMouseEvent *e) { + if (_closePressed) { + const auto menu = static_cast(parentWidget()); + menu->mouseMoved(); + if (!menu->hasMouseMoved(e->globalPos())) { + return; + } + RippleButton::mouseMoveEvent(e); + updateCloseHovered(e->globalPos()); + return; + } + ItemBase::mouseMoveEvent(e); + updateCloseHovered(e->globalPos()); +} + +void WhoReactedEntryAction::mouseReleaseEvent(QMouseEvent *e) { + const auto menu = static_cast(parentWidget()); + if (!menu->hasMouseMoved(e->globalPos())) { + return; + } + if (!base::take(_closePressed)) { + ItemBase::mouseReleaseEvent(e); + updateCloseHovered(e->globalPos()); + return; + } + const auto overRow = rect().contains(e->pos()); + const auto overClose = closeAffordanceActive() + && overRow + && _closeRect.contains(e->pos()); + if (isOver()) { + setOver(false, StateChangeSource::ByPress); + } + if (isDown()) { + setDown( + false, + StateChangeSource::ByPress, + e->modifiers(), + e->button()); + } + if (overRow) { + setOver(true, StateChangeSource::ByHover); + } + updateCloseHovered(e->globalPos()); + if (overClose && _closeCallback) { + _closeCallback(); + } +} + +void WhoReactedEntryAction::resizeEvent(QResizeEvent *e) { + ItemBase::resizeEvent(e); + refreshCloseGeometry(); + invalidateCloseCache(); + updateCloseHovered(QCursor::pos()); +} + +QPoint WhoReactedEntryAction::prepareRippleStartPosition() const { + const auto result = mapFromGlobal(QCursor::pos()); + return (_closeRippleActive && !_closeRect.isEmpty()) + ? (result - _closeRect.topLeft()) + : result; +} + +QImage WhoReactedEntryAction::prepareRippleMask() const { + if (!_closeRippleActive || _closeRect.isEmpty()) { + return Ui::RippleAnimation::RectMask(size()); + } + if (_closeBadgeMask.isNull()) { + auto cache = GenerateWhoReactedCloseBadgeCache( + _closeRect.size(), + _st.itemBgOver, + _st.itemBgOver); + _closeBadgeMask = std::move(cache.mask); + } + return _closeBadgeMask; +} + +bool WhoReactedEntryAction::closeAffordanceActive() const { + return _closeCallback + && isSelected() + && (lastTriggeredSource() == Menu::TriggeredSource::Mouse); +} + +void WhoReactedEntryAction::refreshCloseMouseTracking() { + setMouseTracking(bool(_closeCallback) || !isSelected()); +} + +void WhoReactedEntryAction::refreshCloseGeometry() { + if (!_closeCallback) { + _closeRect = QRect(); + return; + } + _closeRect = QRect( + width() - st::whoReadClose.width, + 0, + st::whoReadClose.width, + st::whoReadClose.height); +} + +void WhoReactedEntryAction::updateCloseHovered(QPoint globalPosition) { + const auto hovered = closeAffordanceActive() + && _closeRect.contains(mapFromGlobal(globalPosition)); + if (_closeHovered == hovered) { + return; + } + _closeHovered = hovered; + finishAnimating(); + invalidateCloseCache(); + update(); +} + +void WhoReactedEntryAction::clearCloseState() { + _closeHovered = false; + _closePressed = false; + _closeRippleActive = false; + finishAnimating(); +} + +void WhoReactedEntryAction::invalidateCloseCache() { + _closeBadge = QImage(); + _closeBadgeMask = QImage(); +} + void WhoReactedEntryAction::setData(Data &&data) { setActionTriggered(std::move(data.callback)); + clearCloseState(); + _closeCallback = std::move(data.closeCallback); + if (!_closeCallback) { + _closeRect = QRect(); + invalidateCloseCache(); + } _userpic = std::move(data.userpic); _text.setMarkedText(_st.itemStyle, { data.text }, MenuTextOptions); if (data.date.isEmpty()) { @@ -797,26 +1064,32 @@ void WhoReactedEntryAction::setData(Data &&data) { _text.maxWidth(), st::whoReadDateSkip + _date.maxWidth()); const auto &padding = _st.itemPadding; - const auto rightSkip = padding.right() - + (_custom ? (size + padding.right()) : 0); + const auto customRight = _custom ? (size + padding.right()) : 0; + const auto rightSkip = customRight; const auto goodWidth = st::defaultWhoRead.nameLeft + textWidth + rightSkip; const auto w = std::clamp(goodWidth, _st.widthMin, _st.widthMax); _textWidth = w - (goodWidth - textWidth); setMinWidth(w); + refreshCloseGeometry(); + refreshCloseMouseTracking(); + invalidateCloseCache(); + updateCloseHovered(QCursor::pos()); update(); } void WhoReactedEntryAction::paint(Painter &&p) { const auto enabled = isEnabled(); - const auto selected = isSelected(); + const auto badgeShown = closeAffordanceActive(); + const auto closeHovered = badgeShown && _closeHovered; + const auto selected = isSelected() && !closeHovered; if (selected && _st.itemBgOver->c.alpha() < 255) { p.fillRect(0, 0, width(), _height, _st.itemBg); } const auto bg = selected ? _st.itemBgOver : _st.itemBg; p.fillRect(0, 0, width(), _height, bg); - if (enabled) { + if (enabled && (!_closeRippleActive || _closeRect.isEmpty())) { paintRipple(p, 0, 0); } const auto photoSize = st::defaultWhoRead.photoSize; @@ -938,12 +1211,37 @@ void WhoReactedEntryAction::paint(Painter &&p) { (height() - _customSize) / 2), }); } + if (badgeShown && !_closeRect.isEmpty()) { + if (_closeBadge.isNull()) { + auto cache = GenerateWhoReactedCloseBadgeCache( + _closeRect.size(), + _st.itemBgOver, + _st.itemBgOver); + _closeBadge = std::move(cache.badge); + _closeBadgeMask = std::move(cache.mask); + } + const auto blur = st::whoReadCloseBlurPadding; + p.drawImage( + _closeRect.topLeft() - QPoint(blur, blur), + _closeBadge); + if (enabled && _closeRippleActive) { + paintRipple(p, _closeRect.topLeft()); + } + const auto &icon = closeHovered + ? st::whoReadClose.iconOver + : st::whoReadClose.icon; + icon.paint( + p, + WhoReactedCloseIconPosition(_closeRect, icon), + width()); + } } bool operator==(const WhoReadParticipant &a, const WhoReadParticipant &b) { return (a.id == b.id) && (a.name == b.name) && (a.date == b.date) + && (a.self == b.self) && (a.userpicKey == b.userpicKey); } @@ -956,13 +1254,15 @@ base::unique_qptr WhoReactedContextAction( rpl::producer content, CustomEmojiFactory factory, Fn participantChosen, - Fn showAllChosen) { + Fn showAllChosen, + Fn moderateReactionChosen) { return base::make_unique_q( menu, std::move(content), std::move(factory), std::move(participantChosen), - std::move(showAllChosen)); + std::move(showAllChosen), + std::move(moderateReactionChosen)); } base::unique_qptr WhenReadContextAction( @@ -978,10 +1278,12 @@ base::unique_qptr WhenReadContextAction( WhoReactedListMenu::WhoReactedListMenu( CustomEmojiFactory factory, Fn participantChosen, - Fn showAllChosen) + Fn showAllChosen, + Fn moderateReactionChosen) : _customEmojiFactory(std::move(factory)) , _participantChosen(std::move(participantChosen)) -, _showAllChosen(std::move(showAllChosen)) { +, _showAllChosen(std::move(showAllChosen)) +, _moderateReactionChosen(std::move(moderateReactionChosen)) { } void WhoReactedListMenu::clear() { @@ -1032,6 +1334,13 @@ void WhoReactedListMenu::populate( const auto chosen = [call = _participantChosen, participant] { call(participant); }; + const auto closeChosen = (!participant.customEntityData.isEmpty() + && _moderateReactionChosen + && !participant.self) + ? Fn([ + call = _moderateReactionChosen, + participant] { call(participant); }) + : Fn(); append({ .text = participant.name, .date = participant.date, @@ -1041,6 +1350,7 @@ void WhoReactedListMenu::populate( .customEntityData = participant.customEntityData, .userpic = participant.userpicLarge, .callback = chosen, + .closeCallback = std::move(closeChosen), }); } if (addShowAll) { diff --git a/Telegram/SourceFiles/ui/controls/who_reacted_context_action.h b/Telegram/SourceFiles/ui/controls/who_reacted_context_action.h index 2e0d1b14c1..baa55cb1b5 100644 --- a/Telegram/SourceFiles/ui/controls/who_reacted_context_action.h +++ b/Telegram/SourceFiles/ui/controls/who_reacted_context_action.h @@ -19,6 +19,7 @@ struct WhoReadParticipant { QString name; QString date; bool dateReacted = false; + bool self = false; QString customEntityData; QImage userpicSmall; QImage userpicLarge; @@ -62,7 +63,8 @@ struct WhoReadContent { rpl::producer content, Text::CustomEmojiFactory factory, Fn participantChosen, - Fn showAllChosen); + Fn showAllChosen, + Fn moderateReactionChosen = nullptr); [[nodiscard]] base::unique_qptr WhenReadContextAction( not_null menu, @@ -86,6 +88,7 @@ struct WhoReactedEntryData { QString customEntityData; QImage userpic; Fn callback; + Fn closeCallback; }; class WhoReactedEntryAction final : public Menu::ItemBase { @@ -105,8 +108,20 @@ public: private: int contentHeight() const override; + void mousePressEvent(QMouseEvent *e) override; + void mouseMoveEvent(QMouseEvent *e) override; + void mouseReleaseEvent(QMouseEvent *e) override; + void resizeEvent(QResizeEvent *e) override; + QPoint prepareRippleStartPosition() const override; + QImage prepareRippleMask() const override; void paint(Painter &&p); + [[nodiscard]] bool closeAffordanceActive() const; + void refreshCloseMouseTracking(); + void refreshCloseGeometry(); + void updateCloseHovered(QPoint globalPosition); + void clearCloseState(); + void invalidateCloseCache(); const not_null _dummyAction; const Text::CustomEmojiFactory _customEmojiFactory; @@ -120,6 +135,13 @@ private: int _textWidth = 0; int _customSize = 0; WhoReactedType _type = WhoReactedType::Viewed; + Fn _closeCallback; + QRect _closeRect; + bool _closeHovered = false; + bool _closePressed = false; + bool _closeRippleActive = false; + mutable QImage _closeBadge; + mutable QImage _closeBadgeMask; }; @@ -128,7 +150,8 @@ public: WhoReactedListMenu( Text::CustomEmojiFactory factory, Fn participantChosen, - Fn showAllChosen); + Fn showAllChosen, + Fn moderateReactionChosen = nullptr); void clear(); void populate( @@ -142,6 +165,7 @@ private: const Text::CustomEmojiFactory _customEmojiFactory; const Fn _participantChosen; const Fn _showAllChosen; + const Fn _moderateReactionChosen; std::vector> _actions;