diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 3184b11ae4..d23e37b829 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -178,6 +178,8 @@ PRIVATE api/api_statistics_sender.h api/api_text_entities.cpp api/api_text_entities.h + api/api_todo_lists.cpp + api/api_todo_lists.h api/api_toggling_media.cpp api/api_toggling_media.h api/api_transcribes.cpp @@ -649,6 +651,8 @@ PRIVATE data/data_streaming.h data/data_thread.cpp data/data_thread.h + data/data_todo_list.cpp + data/data_todo_list.h data/data_types.cpp data/data_types.h data/data_unread_value.cpp @@ -812,6 +816,8 @@ PRIVATE history/view/media/history_view_story_mention.h history/view/media/history_view_theme_document.cpp history/view/media/history_view_theme_document.h + history/view/media/history_view_todo_list.cpp + history/view/media/history_view_todo_list.h history/view/media/history_view_unique_gift.cpp history/view/media/history_view_unique_gift.h history/view/media/history_view_userpic_suggestion.cpp diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index d1a9784970..205656b57e 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -2257,8 +2257,18 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_action_message_price_paid#other" = "Messages now cost {count} Stars each in this group."; "lng_action_direct_messages_enabled" = "Channel enabled Direct Messages."; "lng_action_direct_messages_paid#one" = "Channel allows Direct Messages for {count} Star each."; -"lng_action_direct_messages_paid#other" = "Channel allows Direct Messages for {count} Stars each"; +"lng_action_direct_messages_paid#other" = "Channel allows Direct Messages for {count} Stars each."; "lng_action_direct_messages_disabled" = "Channel disabled Direct Messages."; +"lng_action_todo_marked_done" = "{from} marked {tasks} as done."; +"lng_action_todo_marked_done_self" = "You marked {tasks} as done."; +"lng_action_todo_marked_not_done" = "{from} marked {tasks} as not done."; +"lng_action_todo_marked_not_done_self" = "You marked {tasks} as not done."; +"lng_action_todo_added" = "{from} added {tasks} to the list."; +"lng_action_todo_added_self" = "You added {tasks} to the list."; +"lng_action_todo_tasks_fallback#one" = "task"; +"lng_action_todo_tasks_fallback#other" = "{count} tasks"; +"lng_action_todo_tasks_and_one" = "{tasks}, {task}"; +"lng_action_todo_tasks_and_last" = "{tasks} and {task}"; "lng_you_paid_stars#one" = "You paid {count} Star."; "lng_you_paid_stars#other" = "You paid {count} Stars."; @@ -3791,6 +3801,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_in_dlg_sticker_emoji" = "{emoji} Sticker"; "lng_in_dlg_poll" = "Poll"; "lng_in_dlg_story" = "Story"; +"lng_in_dlg_todo_list" = "To-Do List"; "lng_in_dlg_story_expired" = "Expired story"; "lng_in_dlg_media_count#one" = "{count} media"; "lng_in_dlg_media_count#other" = "{count} media"; @@ -5844,6 +5855,12 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_polls_show_more#other" = "Show more ({count})"; "lng_polls_votes_collapse" = "Collapse"; +"lng_todo_title" = "To-Do List"; +"lng_todo_title_group" = "Group To-Do List"; +"lng_todo_completed#one" = "{count} of {total} completed"; +"lng_todo_completed#other" = "{count} of {total} completed"; +"lng_todo_completed_none" = "None of {total} completed"; + "lng_outdated_title" = "PLEASE UPDATE YOUR OPERATING SYSTEM."; "lng_outdated_title_bits" = "PLEASE SWITCH TO A 64-BIT OPERATING SYSTEM."; "lng_outdated_soon" = "Otherwise, Telegram Desktop will stop updating on {date}."; diff --git a/Telegram/SourceFiles/api/api_todo_lists.cpp b/Telegram/SourceFiles/api/api_todo_lists.cpp new file mode 100644 index 0000000000..dc5a7841cd --- /dev/null +++ b/Telegram/SourceFiles/api/api_todo_lists.cpp @@ -0,0 +1,204 @@ +/* +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_todo_lists.h" + +//#include "api/api_common.h" +//#include "api/api_updates.h" +#include "apiwrap.h" +//#include "base/random.h" +//#include "data/business/data_shortcut_messages.h" +//#include "data/data_changes.h" +//#include "data/data_histories.h" +#include "data/data_todo_list.h" +#include "data/data_session.h" +#include "history/history.h" +#include "history/history_item.h" +//#include "history/history_item_helpers.h" // ShouldSendSilent +#include "main/main_session.h" + +namespace Api { +namespace { + +constexpr auto kSendTogglesDelay = 3 * crl::time(1000); + +[[nodiscard]] TimeId UnixtimeFromMsgId(mtpMsgId msgId) { + return TimeId(msgId >> 32); +} + +} // namespace + +TodoLists::TodoLists(not_null api) +: _session(&api->session()) +, _api(&api->instance()) +, _sendTimer([=] { sendAccumulatedToggles(false); }) { +} +// +//void TodoLists::create( +// const PollData &data, +// SendAction action, +// Fn done, +// Fn fail) { +// _session->api().sendAction(action); +// +// const auto history = action.history; +// const auto peer = history->peer; +// const auto topicRootId = action.replyTo.messageId +// ? action.replyTo.topicRootId +// : 0; +// const auto monoforumPeerId = action.replyTo.monoforumPeerId; +// auto sendFlags = MTPmessages_SendMedia::Flags(0); +// if (action.replyTo) { +// sendFlags |= MTPmessages_SendMedia::Flag::f_reply_to; +// } +// const auto clearCloudDraft = action.clearDraft; +// if (clearCloudDraft) { +// sendFlags |= MTPmessages_SendMedia::Flag::f_clear_draft; +// history->clearLocalDraft(topicRootId, monoforumPeerId); +// history->clearCloudDraft(topicRootId, monoforumPeerId); +// history->startSavingCloudDraft(topicRootId, monoforumPeerId); +// } +// const auto silentPost = ShouldSendSilent(peer, action.options); +// const auto starsPaid = std::min( +// peer->starsPerMessageChecked(), +// action.options.starsApproved); +// if (silentPost) { +// sendFlags |= MTPmessages_SendMedia::Flag::f_silent; +// } +// if (action.options.scheduled) { +// sendFlags |= MTPmessages_SendMedia::Flag::f_schedule_date; +// } +// if (action.options.shortcutId) { +// sendFlags |= MTPmessages_SendMedia::Flag::f_quick_reply_shortcut; +// } +// if (action.options.effectId) { +// sendFlags |= MTPmessages_SendMedia::Flag::f_effect; +// } +// if (starsPaid) { +// action.options.starsApproved -= starsPaid; +// sendFlags |= MTPmessages_SendMedia::Flag::f_allow_paid_stars; +// } +// const auto sendAs = action.options.sendAs; +// if (sendAs) { +// sendFlags |= MTPmessages_SendMedia::Flag::f_send_as; +// } +// auto &histories = history->owner().histories(); +// const auto randomId = base::RandomValue(); +// histories.sendPreparedMessage( +// history, +// action.replyTo, +// randomId, +// Data::Histories::PrepareMessage( +// MTP_flags(sendFlags), +// peer->input, +// Data::Histories::ReplyToPlaceholder(), +// PollDataToInputMedia(&data), +// MTP_string(), +// MTP_long(randomId), +// MTPReplyMarkup(), +// MTPVector(), +// MTP_int(action.options.scheduled), +// (sendAs ? sendAs->input : MTP_inputPeerEmpty()), +// Data::ShortcutIdToMTP(_session, action.options.shortcutId), +// MTP_long(action.options.effectId), +// MTP_long(starsPaid) +// ), [=](const MTPUpdates &result, const MTP::Response &response) { +// if (clearCloudDraft) { +// history->finishSavingCloudDraft( +// topicRootId, +// monoforumPeerId, +// UnixtimeFromMsgId(response.outerMsgId)); +// } +// _session->changes().historyUpdated( +// history, +// (action.options.scheduled +// ? Data::HistoryUpdate::Flag::ScheduledSent +// : Data::HistoryUpdate::Flag::MessageSent)); +// done(); +// }, [=](const MTP::Error &error, const MTP::Response &response) { +// if (clearCloudDraft) { +// history->finishSavingCloudDraft( +// topicRootId, +// monoforumPeerId, +// UnixtimeFromMsgId(response.outerMsgId)); +// } +// fail(); +// }); +//} + +void TodoLists::toggleCompletion(FullMsgId itemId, int id, bool completed) { + auto &entry = _toggles[itemId]; + if (completed) { + if (!entry.completed.emplace(id).second) { + return; + } + } else { + if (!entry.incompleted.emplace(id).second) { + return; + } + } + entry.scheduled = crl::now(); + if (!entry.requestId && !_sendTimer.isActive()) { + _sendTimer.callOnce(kSendTogglesDelay); + } +} + +void TodoLists::sendAccumulatedToggles(bool force) { + const auto now = crl::now(); + auto nearest = crl::time(0); + for (auto &[itemId, entry] : _toggles) { + if (entry.requestId) { + continue; + } + const auto wait = entry.scheduled + kSendTogglesDelay - now; + if (wait <= 0) { + entry.scheduled = 0; + send(itemId, entry); + } else if (!nearest || nearest > wait) { + nearest = wait; + } + } + if (nearest > 0) { + _sendTimer.callOnce(nearest); + } +} + +void TodoLists::send(FullMsgId itemId, Accumulated &entry) { + const auto item = _session->data().message(itemId); + if (!item) { + return; + } + auto completed = entry.completed + | ranges::views::transform([](int id) { return MTP_int(id); }); + auto incompleted = entry.incompleted + | ranges::views::transform([](int id) { return MTP_int(id); }); + entry.requestId = _api.request(MTPmessages_ToggleTodoCompleted( + item->history()->peer->input, + MTP_int(item->id), + MTP_vector_from_range(completed), + MTP_vector_from_range(incompleted) + )).done([=](const MTPUpdates &result) { + _session->api().applyUpdates(result); + finishRequest(itemId); + }).fail([=](const MTP::Error &error) { + finishRequest(itemId); + }).send(); + entry.completed.clear(); + entry.incompleted.clear(); +} + +void TodoLists::finishRequest(FullMsgId itemId) { + auto &entry = _toggles[itemId]; + entry.requestId = 0; + if (entry.completed.empty() && entry.incompleted.empty()) { + _toggles.remove(itemId); + } else { + sendAccumulatedToggles(false); + } +} + +} // namespace Api diff --git a/Telegram/SourceFiles/api/api_todo_lists.h b/Telegram/SourceFiles/api/api_todo_lists.h new file mode 100644 index 0000000000..49891ea0bf --- /dev/null +++ b/Telegram/SourceFiles/api/api_todo_lists.h @@ -0,0 +1,56 @@ +/* +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" +#include "mtproto/sender.h" + +class ApiWrap; +class HistoryItem; +struct TodoListData; + +namespace Main { +class Session; +} // namespace Main + +namespace Api { + +struct SendAction; + +class TodoLists final { +public: + explicit TodoLists(not_null api); + + //void create( + // const PollData &data, + // SendAction action, + // Fn done, + // Fn fail); + void toggleCompletion(FullMsgId itemId, int id, bool completed); + +private: + struct Accumulated { + base::flat_set completed; + base::flat_set incompleted; + crl::time scheduled = 0; + mtpRequestId requestId = 0; + }; + + void sendAccumulatedToggles(bool force); + void send(FullMsgId itemId, Accumulated &entry); + void finishRequest(FullMsgId itemId); + + const not_null _session; + MTP::Sender _api; + + base::flat_map _toggles; + base::Timer _sendTimer; + +}; + +} // namespace Api diff --git a/Telegram/SourceFiles/api/api_updates.cpp b/Telegram/SourceFiles/api/api_updates.cpp index c3928b6073..aa3934e4d2 100644 --- a/Telegram/SourceFiles/api/api_updates.cpp +++ b/Telegram/SourceFiles/api/api_updates.cpp @@ -1916,7 +1916,7 @@ void Updates::feedUpdate(const MTPUpdate &update) { // Update web page anyway. session().data().processWebpage(d.vwebpage()); - session().data().sendWebPageGamePollNotifications(); + session().data().sendWebPageGamePollTodoListNotifications(); updateAndApply(d.vpts().v, d.vpts_count().v, update); } break; @@ -1926,7 +1926,7 @@ void Updates::feedUpdate(const MTPUpdate &update) { // Update web page anyway. session().data().processWebpage(d.vwebpage()); - session().data().sendWebPageGamePollNotifications(); + session().data().sendWebPageGamePollTodoListNotifications(); auto channel = session().data().channelLoaded(d.vchannel_id()); if (channel && !_handlingChannelDifference) { diff --git a/Telegram/SourceFiles/apiwrap.cpp b/Telegram/SourceFiles/apiwrap.cpp index 4f8cef0a22..cbbbf0e65a 100644 --- a/Telegram/SourceFiles/apiwrap.cpp +++ b/Telegram/SourceFiles/apiwrap.cpp @@ -21,6 +21,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_polls.h" #include "api/api_sending.h" #include "api/api_text_entities.h" +#include "api/api_todo_lists.h" #include "api/api_self_destruct.h" #include "api/api_sensitive_content.h" #include "api/api_global_privacy.h" @@ -178,6 +179,7 @@ ApiWrap::ApiWrap(not_null session) , _confirmPhone(std::make_unique(this)) , _peerPhoto(std::make_unique(this)) , _polls(std::make_unique(this)) +, _todoLists(std::make_unique(this)) , _chatParticipants(std::make_unique(this)) , _unreadThings(std::make_unique(this)) , _ringtones(std::make_unique(this)) @@ -2574,7 +2576,10 @@ void ApiWrap::refreshFileReference( }); } -void ApiWrap::gotWebPages(ChannelData *channel, const MTPmessages_Messages &result, mtpRequestId req) { +void ApiWrap::gotWebPages( + ChannelData *channel, + const MTPmessages_Messages &result, + mtpRequestId req) { WebPageData::ApplyChanges(_session, channel, result); for (auto i = _webPagesPending.begin(); i != _webPagesPending.cend();) { if (i->second == req) { @@ -2588,7 +2593,7 @@ void ApiWrap::gotWebPages(ChannelData *channel, const MTPmessages_Messages &resu ++i; } } - _session->data().sendWebPageGamePollNotifications(); + _session->data().sendWebPageGamePollTodoListNotifications(); } void ApiWrap::updateStickers() { @@ -4792,6 +4797,10 @@ Api::Polls &ApiWrap::polls() { return *_polls; } +Api::TodoLists &ApiWrap::todoLists() { + return *_todoLists; +} + Api::ChatParticipants &ApiWrap::chatParticipants() { return *_chatParticipants; } diff --git a/Telegram/SourceFiles/apiwrap.h b/Telegram/SourceFiles/apiwrap.h index cb6ed34c2d..5eabc79096 100644 --- a/Telegram/SourceFiles/apiwrap.h +++ b/Telegram/SourceFiles/apiwrap.h @@ -77,6 +77,7 @@ class ConfirmPhone; class PeerPhoto; class PeerColors; class Polls; +class TodoLists; class ChatParticipants; class UnreadThings; class Ringtones; @@ -413,6 +414,7 @@ public: [[nodiscard]] Api::ConfirmPhone &confirmPhone(); [[nodiscard]] Api::PeerPhoto &peerPhoto(); [[nodiscard]] Api::Polls &polls(); + [[nodiscard]] Api::TodoLists &todoLists(); [[nodiscard]] Api::ChatParticipants &chatParticipants(); [[nodiscard]] Api::UnreadThings &unreadThings(); [[nodiscard]] Api::Ringtones &ringtones(); @@ -764,6 +766,7 @@ private: const std::unique_ptr _confirmPhone; const std::unique_ptr _peerPhoto; const std::unique_ptr _polls; + const std::unique_ptr _todoLists; const std::unique_ptr _chatParticipants; const std::unique_ptr _unreadThings; const std::unique_ptr _ringtones; diff --git a/Telegram/SourceFiles/data/data_media_types.cpp b/Telegram/SourceFiles/data/data_media_types.cpp index cd0f169ec2..38f10e8885 100644 --- a/Telegram/SourceFiles/data/data_media_types.cpp +++ b/Telegram/SourceFiles/data/data_media_types.cpp @@ -29,6 +29,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/view/media/history_view_web_page.h" #include "history/view/media/history_view_poll.h" #include "history/view/media/history_view_theme_document.h" +#include "history/view/media/history_view_todo_list.h" #include "history/view/media/history_view_slot_machine.h" #include "history/view/media/history_view_dice.h" #include "history/view/media/history_view_service_box.h" @@ -65,6 +66,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/data_file_origin.h" #include "data/data_stories.h" #include "data/data_story.h" +#include "data/data_todo_list.h" #include "data/data_user.h" #include "main/main_session.h" #include "main/main_session_settings.h" @@ -645,6 +647,10 @@ PollData *Media::poll() const { return nullptr; } +TodoListData *Media::todolist() const { + return nullptr; +} + const WallPaper *Media::paper() const { return nullptr; } @@ -2315,6 +2321,67 @@ std::unique_ptr MediaPoll::createView( return std::make_unique(message, _poll); } +MediaTodoList::MediaTodoList( + not_null parent, + not_null todolist) +: Media(parent) +, _todolist(todolist) { +} + +MediaTodoList::~MediaTodoList() { +} + +std::unique_ptr MediaTodoList::clone(not_null parent) { + return std::make_unique(parent, _todolist); +} + +TodoListData *MediaTodoList::todolist() const { + return _todolist; +} + +TextWithEntities MediaTodoList::notificationText() const { + return TextWithEntities() + .append(QChar(0x2611)) + .append(QChar(' ')) + .append(Ui::Text::Colorized(_todolist->title)); +} + +QString MediaTodoList::pinnedTextSubstring() const { + return QChar(171) + _todolist->title.text + QChar(187); +} + +TextForMimeData MediaTodoList::clipboardText() const { + auto result = TextWithEntities(); + result + .append(u"[ "_q) + .append(tr::lng_in_dlg_todo_list(tr::now)) + .append(u" : "_q) + .append(_todolist->title) + .append(u" ]"_q); + for (const auto &item : _todolist->items) { + result.append(u"\n- "_q).append(item.text); + } + return TextForMimeData::Rich(std::move(result)); +} + +bool MediaTodoList::updateInlineResultMedia(const MTPMessageMedia &media) { + return false; +} + +bool MediaTodoList::updateSentMedia(const MTPMessageMedia &media) { + return false; +} + +std::unique_ptr MediaTodoList::createView( + not_null message, + not_null realParent, + HistoryView::Element *replacing) { + return std::make_unique( + message, + _todolist, + replacing); +} + MediaDice::MediaDice(not_null parent, QString emoji, int value) : Media(parent) , _emoji(emoji) diff --git a/Telegram/SourceFiles/data/data_media_types.h b/Telegram/SourceFiles/data/data_media_types.h index a8a0a34ac9..fd4cc54d33 100644 --- a/Telegram/SourceFiles/data/data_media_types.h +++ b/Telegram/SourceFiles/data/data_media_types.h @@ -196,6 +196,7 @@ public: virtual const GiftCode *gift() const; virtual CloudImage *location() const; virtual PollData *poll() const; + virtual TodoListData *todolist() const; virtual const WallPaper *paper() const; virtual bool paperForBoth() const; virtual FullStoryId storyId() const; @@ -610,6 +611,33 @@ private: }; +class MediaTodoList final : public Media { +public: + MediaTodoList( + not_null parent, + not_null todolist); + ~MediaTodoList(); + + std::unique_ptr clone(not_null parent) override; + + TodoListData *todolist() const override; + + TextWithEntities notificationText() const override; + QString pinnedTextSubstring() const override; + TextForMimeData clipboardText() const override; + + bool updateInlineResultMedia(const MTPMessageMedia &media) override; + bool updateSentMedia(const MTPMessageMedia &media) override; + std::unique_ptr createView( + not_null message, + not_null realParent, + HistoryView::Element *replacing = nullptr) override; + +private: + not_null _todolist; + +}; + class MediaDice final : public Media { public: MediaDice(not_null parent, QString emoji, int value); diff --git a/Telegram/SourceFiles/data/data_session.cpp b/Telegram/SourceFiles/data/data_session.cpp index 292dffc405..b173fe8b18 100644 --- a/Telegram/SourceFiles/data/data_session.cpp +++ b/Telegram/SourceFiles/data/data_session.cpp @@ -75,6 +75,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/data_premium_limits.h" #include "data/data_forum.h" #include "data/data_forum_topic.h" +#include "data/data_todo_list.h" #include "base/platform/base_platform_info.h" #include "base/unixtime.h" #include "base/call_delayed.h" @@ -1710,6 +1711,16 @@ void Session::requestPollViewRepaint(not_null poll) { } } +void Session::requestTodoListViewRepaint( + not_null todolist) { + if (const auto i = _todoListViews.find(todolist) + ; i != _todoListViews.end()) { + for (const auto &view : i->second) { + requestViewResize(view); + } + } +} + void Session::documentLoadProgress(not_null document) { requestDocumentViewRepaint(document); _documentLoadProgress.fire_copy(document); @@ -4098,6 +4109,39 @@ not_null Session::processPoll(const MTPDmessageMediaPoll &data) { return result; } +not_null Session::todoList(TodoListId id) { + auto i = _todoLists.find(id); + if (i == _todoLists.cend()) { + i = _todoLists.emplace( + id, + std::make_unique(this, id)).first; + } + return i->second.get(); +} + +not_null Session::processTodoList( + TodoListId id, + const MTPTodoList &todolist) { + const auto &data = todolist.data(); + const auto result = todoList(id); + const auto changed = result->applyChanges(data); + if (changed) { + notifyTodoListUpdateDelayed(result); + } + return result; +} + +not_null Session::processTodoList( + TodoListId id, + const MTPDmessageMediaToDo &data) { + const auto result = processTodoList(id, data.vtodo()); + const auto changed = result->applyCompletions(data.vcompletions()); + if (changed) { + notifyTodoListUpdateDelayed(result); + } + return result; +} + void Session::checkPollsClosings() { const auto now = base::unixtime::now(); auto closest = 0; @@ -4308,6 +4352,24 @@ void Session::unregisterPollView( } } +void Session::registerTodoListView( + not_null todolist, + not_null view) { + _todoListViews[todolist].insert(view); +} + +void Session::unregisterTodoListView( + not_null todolist, + not_null view) { + const auto i = _todoListViews.find(todolist); + if (i != _todoListViews.end()) { + auto &items = i->second; + if (items.remove(view) && items.empty()) { + _todoListViews.erase(i); + } + } +} + void Session::registerContactView( UserId contactId, not_null view) { @@ -4488,37 +4550,54 @@ QString Session::findContactPhone(UserId contactId) const { return QString(); } -bool Session::hasPendingWebPageGamePollNotification() const { +bool Session::hasPendingWebPageGamePollTodoListNotification() const { return !_webpagesUpdated.empty() || !_gamesUpdated.empty() - || !_pollsUpdated.empty(); + || !_pollsUpdated.empty() + || !_todoListsUpdated.empty(); } void Session::notifyWebPageUpdateDelayed(not_null page) { - const auto invoke = !hasPendingWebPageGamePollNotification(); + const auto invoke = !hasPendingWebPageGamePollTodoListNotification(); _webpagesUpdated.insert(page); if (invoke) { - crl::on_main(_session, [=] { sendWebPageGamePollNotifications(); }); + crl::on_main(_session, [=] { + sendWebPageGamePollTodoListNotifications(); + }); } } void Session::notifyGameUpdateDelayed(not_null game) { - const auto invoke = !hasPendingWebPageGamePollNotification(); + const auto invoke = !hasPendingWebPageGamePollTodoListNotification(); _gamesUpdated.insert(game); if (invoke) { - crl::on_main(_session, [=] { sendWebPageGamePollNotifications(); }); + crl::on_main(_session, [=] { + sendWebPageGamePollTodoListNotifications(); + }); } } void Session::notifyPollUpdateDelayed(not_null poll) { - const auto invoke = !hasPendingWebPageGamePollNotification(); + const auto invoke = !hasPendingWebPageGamePollTodoListNotification(); _pollsUpdated.insert(poll); if (invoke) { - crl::on_main(_session, [=] { sendWebPageGamePollNotifications(); }); + crl::on_main(_session, [=] { + sendWebPageGamePollTodoListNotifications(); + }); } } -void Session::sendWebPageGamePollNotifications() { +void Session::notifyTodoListUpdateDelayed(not_null todolist) { + const auto invoke = !hasPendingWebPageGamePollTodoListNotification(); + _todoListsUpdated.insert(todolist); + if (invoke) { + crl::on_main(_session, [=] { + sendWebPageGamePollTodoListNotifications(); + }); + } +} + +void Session::sendWebPageGamePollTodoListNotifications() { auto resize = std::vector>(); for (const auto &page : base::take(_webpagesUpdated)) { _webpageUpdates.fire_copy(page); @@ -4537,6 +4616,12 @@ void Session::sendWebPageGamePollNotifications() { resize.insert(end(resize), begin(i->second), end(i->second)); } } + for (const auto &todolist : base::take(_todoListsUpdated)) { + if (const auto i = _todoListViews.find(todolist) + ; i != _todoListViews.end()) { + resize.insert(end(resize), begin(i->second), end(i->second)); + } + } for (const auto &view : resize) { requestViewResize(view); } diff --git a/Telegram/SourceFiles/data/data_session.h b/Telegram/SourceFiles/data/data_session.h index 2ac7d93d75..62083465bf 100644 --- a/Telegram/SourceFiles/data/data_session.h +++ b/Telegram/SourceFiles/data/data_session.h @@ -536,6 +536,7 @@ public: void requestDocumentViewRepaint(not_null document); void markMediaRead(not_null document); void requestPollViewRepaint(not_null poll); + void requestTodoListViewRepaint(not_null todolist); void photoLoadProgress(not_null photo); void photoLoadDone(not_null photo); @@ -690,6 +691,14 @@ public: not_null processPoll(const MTPPoll &data); not_null processPoll(const MTPDmessageMediaPoll &data); + [[nodiscard]] not_null todoList(TodoListId id); + not_null processTodoList( + TodoListId id, + const MTPTodoList &todolist); + not_null processTodoList( + TodoListId id, + const MTPDmessageMediaToDo &data); + [[nodiscard]] not_null location( const LocationPoint &point); @@ -729,6 +738,12 @@ public: void unregisterPollView( not_null poll, not_null view); + void registerTodoListView( + not_null todolist, + not_null view); + void unregisterTodoListView( + not_null todolist, + not_null view); void registerContactView( UserId contactId, not_null view); @@ -758,8 +773,9 @@ public: void notifyWebPageUpdateDelayed(not_null page); void notifyGameUpdateDelayed(not_null game); void notifyPollUpdateDelayed(not_null poll); - [[nodiscard]] bool hasPendingWebPageGamePollNotification() const; - void sendWebPageGamePollNotifications(); + void notifyTodoListUpdateDelayed(not_null todolist); + [[nodiscard]] bool hasPendingWebPageGamePollTodoListNotification() const; + void sendWebPageGamePollTodoListNotifications(); [[nodiscard]] rpl::producer> webPageUpdates() const; void channelDifferenceTooLong(not_null channel); @@ -1066,6 +1082,9 @@ private: std::unordered_map< PollId, std::unique_ptr> _polls; + std::map< + TodoListId, + std::unique_ptr> _todoLists; std::unordered_map< GameId, std::unique_ptr> _games; @@ -1078,6 +1097,9 @@ private: std::unordered_map< not_null, base::flat_set>> _pollViews; + std::unordered_map< + not_null, + base::flat_set>> _todoListViews; std::unordered_map< UserId, base::flat_set>> _contactItems; @@ -1094,6 +1116,7 @@ private: base::flat_set> _webpagesUpdated; base::flat_set> _gamesUpdated; base::flat_set> _pollsUpdated; + base::flat_set> _todoListsUpdated; rpl::event_stream> _webpageUpdates; rpl::event_stream> _channelDifferenceTooLong; diff --git a/Telegram/SourceFiles/data/data_todo_list.cpp b/Telegram/SourceFiles/data/data_todo_list.cpp new file mode 100644 index 0000000000..1bb1a86859 --- /dev/null +++ b/Telegram/SourceFiles/data/data_todo_list.cpp @@ -0,0 +1,232 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "data/data_todo_list.h" + +#include "api/api_text_entities.h" +#include "data/data_user.h" +#include "data/data_session.h" +#include "base/call_delayed.h" +#include "history/history_item.h" +#include "main/main_session.h" +#include "api/api_text_entities.h" +#include "ui/text/text_options.h" + +namespace { + +constexpr auto kShortPollTimeout = 30 * crl::time(1000); + +const TodoListItem *ItemById(const std::vector &list, int id) { + const auto i = ranges::find(list, id, &TodoListItem::id); + return (i != end(list)) ? &*i : nullptr; +} + +TodoListItem *ItemById(std::vector &list, int id) { + return const_cast(ItemById(std::as_const(list), id)); +} + +} // namespace + +TodoListData::TodoListData(not_null owner, TodoListId id) +: id(id) +, _owner(owner) { +} + +Data::Session &TodoListData::owner() const { + return *_owner; +} + +Main::Session &TodoListData::session() const { + return _owner->session(); +} + +bool TodoListData::applyChanges(const MTPDtodoList &todolist) { + const auto newTitle = TextWithEntities{ + .text = qs(todolist.vtitle().data().vtext()), + .entities = Api::EntitiesFromMTP( + &session(), + todolist.vtitle().data().ventities().v), + }; + const auto newFlags = (todolist.is_others_can_append() + ? Flag::OthersCanAppend + : Flag()) + | (todolist.is_others_can_complete() ? Flag::OthersCanComplete + : Flag()); + auto newItems = ranges::views::all( + todolist.vlist().v + ) | ranges::views::transform([&](const MTPTodoItem &item) { + return TodoListItemFromMTP(&session(), item); + }) | ranges::views::take( + kMaxOptions + ) | ranges::to_vector; + + const auto changed1 = (title != newTitle) || (_flags != newFlags); + const auto changed2 = (items != newItems); + if (!changed1 && !changed2) { + return false; + } + if (changed1) { + title = newTitle; + _flags = newFlags; + } + if (changed2) { + std::swap(items, newItems); + for (const auto &old : newItems) { + if (const auto current = itemById(old.id)) { + current->completedBy = old.completedBy; + current->completionDate = old.completionDate; + } + } + } + ++version; + return true; +} + +bool TodoListData::applyCompletions( + const MTPVector *completions) { + auto changed = false; + const auto lookup = [&](int id) { + if (!completions) { + return (const MTPDtodoCompletion*)nullptr; + } + const auto proj = [](const MTPTodoCompletion &completion) { + return completion.data().vid().v; + }; + const auto i = ranges::find(completions->v, id, proj); + return (i != completions->v.end()) ? &i->data() : nullptr; + }; + for (auto &item : items) { + const auto completion = lookup(item.id); + const auto by = (completion && completion->vcompleted_by().v) + ? owner().user(UserId(completion->vcompleted_by().v)).get() + : nullptr; + const auto date = completion ? completion->vdate().v : TimeId(); + if (item.completedBy != by || item.completionDate != date) { + item.completedBy = by; + item.completionDate = date; + changed = true; + } + } + if (changed) { + ++version; + } + return changed; +} + +void TodoListData::apply( + not_null item, + const MTPDmessageActionTodoCompletions &data) { + for (const auto &id : data.vcompleted().v) { + if (const auto task = itemById(id.v)) { + task->completedBy = item->from(); + task->completionDate = item->date(); + } + } + for (const auto &id : data.vincompleted().v) { + if (const auto task = itemById(id.v)) { + task->completedBy = nullptr; + task->completionDate = TimeId(); + } + } + owner().notifyTodoListUpdateDelayed(this); +} + +void TodoListData::apply(const MTPDmessageActionTodoAppendTasks &data) { + const auto limit = TodoListData::kMaxOptions; + for (const auto &task : data.vlist().v) { + if (items.size() < limit) { + const auto parsed = TodoListItemFromMTP( + &session(), + task); + if (!itemById(parsed.id)) { + items.push_back(std::move(parsed)); + } + } + } + owner().notifyTodoListUpdateDelayed(this); +} + +TodoListItem *TodoListData::itemById(int id) { + return ItemById(items, id); +} + +const TodoListItem *TodoListData::itemById(int id) const { + return ItemById(items, id); +} + +void TodoListData::setFlags(Flags flags) { + if (_flags != flags) { + _flags = flags; + ++version; + } +} + +TodoListData::Flags TodoListData::flags() const { + return _flags; +} + +bool TodoListData::othersCanAppend() const { + return (_flags & Flag::OthersCanAppend); +} + +bool TodoListData::othersCanComplete() const { + return (_flags & Flag::OthersCanComplete); +} + +MTPTodoList TodoListDataToMTP(not_null todolist) { + const auto convert = [&](const TodoListItem &item) { + return MTP_todoItem( + MTP_int(item.id), + MTP_textWithEntities( + MTP_string(item.text.text), + Api::EntitiesToMTP( + &todolist->session(), + item.text.entities))); + }; + auto items = QVector(); + items.reserve(todolist->items.size()); + ranges::transform( + todolist->items, + ranges::back_inserter(items), + convert); + using Flag = MTPDtodoList::Flag; + const auto flags = Flag() + | (todolist->othersCanAppend() + ? Flag::f_others_can_append + : Flag()) + | (todolist->othersCanComplete() + ? Flag::f_others_can_complete + : Flag()); + return MTP_todoList( + MTP_flags(flags), + MTP_textWithEntities( + MTP_string(todolist->title.text), + Api::EntitiesToMTP( + &todolist->session(), + todolist->title.entities)), + MTP_vector(items)); +} + +MTPInputMedia TodoListDataToInputMedia( + not_null todolist) { + return MTP_inputMediaTodo(TodoListDataToMTP(todolist)); +} + +TodoListItem TodoListItemFromMTP( + not_null session, + const MTPTodoItem &item) { + const auto &data = item.data(); + return { + .text = TextWithEntities{ + .text = qs(data.vtitle().data().vtext()), + .entities = Api::EntitiesFromMTP( + session, + data.vtitle().data().ventities().v), + }, + .id = data.vid().v, + }; +} diff --git a/Telegram/SourceFiles/data/data_todo_list.h b/Telegram/SourceFiles/data/data_todo_list.h new file mode 100644 index 0000000000..3ba84c6c78 --- /dev/null +++ b/Telegram/SourceFiles/data/data_todo_list.h @@ -0,0 +1,79 @@ +/* +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 + +namespace Data { +class Session; +} // namespace Data + +namespace Main { +class Session; +} // namespace Main + +struct TodoListItem { + TextWithEntities text; + PeerData *completedBy = nullptr; + TimeId completionDate = 0; + int id = 0; + + friend inline bool operator==( + const TodoListItem &, + const TodoListItem &) = default; +}; + +struct TodoListData { + TodoListData(not_null owner, TodoListId id); + + [[nodiscard]] Data::Session &owner() const; + [[nodiscard]] Main::Session &session() const; + + enum class Flag { + OthersCanAppend = 0x01, + OthersCanComplete = 0x02, + }; + friend inline constexpr bool is_flag_type(Flag) { return true; }; + using Flags = base::flags; + + bool applyChanges(const MTPDtodoList &todolist); + bool applyCompletions(const MTPVector *completions); + + void apply( + not_null item, + const MTPDmessageActionTodoCompletions &data); + void apply(const MTPDmessageActionTodoAppendTasks &data); + + [[nodiscard]] TodoListItem *itemById(int id); + [[nodiscard]] const TodoListItem *itemById(int id) const; + + void setFlags(Flags flags); + [[nodiscard]] Flags flags() const; + [[nodiscard]] bool othersCanAppend() const; + [[nodiscard]] bool othersCanComplete() const; + + TodoListId id; + TextWithEntities title; + std::vector items; + int version = 0; + + static constexpr auto kMaxOptions = 32; + +private: + bool applyCompletionToItems(const MTPTodoCompletion *result); + + const not_null _owner; + Flags _flags = Flags(); + +}; + +[[nodiscard]] MTPTodoList TodoListDataToMTP( + not_null todolist); +[[nodiscard]] MTPInputMedia TodoListDataToInputMedia( + not_null todolist); +[[nodiscard]] TodoListItem TodoListItemFromMTP( + not_null session, + const MTPTodoItem &item); diff --git a/Telegram/SourceFiles/data/data_types.h b/Telegram/SourceFiles/data/data_types.h index c1ed9c42f7..c8f373a4f6 100644 --- a/Telegram/SourceFiles/data/data_types.h +++ b/Telegram/SourceFiles/data/data_types.h @@ -128,6 +128,7 @@ struct WebPageData; struct GameData; struct BotAppData; struct PollData; +struct TodoListData; using PhotoId = uint64; using VideoId = uint64; @@ -136,6 +137,7 @@ using DocumentId = uint64; using WebPageId = uint64; using GameId = uint64; using PollId = uint64; +using TodoListId = FullMsgId; using WallPaperId = uint64; using CallId = uint64; using BotAppId = uint64; diff --git a/Telegram/SourceFiles/data/data_web_page.cpp b/Telegram/SourceFiles/data/data_web_page.cpp index 74db4d6b5a..508a45d7c0 100644 --- a/Telegram/SourceFiles/data/data_web_page.cpp +++ b/Telegram/SourceFiles/data/data_web_page.cpp @@ -372,7 +372,7 @@ void WebPageData::ApplyChanges( }, [&](const auto &) { }); } - session->data().sendWebPageGamePollNotifications(); + session->data().sendWebPageGamePollTodoListNotifications(); } QString WebPageData::displayedSiteName() const { diff --git a/Telegram/SourceFiles/history/history.cpp b/Telegram/SourceFiles/history/history.cpp index 40ba51d43e..367cde6421 100644 --- a/Telegram/SourceFiles/history/history.cpp +++ b/Telegram/SourceFiles/history/history.cpp @@ -46,6 +46,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/data_document.h" #include "data/data_histories.h" #include "data/data_history_messages.h" +#include "data/data_todo_list.h" #include "lang/lang_keys.h" #include "apiwrap.h" #include "api/api_chat_participants.h" @@ -1331,6 +1332,28 @@ void History::applyServiceChanges( Core::App().calls().showConferenceInvite(user, item->id); } } + }, [&](const MTPDmessageActionTodoCompletions &data) { + if (const auto done = item->Get()) { + const auto list = done->msg + ? done->msg + : owner().message(peer, done->msgId); + if (const auto media = list ? list->media() : nullptr) { + if (const auto todolist = media->todolist()) { + todolist->apply(item, data); + } + } + } + }, [&](const MTPDmessageActionTodoAppendTasks &data) { + if (const auto done = item->Get()) { + const auto list = done->msg + ? done->msg + : owner().message(peer, done->msgId); + if (const auto media = list ? list->media() : nullptr) { + if (const auto todolist = media->todolist()) { + todolist->apply(data); + } + } + } }, [](const auto &) { }); } diff --git a/Telegram/SourceFiles/history/history_item.cpp b/Telegram/SourceFiles/history/history_item.cpp index 0808d03fd3..18dfcba072 100644 --- a/Telegram/SourceFiles/history/history_item.cpp +++ b/Telegram/SourceFiles/history/history_item.cpp @@ -62,6 +62,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/data_user.h" #include "data/data_group_call.h" // Data::GroupCall::id(). #include "data/data_poll.h" // PollData::publicVotes. +#include "data/data_todo_list.h" #include "data/data_stories.h" #include "data/data_web_page.h" #include "chat_helpers/stickers_gift_box_pack.h" @@ -358,7 +359,9 @@ std::unique_ptr HistoryItem::CreateMedia( item, item->history()->owner().processPoll(media)); }, [&](const MTPDmessageMediaToDo &media) -> Result { - return nullptr; // #TODO todo + return std::make_unique( + item, + item->history()->owner().processTodoList(item->fullId(), media)); }, [&](const MTPDmessageMediaDice &media) -> Result { return std::make_unique( item, @@ -820,6 +823,10 @@ HistoryServiceDependentData *HistoryItem::GetServiceDependentData() { return same; } else if (const auto results = Get()) { return results; + } else if (const auto done = Get()) { + return done; + } else if (const auto append = Get()) { + return append; } return nullptr; } @@ -877,6 +884,10 @@ void HistoryItem::updateDependentServiceText() { updateServiceText(prepareGameScoreText()); } else if (Has()) { updateServiceText(preparePaymentSentText()); + } else if (Has()) { + updateServiceText(prepareTodoCompletionsText()); + } else if (Has()) { + updateServiceText(prepareTodoAppendTasksText()); } } @@ -4528,12 +4539,32 @@ void HistoryItem::createServiceFromMtp(const MTPDmessageService &message) { refund->transactionId = qs(data.vcharge().data().vid()); const auto id = fullId(); refund->link = std::make_shared([=]( - ClickContext context) { + ClickContext context) { const auto my = context.other.value(); if (const auto window = my.sessionWindow.get()) { Settings::ShowRefundInfoBox(window, id); } }); + } else if (type == mtpc_messageActionTodoCompletions) { + const auto &data = action.c_messageActionTodoCompletions(); + UpdateComponents(HistoryServiceTodoCompletions::Bit()); + const auto done = Get(); + done->completed = data.vcompleted().v + | ranges::views::transform(&MTPint::v) + | ranges::to_vector; + done->incompleted = data.vincompleted().v + | ranges::views::transform(&MTPint::v) + | ranges::to_vector; + } else if (type == mtpc_messageActionTodoAppendTasks) { + const auto session = &_history->session(); + const auto &data = action.c_messageActionTodoAppendTasks(); + UpdateComponents(HistoryServiceTodoAppendTasks::Bit()); + const auto append = Get(); + append->list = ranges::views::all( + data.vlist().v + ) | ranges::views::transform([&](const MTPTodoItem &item) { + return TodoListItemFromMTP(session, item); + }) | ranges::to_vector; } if (const auto replyTo = message.vreply_to()) { replyTo->match([&](const MTPDmessageReplyHeader &data) { @@ -5874,14 +5905,12 @@ void HistoryItem::setServiceMessageByAction(const MTPmessageAction &action) { return result; }; - auto prepareTodoCompletions = [&](const MTPDmessageActionTodoCompletions &action) { - auto result = PreparedServiceText(); // #TODO todo - return result; + auto prepareTodoCompletions = [&](const MTPDmessageActionTodoCompletions &) { + return prepareTodoCompletionsText(); }; - auto prepareTodoAppendTasks = [&](const MTPDmessageActionTodoAppendTasks &action) { - auto result = PreparedServiceText(); // #TODO todo - return result; + auto prepareTodoAppendTasks = [&](const MTPDmessageActionTodoAppendTasks &) { + return prepareTodoAppendTasksText(); }; auto prepareConferenceCall = [&](const MTPDmessageActionConferenceCall &) -> PreparedServiceText { @@ -6549,6 +6578,92 @@ PreparedServiceText HistoryItem::prepareCallScheduledText( return result; } +PreparedServiceText HistoryItem::composeTodoIncompleted( + not_null done) { + const auto tasks = ComposeTodoTasksList(done->msg, done->incompleted); + if (out()) { + return { + tr::lng_action_todo_marked_not_done_self( + tr::now, + lt_tasks, + tasks, + Ui::Text::WithEntities), + }; + } + return { + .text = tr::lng_action_todo_marked_not_done( + tr::now, + lt_from, + fromLinkText(), + lt_tasks, + tasks, + Ui::Text::WithEntities), + .links = { fromLink() }, + }; +} + +PreparedServiceText HistoryItem::composeTodoCompleted( + not_null done) { + const auto tasks = ComposeTodoTasksList(done->msg, done->completed); + if (out()) { + return { + tr::lng_action_todo_marked_done_self( + tr::now, + lt_tasks, + tasks, + Ui::Text::WithEntities), + }; + } + return { + .text = tr::lng_action_todo_marked_done( + tr::now, + lt_from, + fromLinkText(), + lt_tasks, + tasks, + Ui::Text::WithEntities), + .links = { fromLink() }, + }; +} + +PreparedServiceText HistoryItem::prepareTodoCompletionsText() { + auto result = PreparedServiceText(); + const auto done = Get(); + Assert(done != nullptr); + + return done->completed.empty() + ? composeTodoIncompleted(done) + : composeTodoCompleted(done); +} + +PreparedServiceText HistoryItem::prepareTodoAppendTasksText() { + auto result = PreparedServiceText(); + auto append = Get(); + Assert(append != nullptr); + + const auto tasks = ComposeTodoTasksList(append); + if (out()) { + return { + tr::lng_action_todo_added_self( + tr::now, + lt_tasks, + tasks, + Ui::Text::WithEntities), + }; + } + return { + .text = tr::lng_action_todo_added( + tr::now, + lt_from, + fromLinkText(), + lt_tasks, + tasks, + Ui::Text::WithEntities), + .links = { fromLink() }, + }; + return result; +} + TextWithEntities HistoryItem::fromLinkText() const { return Ui::Text::Link(st::wrap_rtl(_from->name()), 1); } diff --git a/Telegram/SourceFiles/history/history_item.h b/Telegram/SourceFiles/history/history_item.h index 55904615ce..d869944333 100644 --- a/Telegram/SourceFiles/history/history_item.h +++ b/Telegram/SourceFiles/history/history_item.h @@ -23,6 +23,7 @@ struct HistoryMessageReplyMarkup; struct HistoryMessageTranslation; struct HistoryMessageForwarded; struct HistoryServiceDependentData; +struct HistoryServiceTodoCompletions; enum class HistorySelfDestructType; struct PreparedServiceText; struct MessageFactcheck; @@ -644,6 +645,13 @@ private: CallId linkCallId); [[nodiscard]] PreparedServiceText prepareCallScheduledText( TimeId scheduleDate); + [[nodiscard]] PreparedServiceText prepareTodoCompletionsText(); + [[nodiscard]] PreparedServiceText prepareTodoAppendTasksText(); + + [[nodiscard]] PreparedServiceText composeTodoIncompleted( + not_null done); + [[nodiscard]] PreparedServiceText composeTodoCompleted( + not_null done); [[nodiscard]] PreparedServiceText prepareServiceTextForMessage( const MTPMessageMedia &media, diff --git a/Telegram/SourceFiles/history/history_item_components.cpp b/Telegram/SourceFiles/history/history_item_components.cpp index edb6a099da..f4381d5756 100644 --- a/Telegram/SourceFiles/history/history_item_components.cpp +++ b/Telegram/SourceFiles/history/history_item_components.cpp @@ -48,6 +48,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/data_file_click_handler.h" #include "data/data_session.h" #include "data/data_stories.h" +#include "data/data_todo_list.h" #include "main/main_session.h" #include "window/window_session_controller.h" #include "api/api_bot.h" @@ -70,6 +71,38 @@ base::options::toggle FastButtonsModeOption({ .description = "Trigger inline keyboard buttons by 1-9 keyboard keys.", }); +[[nodiscard]] TextWithEntities ComposeTodoTasksList( + int fullCount, + const std::vector &names) { + const auto count = int(names.size()); + if (!count) { + return tr::lng_action_todo_tasks_fallback( + tr::now, + lt_count, + fullCount, + Ui::Text::WithEntities); + } else if (count == 1) { + return names.front(); + } + auto full = names.front(); + for (auto i = 1; i != count - 1; ++i) { + full = tr::lng_action_todo_tasks_and_one( + tr::now, + lt_tasks, + full, + lt_task, + names[i], + Ui::Text::WithEntities); + } + return tr::lng_action_todo_tasks_and_last( + tr::now, + lt_tasks, + full, + lt_task, + names.back(), + Ui::Text::WithEntities); +} + } // namespace const char kOptionFastButtonsMode[] = "fast-buttons-mode"; @@ -1225,6 +1258,38 @@ MessageFactcheck FromMTP( return result; } +TextWithEntities ComposeTodoTasksList( + HistoryItem *itemWithList, + const std::vector &ids) { + const auto media = itemWithList ? itemWithList->media() : nullptr; + const auto list = media ? media->todolist() : nullptr; + auto names = std::vector(); + if (list) { + names.reserve(ids.size()); + for (const auto &id : ids) { + const auto i = ranges::find(list->items, id, &TodoListItem::id); + if (i == end(list->items)) { + names.clear(); + break; + } + names.push_back( + TextWithEntities().append('"').append(i->text).append('"')); + } + } + return ComposeTodoTasksList(ids.size(), names); +} + +TextWithEntities ComposeTodoTasksList( + not_null append) { + auto names = std::vector(); + names.reserve(append->list.size()); + for (const auto &task : append->list) { + names.push_back( + TextWithEntities().append('"').append(task.text).append('"')); + } + return ComposeTodoTasksList(names.size(), names); +} + HistoryDocumentCaptioned::HistoryDocumentCaptioned() : caption(st::msgFileMinWidth - st::msgPadding.left() - st::msgPadding.right()) { } diff --git a/Telegram/SourceFiles/history/history_item_components.h b/Telegram/SourceFiles/history/history_item_components.h index 6ec434a967..7050237255 100644 --- a/Telegram/SourceFiles/history/history_item_components.h +++ b/Telegram/SourceFiles/history/history_item_components.h @@ -16,6 +16,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/chat/message_bubble.h" struct WebPageData; +struct TodoListItem; class VoiceSeekClickHandler; class ReplyKeyboard; @@ -661,6 +662,26 @@ struct HistoryServiceTopicInfo } }; +struct HistoryServiceTodoCompletions +: RuntimeComponent +, HistoryServiceDependentData { + std::vector completed; + std::vector incompleted; +}; + +[[nodiscard]] TextWithEntities ComposeTodoTasksList( + HistoryItem *itemWithList, + const std::vector &ids); + +struct HistoryServiceTodoAppendTasks +: RuntimeComponent +, HistoryServiceDependentData { + std::vector list; +}; + +[[nodiscard]] TextWithEntities ComposeTodoTasksList( + not_null append); + struct HistoryServiceGameScore : RuntimeComponent , HistoryServiceDependentData { diff --git a/Telegram/SourceFiles/history/view/history_view_element.cpp b/Telegram/SourceFiles/history/view/history_view_element.cpp index 3e7377f7ea..cd5d3bc339 100644 --- a/Telegram/SourceFiles/history/view/history_view_element.cpp +++ b/Telegram/SourceFiles/history/view/history_view_element.cpp @@ -598,12 +598,15 @@ void MonoforumSenderBar::Paint( }); } -void ServicePreMessage::init(PreparedServiceText string) { +void ServicePreMessage::init( + PreparedServiceText string, + ClickHandlerPtr fullClickHandler) { text = Ui::Text::String( st::serviceTextStyle, string.text, kMarkupTextOptions, st::msgMinWidth); + handler = std::move(fullClickHandler); for (auto i = 0; i != int(string.links.size()); ++i) { text.setLink(i + 1, string.links[i]); } @@ -687,10 +690,16 @@ ClickHandlerPtr ServicePreMessage::textState( if (trect.contains(point)) { auto textRequest = request.forText(); textRequest.align = style::al_center; - return text.getState( + const auto link = text.getState( point - trect.topLeft(), trect.width(), textRequest).link; + if (link) { + return link; + } + } + if (handler && rect.contains(point)) { + return handler; } return {}; } @@ -1282,6 +1291,16 @@ void Element::validateText() { ? _textItem->customTextLinks() : contextDependentText.links; setTextWithLinks(markedText, customLinks); + + if (const auto done = item->Get()) { + if (!done->completed.empty() && !done->incompleted.empty()) { + setServicePreMessage( + item->composeTodoIncompleted(done), + done->lnk); + } else { + setServicePreMessage({}); + } + } } else { const auto unavailable = item->computeUnavailableReason(); if (!unavailable.isEmpty()) { @@ -1606,11 +1625,13 @@ void Element::setDisplayDate(bool displayDate) { } } -void Element::setServicePreMessage(PreparedServiceText text) { +void Element::setServicePreMessage( + PreparedServiceText text, + ClickHandlerPtr fullClickHandler) { if (!text.text.empty()) { AddComponents(ServicePreMessage::Bit()); const auto service = Get(); - service->init(std::move(text)); + service->init(std::move(text), std::move(fullClickHandler)); setPendingResize(); } else if (Has()) { RemoveComponents(ServicePreMessage::Bit()); diff --git a/Telegram/SourceFiles/history/view/history_view_element.h b/Telegram/SourceFiles/history/view/history_view_element.h index ea4d9f0f19..a27dcae59d 100644 --- a/Telegram/SourceFiles/history/view/history_view_element.h +++ b/Telegram/SourceFiles/history/view/history_view_element.h @@ -309,7 +309,7 @@ private: // Any HistoryView::Element can have this Component for // displaying some text in layout of a service message above the message. struct ServicePreMessage : RuntimeComponent { - void init(PreparedServiceText string); + void init(PreparedServiceText string, ClickHandlerPtr fullClickHandler); int resizeToWidth(int newWidth, ElementChatMode mode); @@ -324,6 +324,7 @@ struct ServicePreMessage : RuntimeComponent { QRect g) const; Ui::Text::String text; + ClickHandlerPtr handler; int width = 0; int height = 0; @@ -456,7 +457,9 @@ public: // For blocks context this should be called only from recountDisplayDate(). void setDisplayDate(bool displayDate); - void setServicePreMessage(PreparedServiceText text); + void setServicePreMessage( + PreparedServiceText text, + ClickHandlerPtr fullClickHandler = nullptr); bool computeIsAttachToPrevious(not_null previous); diff --git a/Telegram/SourceFiles/history/view/history_view_service_message.cpp b/Telegram/SourceFiles/history/view/history_view_service_message.cpp index 02470dc925..c02bdd099c 100644 --- a/Telegram/SourceFiles/history/view/history_view_service_message.cpp +++ b/Telegram/SourceFiles/history/view/history_view_service_message.cpp @@ -17,6 +17,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/data_abstract_structure.h" #include "data/data_chat.h" #include "data/data_channel.h" +#include "data/data_todo_list.h" #include "info/profile/info_profile_cover.h" #include "ui/chat/chat_style.h" #include "ui/effects/reaction_fly_animation.h" @@ -448,16 +449,14 @@ void Service::animateReaction(Ui::ReactionFlyAnimationArgs &&args) { } QSize Service::performCountCurrentSize(int newWidth) { - auto newHeight = displayedDateHeight(); - if (const auto bar = Get()) { - newHeight += bar->height(); - } - if (const auto monoforumBar = Get()) { - newHeight += monoforumBar->height(); - } + auto newHeight = marginTop(); data()->resolveDependent(); + if (const auto service = Get()) { + service->resizeToWidth(newWidth, delegate()->elementChatMode()); + } + if (isHidden()) { return { newWidth, newHeight }; } @@ -465,9 +464,7 @@ QSize Service::performCountCurrentSize(int newWidth) { const auto mediaDisplayed = media && media->isDisplayed(); auto contentWidth = newWidth; if (mediaDisplayed && media->hideServiceText()) { - newHeight += st::msgServiceMargin.top() - + media->resizeGetHeight(newWidth) - + st::msgServiceMargin.bottom(); + newHeight += media->resizeGetHeight(newWidth) + marginBottom(); } else if (!text().isEmpty()) { if (delegate()->elementChatMode() == ElementChatMode::Wide) { accumulate_min(contentWidth, st::msgMaxWidth + 2 * st::msgPhotoSkip + 2 * st::msgMargin.left()); @@ -481,12 +478,15 @@ QSize Service::performCountCurrentSize(int newWidth) { newHeight += (contentWidth >= maxWidth()) ? minHeight() : textHeightFor(nwidth); - newHeight += st::msgServicePadding.top() + st::msgServicePadding.bottom() + st::msgServiceMargin.top() + st::msgServiceMargin.bottom(); + newHeight += st::msgServicePadding.top() + st::msgServicePadding.bottom(); if (mediaDisplayed) { const auto mediaWidth = std::min(media->maxWidth(), nwidth); newHeight += st::msgServiceMargin.top() + media->resizeGetHeight(mediaWidth); } + newHeight += marginBottom(); + } else { + newHeight -= st::msgServiceMargin.top(); } if (_reactions) { @@ -523,7 +523,7 @@ bool Service::isHidden() const { } int Service::marginTop() const { - auto result = st::msgServiceMargin.top(); + auto result = isHidden() ? 0 : st::msgServiceMargin.top(); result += displayedDateHeight(); if (const auto bar = Get()) { result += bar->height(); @@ -531,6 +531,9 @@ int Service::marginTop() const { if (const auto monoforumBar = Get()) { result += monoforumBar->height(); } + if (const auto service = Get()) { + result += service->height; + } return result; } @@ -566,6 +569,10 @@ void Service::draw(Painter &p, const PaintContext &context) const { } } + if (const auto service = Get()) { + service->paint(p, context, g, delegate()->elementChatMode()); + } + if (isHidden()) { return; } @@ -667,6 +674,13 @@ TextState Service::textState(QPoint point, StateRequest request) const { return result; } + if (const auto service = Get()) { + result.link = service->textState(point, request, g); + if (result.link) { + return result; + } + } + if (_reactions) { const auto reactionsHeight = st::mediaInBubbleSkip + _reactions->height(); const auto reactionsLeft = 0; @@ -724,6 +738,10 @@ TextState Service::textState(QPoint point, StateRequest request) const { result.link = custom->link; } else if (const auto payment = item->Get()) { result.link = payment->link; + } else if (const auto done = item->Get()) { + result.link = done->lnk; + } else if (const auto append = item->Get()) { + result.link = append->lnk; } else if (media && data()->showSimilarChannels()) { result = media->textState(mediaPoint, request); } diff --git a/Telegram/SourceFiles/history/view/media/history_view_media.cpp b/Telegram/SourceFiles/history/view/media/history_view_media.cpp index 1d3554972a..8741c51f69 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_media.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_media.cpp @@ -589,6 +589,10 @@ QImage Media::locationTakeImage() { return QImage(); } +std::vector Media::takeTasksInfo() { + return {}; +} + TextState Media::getStateGrouped( const QRect &geometry, RectParts sides, diff --git a/Telegram/SourceFiles/history/view/media/history_view_media.h b/Telegram/SourceFiles/history/view/media/history_view_media.h index 844f0465c4..adf66c7c3d 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_media.h +++ b/Telegram/SourceFiles/history/view/media/history_view_media.h @@ -209,6 +209,14 @@ public: not_null data, const Lottie::ColorReplacements *replacements); virtual QImage locationTakeImage(); + + struct TodoTaskInfo { + int id = 0; + PeerData *completedBy = nullptr; + TimeId completionDate = TimeId(); + }; + virtual std::vector takeTasksInfo(); + virtual void checkAnimation() { } diff --git a/Telegram/SourceFiles/history/view/media/history_view_todo_list.cpp b/Telegram/SourceFiles/history/view/media/history_view_todo_list.cpp new file mode 100644 index 0000000000..af6ad3c56f --- /dev/null +++ b/Telegram/SourceFiles/history/view/media/history_view_todo_list.cpp @@ -0,0 +1,712 @@ +/* +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/media/history_view_todo_list.h" + +#include "base/unixtime.h" +#include "core/ui_integration.h" // TextContext +#include "lang/lang_keys.h" +#include "history/history.h" +#include "history/history_item.h" +#include "history/view/history_view_message.h" +#include "history/view/history_view_cursor_state.h" +#include "calls/calls_instance.h" +#include "ui/chat/message_bubble.h" +#include "ui/chat/chat_style.h" +#include "ui/text/text_options.h" +#include "ui/text/text_utilities.h" +#include "ui/text/format_values.h" +#include "ui/effects/animations.h" +#include "ui/effects/radial_animation.h" +#include "ui/effects/ripple_animation.h" +#include "ui/toast/toast.h" +#include "ui/painter.h" +#include "data/data_media_types.h" +#include "data/data_poll.h" +#include "data/data_user.h" +#include "data/data_session.h" +#include "base/unixtime.h" +#include "base/timer.h" +#include "main/main_session.h" +#include "apiwrap.h" +#include "api/api_todo_lists.h" +#include "styles/style_chat.h" +#include "styles/style_widgets.h" +#include "styles/style_window.h" + +namespace HistoryView { +namespace { + +constexpr auto kShowRecentVotersCount = 3; +constexpr auto kRotateSegments = 8; +constexpr auto kRotateAmplitude = 3.; +constexpr auto kScaleSegments = 2; +constexpr auto kScaleAmplitude = 0.03; +constexpr auto kLargestRadialDuration = 30 * crl::time(1000); +constexpr auto kCriticalCloseDuration = 5 * crl::time(1000); + +} // namespace + +struct TodoList::Task { + Task(); + + void fillData( + not_null todolist, + const TodoListItem &original, + Ui::Text::MarkedContext context); + + Ui::Text::String text; + PeerData *completedBy = nullptr; + mutable Ui::PeerUserpicView userpic; + TimeId completionDate = 0; + int id = 0; + ClickHandlerPtr handler; + Ui::Animations::Simple selectedAnimation; + mutable std::unique_ptr ripple; +}; + +TodoList::Task::Task() : text(st::msgMinWidth / 2) { +} + +void TodoList::Task::fillData( + not_null todolist, + const TodoListItem &original, + Ui::Text::MarkedContext context) { + id = original.id; + if (original.completedBy) { + completedBy = original.completedBy; + } + completionDate = original.completionDate; + if (!text.isEmpty() && text.toTextWithEntities() == original.text) { + return; + } + text.setMarkedText( + st::historyPollAnswerStyle, + original.text, + Ui::WebpageTextTitleOptions(), + context); +} + +TodoList::TodoList( + not_null parent, + not_null todolist, + Element *replacing) +: Media(parent) +, _todolist(todolist) +, _title(st::msgMinWidth / 2) { + history()->owner().registerTodoListView(_todolist, _parent); + if (const auto media = replacing ? replacing->media() : nullptr) { + const auto info = media->takeTasksInfo(); + if (!info.empty()) { + setupPreviousState(info); + } + } +} + +void TodoList::setupPreviousState(const std::vector &info) { + // If we restore state from the view we're replacing we'll be able to + // animate the changes properly. + updateTasks(true); + for (auto &task : _tasks) { + const auto i = ranges::find(info, task.id, &TodoTaskInfo::id); + if (i != end(info)) { + task.completedBy = i->completedBy; + task.completionDate = i->completionDate; + } + } +} + +QSize TodoList::countOptimalSize() { + updateTexts(); + + const auto paddings = st::msgPadding.left() + st::msgPadding.right(); + + auto maxWidth = st::msgFileMinWidth; + accumulate_max(maxWidth, paddings + _title.maxWidth()); + for (const auto &task : _tasks) { + accumulate_max( + maxWidth, + paddings + + st::historyPollAnswerPadding.left() + + task.text.maxWidth() + + st::historyPollAnswerPadding.right()); + } + + const auto tasksHeight = ranges::accumulate(ranges::views::all( + _tasks + ) | ranges::views::transform([](const Task &task) { + return st::historyPollAnswerPadding.top() + + task.text.minHeight() + + st::historyPollAnswerPadding.bottom(); + }), 0); + + const auto bottomButtonHeight = st::historyPollBottomButtonSkip; + auto minHeight = st::historyPollQuestionTop + + _title.minHeight() + + st::historyPollSubtitleSkip + + st::msgDateFont->height + + st::historyPollAnswersSkip + + tasksHeight + + st::historyPollTotalVotesSkip + + bottomButtonHeight + + st::msgDateFont->height + + st::msgPadding.bottom(); + if (!isBubbleTop()) { + minHeight -= st::msgFileTopMinus; + } + return { maxWidth, minHeight }; +} + +bool TodoList::canComplete() const { + return (_parent->data()->out() || _todolist->othersCanComplete()) + && _parent->data()->isRegular(); +} + +int TodoList::countTaskTop( + const Task &task, + int innerWidth) const { + auto tshift = st::historyPollQuestionTop; + if (!isBubbleTop()) { + tshift -= st::msgFileTopMinus; + } + tshift += _title.countHeight(innerWidth) + st::historyPollSubtitleSkip; + tshift += st::msgDateFont->height + st::historyPollAnswersSkip; + const auto i = ranges::find( + _tasks, + &task, + [](const Task &task) { return &task; }); + const auto countHeight = [&](const Task &task) { + return countTaskHeight(task, innerWidth); + }; + tshift += ranges::accumulate( + begin(_tasks), + i, + 0, + ranges::plus(), + countHeight); + return tshift; +} + +int TodoList::countTaskHeight( + const Task &task, + int innerWidth) const { + const auto answerWidth = innerWidth + - st::historyPollAnswerPadding.left() + - st::historyPollAnswerPadding.right(); + return st::historyPollAnswerPadding.top() + + task.text.countHeight(answerWidth) + + st::historyPollAnswerPadding.bottom(); +} + +QSize TodoList::countCurrentSize(int newWidth) { + accumulate_min(newWidth, maxWidth()); + const auto innerWidth = newWidth + - st::msgPadding.left() + - st::msgPadding.right(); + + const auto tasksHeight = ranges::accumulate(ranges::views::all( + _tasks + ) | ranges::views::transform([&](const Task &task) { + return countTaskHeight(task, innerWidth); + }), 0); + + const auto bottomButtonHeight = st::historyPollBottomButtonSkip; + auto newHeight = st::historyPollQuestionTop + + _title.countHeight(innerWidth) + + st::historyPollSubtitleSkip + + st::msgDateFont->height + + st::historyPollAnswersSkip + + tasksHeight + + st::historyPollTotalVotesSkip + + bottomButtonHeight + + st::msgDateFont->height + + st::msgPadding.bottom(); + if (!isBubbleTop()) { + newHeight -= st::msgFileTopMinus; + } + return { newWidth, newHeight }; +} + +void TodoList::updateTexts() { + if (_todoListVersion == _todolist->version) { + return; + } + const auto skipAnimations = _tasks.empty(); + _todoListVersion = _todolist->version; + + if (_title.toTextWithEntities() != _todolist->title) { + auto options = Ui::WebpageTextTitleOptions(); + options.maxw = options.maxh = 0; + _title.setMarkedText( + st::historyPollQuestionStyle, + _todolist->title, + options, + Core::TextContext({ + .session = &_todolist->session(), + .repaint = [=] { repaint(); }, + .customEmojiLoopLimit = 2, + })); + } + if (_flags != _todolist->flags() || _subtitle.isEmpty()) { + using Flag = PollData::Flag; + _flags = _todolist->flags(); + _subtitle.setText( + st::msgDateTextStyle, + (parent()->history()->peer->isUser() + ? tr::lng_todo_title(tr::now) + : tr::lng_todo_title_group(tr::now))); + } + updateTasks(skipAnimations); +} + +void TodoList::updateTasks(bool skipAnimations) { + const auto context = Core::TextContext({ + .session = &_todolist->session(), + .repaint = [=] { repaint(); }, + .customEmojiLoopLimit = 2, + }); + const auto changed = !ranges::equal( + _tasks, + _todolist->items, + ranges::equal_to(), + &Task::id, + &TodoListItem::id); + if (!changed) { + auto &&tasks = ranges::views::zip(_tasks, _todolist->items); + for (auto &&[task, original] : tasks) { + const auto wasDate = task.completionDate; + task.fillData(_todolist, original, context); + if (!skipAnimations && (!wasDate != !task.completionDate)) { + startToggleAnimation(task); + } + } + return; + } + _tasks = ranges::views::all( + _todolist->items + ) | ranges::views::transform([&](const TodoListItem &item) { + auto result = Task(); + result.id = item.id; + result.fillData(_todolist, item, context); + return result; + }) | ranges::to_vector; + + for (auto &task : _tasks) { + task.handler = createTaskClickHandler(task); + } +} + +ClickHandlerPtr TodoList::createTaskClickHandler( + const Task &task) { + const auto id = task.id; + return std::make_shared(crl::guard(this, [=] { + toggleCompletion(id); + })); +} + +void TodoList::startToggleAnimation(Task &task) { + const auto selected = (task.completionDate != 0); + task.selectedAnimation.start( + [=] { repaint(); }, + selected ? 0. : 1., + selected ? 1. : 0., + st::defaultCheck.duration); +} + +void TodoList::toggleCompletion(int id) { + const auto i = ranges::find( + _tasks, + id, + &Task::id); + if (i == end(_tasks)) { + return; + } + const auto selected = (i->completionDate != 0); + i->completionDate = selected ? TimeId() : base::unixtime::now(); + if (!selected) { + i->completedBy = _parent->history()->session().user(); + } + startToggleAnimation(*i); + repaint(); + + history()->session().api().todoLists().toggleCompletion( + _parent->data()->fullId(), + id, + !selected); +} + +void TodoList::updateCompletionStatus() { + const auto incompleted = int(ranges::count( + _todolist->items, + nullptr, + &TodoListItem::completedBy)); + const auto total = int(_todolist->items.size()); + if (_total == total + && _incompleted == incompleted + && !_completionStatusLabel.isEmpty()) { + return; + } + _total = total; + _incompleted = incompleted; + const auto totalText = QString::number(total); + const auto string = (incompleted == total) + ? tr::lng_todo_completed_none(tr::now, lt_total, totalText) + : tr::lng_todo_completed( + tr::now, + lt_count, + total - incompleted, + lt_total, + totalText); + _completionStatusLabel.setText(st::msgDateTextStyle, string); +} + +void TodoList::draw(Painter &p, const PaintContext &context) const { + if (width() < st::msgPadding.left() + st::msgPadding.right() + 1) return; + auto paintw = width(); + + const auto stm = context.messageStyle(); + const auto padding = st::msgPadding; + auto tshift = st::historyPollQuestionTop; + if (!isBubbleTop()) { + tshift -= st::msgFileTopMinus; + } + paintw -= padding.left() + padding.right(); + + p.setPen(stm->historyTextFg); + _title.drawLeft(p, padding.left(), tshift, paintw, width(), style::al_left, 0, -1, context.selection); + tshift += _title.countHeight(paintw) + st::historyPollSubtitleSkip; + + p.setPen(stm->msgDateFg); + _subtitle.drawLeftElided(p, padding.left(), tshift, paintw, width()); + tshift += st::msgDateFont->height + st::historyPollAnswersSkip; + + auto heavy = false; + auto created = false; + auto &&tasks = ranges::views::zip( + _tasks, + ranges::views::ints(0, int(_tasks.size()))); + for (const auto &[task, index] : tasks) { + const auto was = !task.userpic.null(); + const auto height = paintTask( + p, + task, + padding.left(), + tshift, + paintw, + width(), + context); + if (was) { + heavy = true; + } else if (!task.userpic.null()) { + created = true; + } + tshift += height; + } + if (!heavy && created) { + history()->owner().registerHeavyViewPart(_parent); + } + paintBottom(p, padding.left(), tshift, paintw, context); +} + +void TodoList::paintBottom( + Painter &p, + int left, + int top, + int paintw, + const PaintContext &context) const { + const auto stringtop = top + + st::msgPadding.bottom() + + st::historyPollBottomButtonTop; + const auto stm = context.messageStyle(); + + p.setPen(stm->msgDateFg); + _completionStatusLabel.draw(p, left, stringtop, paintw, style::al_top); +} + +void TodoList::radialAnimationCallback() const { + if (!anim::Disabled()) { + repaint(); + } +} + +int TodoList::paintTask( + Painter &p, + const Task &task, + int left, + int top, + int width, + int outerWidth, + const PaintContext &context) const { + const auto height = countTaskHeight(task, width); + const auto stm = context.messageStyle(); + const auto aleft = left + st::historyPollAnswerPadding.left(); + const auto awidth = width + - st::historyPollAnswerPadding.left() + - st::historyPollAnswerPadding.right(); + + if (task.ripple) { + p.setOpacity(st::historyPollRippleOpacity); + task.ripple->paint( + p, + left - st::msgPadding.left(), + top, + outerWidth, + &stm->msgWaveformInactive->c); + if (task.ripple->empty()) { + task.ripple.reset(); + } + p.setOpacity(1.); + } + + paintRadio(p, task, left, top, context); + + top += st::historyPollAnswerPadding.top(); + p.setPen(stm->historyTextFg); + task.text.drawLeft(p, aleft, top, awidth, outerWidth); + + return height; +} + +void TodoList::paintRadio( + Painter &p, + const Task &task, + int left, + int top, + const PaintContext &context) const { + top += st::historyPollAnswerPadding.top(); + + const auto stm = context.messageStyle(); + + PainterHighQualityEnabler hq(p); + const auto &radio = st::historyPollRadio; + const auto over = ClickHandler::showAsActive(task.handler); + const auto ®ular = stm->msgDateFg; + + const auto checkmark = task.selectedAnimation.value( + task.completionDate ? 1. : 0.); + + const auto o = p.opacity(); + if (checkmark < 1.) { + p.setBrush(Qt::NoBrush); + p.setOpacity(o * (over ? st::historyPollRadioOpacityOver : st::historyPollRadioOpacity)); + } + + const auto rect = QRectF(left, top, radio.diameter, radio.diameter).marginsRemoved(QMarginsF(radio.thickness / 2., radio.thickness / 2., radio.thickness / 2., radio.thickness / 2.)); + if (checkmark > 0. && task.completedBy) { + const auto skip = st::lineWidth; + const auto userpic = QRect( + left + (radio.diameter / 2) + skip, + top + skip, + radio.diameter - 2 * skip, + radio.diameter - 2 * skip); + if (checkmark < 1.) { + p.save(); + p.setOpacity(checkmark); + p.translate(QRectF(userpic).center()); + const auto ratio = 0.4 + 0.6 * checkmark; + p.scale(ratio, ratio); + p.translate(-QRectF(userpic).center()); + } + task.completedBy->paintUserpic( + p, + task.userpic, + userpic.left(), + userpic.top(), + userpic.width()); + if (checkmark < 1.) { + p.restore(); + } + } + if (checkmark < 1.) { + auto pen = regular->p; + pen.setWidth(radio.thickness); + p.setPen(pen); + p.drawEllipse(rect); + } + + if (checkmark > 0.) { + const auto removeFull = (radio.diameter / 2 - radio.thickness); + const auto removeNow = removeFull * (1. - checkmark); + const auto color = stm->msgFileThumbLinkFg; + auto pen = color->p; + pen.setWidth(radio.thickness); + p.setPen(pen); + p.setBrush(color); + p.drawEllipse(rect.marginsRemoved({ removeNow, removeNow, removeNow, removeNow })); + const auto &icon = stm->historyPollChosen; + icon.paint(p, left + (radio.diameter - icon.width()) / 2, top + (radio.diameter - icon.height()) / 2, width()); + + const auto stm = context.messageStyle(); + auto bgpen = stm->msgBg->p; + bgpen.setWidth(st::lineWidth); + const auto outline = QRect(left, top, radio.diameter, radio.diameter); + const auto paintContent = [&](QPainter &p) { + p.setPen(bgpen); + p.setBrush(Qt::NoBrush); + PainterHighQualityEnabler hq(p); + p.drawEllipse(outline); + }; + if (usesBubblePattern(context)) { + const auto add = st::lineWidth * 3; + const auto target = outline.marginsAdded( + { add, add, add, add }); + Ui::PaintPatternBubblePart( + p, + context.viewport, + context.bubblesPattern->pixmap, + target, + paintContent, + _userpicCircleCache); + } else { + paintContent(p); + } + } + + p.setOpacity(o); +} + +TextSelection TodoList::adjustSelection( + TextSelection selection, + TextSelectType type) const { + return _title.adjustSelection(selection, type); +} + +uint16 TodoList::fullSelectionLength() const { + return _title.length(); +} + +TextForMimeData TodoList::selectedText(TextSelection selection) const { + return _title.toTextForMimeData(selection); +} + +TextState TodoList::textState(QPoint point, StateRequest request) const { + auto result = TextState(_parent); + const auto can = canComplete(); + const auto padding = st::msgPadding; + auto paintw = width(); + auto tshift = st::historyPollQuestionTop; + if (!isBubbleTop()) { + tshift -= st::msgFileTopMinus; + } + paintw -= padding.left() + padding.right(); + + const auto questionH = _title.countHeight(paintw); + if (QRect(padding.left(), tshift, paintw, questionH).contains(point)) { + result = TextState(_parent, _title.getState( + point - QPoint(padding.left(), tshift), + paintw, + request.forText())); + return result; + } + tshift += questionH + st::historyPollSubtitleSkip; + tshift += st::msgDateFont->height + st::historyPollAnswersSkip; + for (const auto &task : _tasks) { + const auto height = countTaskHeight(task, paintw); + if (point.y() >= tshift && point.y() < tshift + height) { + if (can) { + _lastLinkPoint = point; + result.link = task.handler; + } else if (task.completionDate) { + result.customTooltip = true; + using Flag = Ui::Text::StateRequest::Flag; + if (request.flags & Flag::LookupCustomTooltip) { + result.customTooltipText = langDateTimeFull( + base::unixtime::parse(task.completionDate)); + } + } + return result; + } + tshift += height; + } + return result; +} + +void TodoList::clickHandlerPressedChanged( + const ClickHandlerPtr &handler, + bool pressed) { + if (!handler) return; + + const auto i = ranges::find( + _tasks, + handler, + &Task::handler); + if (i != end(_tasks)) { + toggleRipple(*i, pressed); + } +} + +void TodoList::unloadHeavyPart() { + for (auto &task : _tasks) { + task.userpic = {}; + } +} + +bool TodoList::hasHeavyPart() const { + for (auto &task : _tasks) { + if (!task.userpic.null()) { + return true; + } + } + return false; +} + +std::vector TodoList::takeTasksInfo() { + if (_tasks.empty()) { + return {}; + } + return _tasks | ranges::views::transform([](const Task &task) { + return TodoTaskInfo{ + .id = task.id, + .completedBy = task.completedBy, + .completionDate = task.completionDate, + }; + }) | ranges::to_vector; +} + +void TodoList::toggleRipple(Task &task, bool pressed) { + if (pressed) { + const auto outerWidth = width(); + const auto innerWidth = outerWidth + - st::msgPadding.left() + - st::msgPadding.right(); + if (!task.ripple) { + auto mask = Ui::RippleAnimation::RectMask(QSize( + outerWidth, + countTaskHeight(task, innerWidth))); + task.ripple = std::make_unique( + st::defaultRippleAnimation, + std::move(mask), + [=] { repaint(); }); + } + const auto top = countTaskTop(task, innerWidth); + task.ripple->add(_lastLinkPoint - QPoint(0, top)); + } else if (task.ripple) { + task.ripple->lastStop(); + } +} + +int TodoList::bottomButtonHeight() const { + const auto skip = st::historyPollChoiceRight.height() + - st::historyPollFillingBottom + - st::historyPollFillingHeight + - (st::historyPollChoiceRight.height() - st::historyPollFillingHeight) / 2; + return st::historyPollTotalVotesSkip + - skip + + st::historyPollBottomButtonSkip + + st::msgDateFont->height + + st::msgPadding.bottom(); +} + +TodoList::~TodoList() { + history()->owner().unregisterTodoListView(_todolist, _parent); + if (hasHeavyPart()) { + unloadHeavyPart(); + _parent->checkHeavyPart(); + } +} + +} // namespace HistoryView diff --git a/Telegram/SourceFiles/history/view/media/history_view_todo_list.h b/Telegram/SourceFiles/history/view/media/history_view_todo_list.h new file mode 100644 index 0000000000..d0f25638b0 --- /dev/null +++ b/Telegram/SourceFiles/history/view/media/history_view_todo_list.h @@ -0,0 +1,131 @@ +/* +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 "history/view/media/history_view_media.h" +#include "ui/effects/animations.h" +#include "data/data_todo_list.h" +#include "base/weak_ptr.h" + +namespace Ui { +class RippleAnimation; +} // namespace Ui + +namespace HistoryView { + +class Message; + +class TodoList final : public Media { +public: + TodoList( + not_null parent, + not_null todolist, + Element *replacing); + ~TodoList(); + + void draw(Painter &p, const PaintContext &context) const override; + TextState textState(QPoint point, StateRequest request) const override; + + bool toggleSelectionByHandlerClick(const ClickHandlerPtr &p) const override { + return true; + } + bool dragItemByHandler(const ClickHandlerPtr &p) const override { + return true; + } + + bool needsBubble() const override { + return true; + } + bool customInfoLayout() const override { + return false; + } + + [[nodiscard]] TextSelection adjustSelection( + TextSelection selection, + TextSelectType type) const override; + uint16 fullSelectionLength() const override; + TextForMimeData selectedText(TextSelection selection) const override; + + void clickHandlerPressedChanged( + const ClickHandlerPtr &handler, + bool pressed) override; + + void unloadHeavyPart() override; + bool hasHeavyPart() const override; + + std::vector takeTasksInfo() override; + +private: + struct Task; + + QSize countOptimalSize() override; + QSize countCurrentSize(int newWidth) override; + + [[nodiscard]] bool canComplete() const; + + [[nodiscard]] int countTaskTop( + const Task &task, + int innerWidth) const; + [[nodiscard]] int countTaskHeight( + const Task &task, + int innerWidth) const; + [[nodiscard]] ClickHandlerPtr createTaskClickHandler( + const Task &task); + void updateTexts(); + void updateTasks(bool skipAnimations); + void startToggleAnimation(Task &task); + void updateCompletionStatus(); + void setupPreviousState(const std::vector &info); + + int paintTask( + Painter &p, + const Task &task, + int left, + int top, + int width, + int outerWidth, + const PaintContext &context) const; + void paintRadio( + Painter &p, + const Task &task, + int left, + int top, + const PaintContext &context) const; + void paintBottom( + Painter &p, + int left, + int top, + int paintw, + const PaintContext &context) const; + + void radialAnimationCallback() const; + + void toggleRipple(Task &task, bool pressed); + void toggleCompletion(int id); + + [[nodiscard]] int bottomButtonHeight() const; + + const not_null _todolist; + int _todoListVersion = 0; + int _total = 0; + int _incompleted = 0; + TodoListData::Flags _flags = TodoListData::Flags(); + + Ui::Text::String _title; + Ui::Text::String _subtitle; + + std::vector _tasks; + Ui::Text::String _completionStatusLabel; + + mutable QPoint _lastLinkPoint; + mutable QImage _userpicCircleCache; + mutable QImage _fillingIconCache; + +}; + +} // namespace HistoryView