diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index d23e37b829..e83797828e 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -273,6 +273,8 @@ PRIVATE boxes/connection_box.h boxes/create_poll_box.cpp boxes/create_poll_box.h + boxes/create_todo_list_box.cpp + boxes/create_todo_list_box.h boxes/delete_messages_box.cpp boxes/delete_messages_box.h boxes/dictionaries_manager.cpp diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 205656b57e..80b008c240 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -5860,6 +5860,20 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "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_todo_create" = "Create To-Do List"; +"lng_todo_create_title" = "New To-Do List"; +"lng_todo_create_title_placeholder" = "Title"; +"lng_todo_create_list" = "Tasks List"; +"lng_todo_create_list_add" = "Add a task..."; +"lng_todo_create_limit#one" = "You can add {count} more task."; +"lng_todo_create_limit#other" = "You can add {count} more tasks."; +"lng_todo_create_maximum" = "You have added the maximum number of tasks."; +"lng_todo_create_settings" = "Settings"; +"lng_todo_create_allow_add" = "Allow Others to Add Tasks"; +"lng_todo_create_allow_mark" = "Allow Others to Mark As Done"; +"lng_todo_create_button" = "Create"; +"lng_todo_choose_title" = "Please enter a title."; +"lng_todo_choose_tasks" = "Please enter at least one task."; "lng_outdated_title" = "PLEASE UPDATE YOUR OPERATING SYSTEM."; "lng_outdated_title_bits" = "PLEASE SWITCH TO A 64-BIT OPERATING SYSTEM."; diff --git a/Telegram/SourceFiles/api/api_todo_lists.cpp b/Telegram/SourceFiles/api/api_todo_lists.cpp index dc5a7841cd..e72e8294ad 100644 --- a/Telegram/SourceFiles/api/api_todo_lists.cpp +++ b/Telegram/SourceFiles/api/api_todo_lists.cpp @@ -10,15 +10,15 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL //#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 "base/random.h" +#include "data/business/data_shortcut_messages.h" // ShortcutIdToMTP +#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 "history/history_item_helpers.h" // ShouldSendSilent #include "main/main_session.h" namespace Api { @@ -37,98 +37,98 @@ TodoLists::TodoLists(not_null api) , _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::create( + const TodoListData &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(), + TodoListDataToInputMedia(&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]; diff --git a/Telegram/SourceFiles/api/api_todo_lists.h b/Telegram/SourceFiles/api/api_todo_lists.h index 49891ea0bf..abb8c72d2a 100644 --- a/Telegram/SourceFiles/api/api_todo_lists.h +++ b/Telegram/SourceFiles/api/api_todo_lists.h @@ -26,11 +26,11 @@ class TodoLists final { public: explicit TodoLists(not_null api); - //void create( - // const PollData &data, - // SendAction action, - // Fn done, - // Fn fail); + void create( + const TodoListData &data, + SendAction action, + Fn done, + Fn fail); void toggleCompletion(FullMsgId itemId, int id, bool completed); private: diff --git a/Telegram/SourceFiles/boxes/create_poll_box.cpp b/Telegram/SourceFiles/boxes/create_poll_box.cpp index 926656d8b6..290da2d72e 100644 --- a/Telegram/SourceFiles/boxes/create_poll_box.cpp +++ b/Telegram/SourceFiles/boxes/create_poll_box.cpp @@ -114,7 +114,7 @@ private: void setPlaceholder() const; void removePlaceholder() const; - not_null field() const; + [[nodiscard]] not_null field() const; [[nodiscard]] PollAnswer toPollAnswer(int index) const; diff --git a/Telegram/SourceFiles/boxes/create_todo_list_box.cpp b/Telegram/SourceFiles/boxes/create_todo_list_box.cpp new file mode 100644 index 0000000000..d251893d95 --- /dev/null +++ b/Telegram/SourceFiles/boxes/create_todo_list_box.cpp @@ -0,0 +1,995 @@ +/* +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 "boxes/create_todo_list_box.h" + +#include "base/call_delayed.h" +#include "base/event_filter.h" +#include "base/random.h" +#include "base/unique_qptr.h" +#include "chat_helpers/emoji_suggestions_widget.h" +#include "chat_helpers/message_field.h" +#include "chat_helpers/tabbed_panel.h" +#include "chat_helpers/tabbed_selector.h" +#include "core/application.h" +#include "core/core_settings.h" +#include "data/data_session.h" +#include "data/data_todo_list.h" +#include "data/data_user.h" +#include "data/stickers/data_custom_emoji.h" +#include "history/view/history_view_schedule_box.h" +#include "lang/lang_keys.h" +#include "main/main_app_config.h" +#include "main/main_session.h" +#include "menu/menu_send.h" +#include "ui/controls/emoji_button.h" +#include "ui/controls/emoji_button_factory.h" +#include "ui/rect.h" +#include "ui/text/text_utilities.h" +#include "ui/toast/toast.h" +#include "ui/vertical_list.h" +#include "ui/widgets/fields/input_field.h" +#include "ui/widgets/buttons.h" +#include "ui/widgets/checkbox.h" +#include "ui/widgets/labels.h" +#include "ui/widgets/shadow.h" +#include "ui/wrap/fade_wrap.h" +#include "ui/wrap/slide_wrap.h" +#include "ui/wrap/vertical_layout.h" +#include "ui/ui_utility.h" +#include "window/window_session_controller.h" +#include "styles/style_boxes.h" +#include "styles/style_chat_helpers.h" // defaultComposeFiles. +#include "styles/style_layers.h" +#include "styles/style_settings.h" + +namespace { + +constexpr auto kMaxOptionsCount = TodoListData::kMaxOptions; +constexpr auto kWarnTitleLimit = 12; +constexpr auto kWarnTaskLimit = 24; +constexpr auto kErrorLimit = 99; + +class Tasks { +public: + Tasks( + not_null box, + not_null container, + not_null controller, + ChatHelpers::TabbedPanel *emojiPanel); + + [[nodiscard]] bool hasTasks() const; + [[nodiscard]] bool isValid() const; + [[nodiscard]] std::vector toTodoListItems() const; + void focusFirst(); + + [[nodiscard]] rpl::producer usedCount() const; + [[nodiscard]] rpl::producer> scrollToWidget() const; + [[nodiscard]] rpl::producer<> backspaceInFront() const; + [[nodiscard]] rpl::producer<> tabbed() const; + +private: + class Task { + public: + Task( + not_null outer, + not_null container, + not_null session, + int position); + + Task(const Task &other) = delete; + Task &operator=(const Task &other) = delete; + + void toggleRemoveAlways(bool toggled); + + void show(anim::type animated); + void destroy(FnMut done); + + [[nodiscard]] bool hasShadow() const; + void createShadow(); + void destroyShadow(); + + [[nodiscard]] bool isEmpty() const; + [[nodiscard]] bool isGood() const; + [[nodiscard]] bool isTooLong() const; + [[nodiscard]] bool hasFocus() const; + void setFocus() const; + void clearValue(); + + void setPlaceholder() const; + void removePlaceholder() const; + + [[nodiscard]] not_null field() const; + + [[nodiscard]] TodoListItem toTodoListItem(int index) const; + + [[nodiscard]] rpl::producer removeClicks() const; + + private: + void createRemove(); + void createWarning(); + void updateFieldGeometry(); + + base::unique_qptr> _wrap; + not_null _content; + Ui::InputField *_field = nullptr; + base::unique_qptr _shadow; + base::unique_qptr _remove; + rpl::variable *_removeAlways = nullptr; + int _limit = 0; + + }; + + [[nodiscard]] bool full() const; + [[nodiscard]] bool correctShadows() const; + void fixShadows(); + void removeEmptyTail(); + void addEmptyTask(); + void checkLastTask(); + void validateState(); + void fixAfterErase(); + void destroy(std::unique_ptr task); + void removeDestroyed(not_null field); + int findField(not_null field) const; + + not_null _box; + not_null _container; + const not_null _controller; + ChatHelpers::TabbedPanel * const _emojiPanel; + int _position = 0; + int _tasksLimit = 0; + std::vector> _list; + std::vector> _destroyed; + rpl::variable _usedCount = 0; + bool _hasTasks = false; + bool _isValid = false; + rpl::event_stream> _scrollToWidget; + rpl::event_stream<> _backspaceInFront; + rpl::event_stream<> _tabbed; + rpl::lifetime _emojiPanelLifetime; + +}; + +void InitField( + not_null container, + not_null field, + not_null session) { + field->setInstantReplaces(Ui::InstantReplaces::Default()); + field->setInstantReplacesEnabled( + Core::App().settings().replaceEmojiValue()); + auto options = Ui::Emoji::SuggestionsController::Options(); + options.suggestExactFirstWord = false; + Ui::Emoji::SuggestionsController::Init( + container, + field, + session, + options); +} + +not_null CreateWarningLabel( + not_null parent, + not_null field, + int valueLimit, + int warnLimit) { + const auto result = Ui::CreateChild( + parent.get(), + QString(), + st::createPollWarning); + result->setAttribute(Qt::WA_TransparentForMouseEvents); + field->changes( + ) | rpl::start_with_next([=] { + Ui::PostponeCall(crl::guard(field, [=] { + const auto length = field->getLastText().size(); + const auto value = valueLimit - length; + const auto shown = (value < warnLimit) + && (field->height() > st::createPollOptionField.heightMin); + if (value >= 0) { + result->setText(QString::number(value)); + } else { + constexpr auto kMinus = QChar(0x2212); + result->setMarkedText(Ui::Text::Colorized( + kMinus + QString::number(std::abs(value)))); + } + result->setVisible(shown); + })); + }, field->lifetime()); + return result; +} + +void FocusAtEnd(not_null field) { + field->setFocus(); + field->setCursorPosition(field->getLastText().size()); + field->ensureCursorVisible(); +} + +Tasks::Task::Task( + not_null outer, + not_null container, + not_null session, + int position) +: _wrap(container->insert( + position, + object_ptr>( + container, + object_ptr(container)))) +, _content(_wrap->entity()) +, _field( + Ui::CreateChild( + _content.get(), + session->user()->isPremium() + ? st::createPollOptionFieldPremium + : st::createPollOptionField, + Ui::InputField::Mode::NoNewlines, + tr::lng_todo_create_list_add())) +, _limit(session->appConfig().todoListItemTextLimit()) { + InitField(outer, _field, session); + _field->setMaxLength(_limit + kErrorLimit); + _field->show(); + _field->customTab(true); + + _wrap->hide(anim::type::instant); + + _content->widthValue( + ) | rpl::start_with_next([=] { + updateFieldGeometry(); + }, _field->lifetime()); + + _field->heightValue( + ) | rpl::start_with_next([=](int height) { + _content->resize(_content->width(), height); + }, _field->lifetime()); + + createShadow(); + createRemove(); + createWarning(); + updateFieldGeometry(); +} + +bool Tasks::Task::hasShadow() const { + return (_shadow != nullptr); +} + +void Tasks::Task::createShadow() { + Expects(_content != nullptr); + + if (_shadow) { + return; + } + _shadow.reset(Ui::CreateChild(field().get())); + _shadow->show(); + field()->sizeValue( + ) | rpl::start_with_next([=](QSize size) { + const auto left = st::createPollFieldPadding.left(); + _shadow->setGeometry( + left, + size.height() - st::lineWidth, + size.width() - left, + st::lineWidth); + }, _shadow->lifetime()); +} + +void Tasks::Task::destroyShadow() { + _shadow = nullptr; +} + +void Tasks::Task::createRemove() { + using namespace rpl::mappers; + + const auto field = this->field(); + auto &lifetime = field->lifetime(); + + const auto remove = Ui::CreateChild( + field.get(), + st::createPollOptionRemove); + remove->show(anim::type::instant); + + const auto toggle = lifetime.make_state>(false); + _removeAlways = lifetime.make_state>(false); + + field->changes( + ) | rpl::start_with_next([field, toggle] { + // Don't capture 'this'! Because Option is a value type. + *toggle = !field->getLastText().isEmpty(); + }, field->lifetime()); +#if 0 + rpl::combine( + toggle->value(), + _removeAlways->value(), + _1 || _2 + ) | rpl::start_with_next([=](bool shown) { + remove->toggle(shown, anim::type::normal); + }, remove->lifetime()); +#endif + + field->widthValue( + ) | rpl::start_with_next([=](int width) { + remove->moveToRight( + st::createPollOptionRemovePosition.x(), + st::createPollOptionRemovePosition.y(), + width); + }, remove->lifetime()); + + _remove.reset(remove); +} + +void Tasks::Task::createWarning() { + using namespace rpl::mappers; + + const auto field = this->field(); + const auto warning = CreateWarningLabel( + field, + field, + _limit, + kWarnTaskLimit); + rpl::combine( + field->sizeValue(), + warning->sizeValue() + ) | rpl::start_with_next([=](QSize size, QSize label) { + warning->moveToLeft( + (size.width() + - label.width() + - st::createPollWarningPosition.x()), + (size.height() + - label.height() + - st::createPollWarningPosition.y()), + size.width()); + }, warning->lifetime()); +} + +bool Tasks::Task::isEmpty() const { + return field()->getLastText().trimmed().isEmpty(); +} + +bool Tasks::Task::isGood() const { + return !field()->getLastText().trimmed().isEmpty() && !isTooLong(); +} + +bool Tasks::Task::isTooLong() const { + return (field()->getLastText().size() > _limit); +} + +bool Tasks::Task::hasFocus() const { + return field()->hasFocus(); +} + +void Tasks::Task::setFocus() const { + FocusAtEnd(field()); +} + +void Tasks::Task::clearValue() { + field()->setText(QString()); +} + +void Tasks::Task::setPlaceholder() const { + field()->setPlaceholder(tr::lng_todo_create_list_add()); +} + +void Tasks::Task::toggleRemoveAlways(bool toggled) { + *_removeAlways = toggled; +} + +void Tasks::Task::updateFieldGeometry() { + _field->resizeToWidth(_content->width()); + _field->moveToLeft(0, 0); +} + +not_null Tasks::Task::field() const { + return _field; +} + +void Tasks::Task::removePlaceholder() const { + field()->setPlaceholder(rpl::single(QString())); +} + +TodoListItem Tasks::Task::toTodoListItem(int index) const { + Expects(index >= 0 && index < kMaxOptionsCount); + + const auto text = field()->getTextWithTags(); + + auto result = TodoListItem{ + .text = TextWithEntities{ + .text = text.text, + .entities = TextUtilities::ConvertTextTagsToEntities(text.tags), + }, + .id = (index + 1) + }; + TextUtilities::Trim(result.text); + return result; +} + +rpl::producer Tasks::Task::removeClicks() const { + return _remove->clicks(); +} + +Tasks::Tasks( + not_null box, + not_null container, + not_null controller, + ChatHelpers::TabbedPanel *emojiPanel) +: _box(box) +, _container(container) +, _controller(controller) +, _emojiPanel(emojiPanel) +, _position(_container->count()) +, _tasksLimit(controller->session().appConfig().todoListItemsLimit()) { + checkLastTask(); +} + +bool Tasks::full() const { + return (_list.size() >= _tasksLimit); +} + +bool Tasks::hasTasks() const { + return _hasTasks; +} + +bool Tasks::isValid() const { + return _isValid; +} + +rpl::producer Tasks::usedCount() const { + return _usedCount.value(); +} + +rpl::producer> Tasks::scrollToWidget() const { + return _scrollToWidget.events(); +} + +rpl::producer<> Tasks::backspaceInFront() const { + return _backspaceInFront.events(); +} + +rpl::producer<> Tasks::tabbed() const { + return _tabbed.events(); +} + +void Tasks::Task::show(anim::type animated) { + _wrap->show(animated); +} + +void Tasks::Task::destroy(FnMut done) { + if (anim::Disabled() || _wrap->isHidden()) { + Ui::PostponeCall(std::move(done)); + return; + } + _wrap->hide(anim::type::normal); + base::call_delayed( + st::slideWrapDuration * 2, + _content.get(), + std::move(done)); +} + +std::vector Tasks::toTodoListItems() const { + auto result = std::vector(); + result.reserve(_list.size()); + auto counter = int(0); + const auto makeTask = [&](const std::unique_ptr &task) { + return task->toTodoListItem(counter++); + }; + ranges::copy( + _list + | ranges::views::filter(&Task::isGood) + | ranges::views::transform(makeTask), + ranges::back_inserter(result)); + return result; +} + +void Tasks::focusFirst() { + Expects(!_list.empty()); + + _list.front()->setFocus(); +} + +bool Tasks::correctShadows() const { + // Last one should be without shadow. + const auto noShadow = ranges::find( + _list, + true, + ranges::not_fn(&Task::hasShadow)); + return (noShadow == end(_list) - 1); +} + +void Tasks::fixShadows() { + if (correctShadows()) { + return; + } + for (auto &option : _list) { + option->createShadow(); + } + _list.back()->destroyShadow(); +} + +void Tasks::removeEmptyTail() { + // Only one option at the end of options list can be empty. + // Remove all other trailing empty options. + // Only last empty and previous option have non-empty placeholders. + const auto focused = ranges::find_if( + _list, + &Task::hasFocus); + const auto end = _list.end(); + const auto reversed = ranges::views::reverse(_list); + const auto emptyItem = ranges::find_if( + reversed, + ranges::not_fn(&Task::isEmpty)).base(); + const auto focusLast = (focused > emptyItem) && (focused < end); + if (emptyItem == end) { + return; + } + if (focusLast) { + (*emptyItem)->setFocus(); + } + for (auto i = emptyItem + 1; i != end; ++i) { + destroy(std::move(*i)); + } + _list.erase(emptyItem + 1, end); + fixAfterErase(); +} + +void Tasks::destroy(std::unique_ptr task) { + const auto value = task.get(); + task->destroy([=] { removeDestroyed(value); }); + _destroyed.push_back(std::move(task)); +} + +void Tasks::fixAfterErase() { + Expects(!_list.empty()); + + const auto last = _list.end() - 1; + (*last)->setPlaceholder(); + (*last)->toggleRemoveAlways(false); + if (last != begin(_list)) { + (*(last - 1))->setPlaceholder(); + (*(last - 1))->toggleRemoveAlways(false); + } + fixShadows(); +} + +void Tasks::addEmptyTask() { + if (full()) { + return; + } else if (!_list.empty() && _list.back()->isEmpty()) { + return; + } + if (_list.size() > 1) { + (*(_list.end() - 2))->removePlaceholder(); + (*(_list.end() - 2))->toggleRemoveAlways(true); + } + _list.push_back(std::make_unique( + _box, + _container, + &_controller->session(), + _position + _list.size() + _destroyed.size())); + const auto field = _list.back()->field(); + if (const auto emojiPanel = _emojiPanel) { + const auto emojiToggle = Ui::AddEmojiToggleToField( + field, + _box, + _controller, + emojiPanel, + QPoint( + -st::createPollOptionFieldPremium.textMargins.right(), + st::createPollOptionEmojiPositionSkip)); + emojiToggle->shownValue() | rpl::start_with_next([=](bool shown) { + if (!shown) { + return; + } + _emojiPanelLifetime.destroy(); + emojiPanel->selector()->emojiChosen( + ) | rpl::start_with_next([=](ChatHelpers::EmojiChosen data) { + if (field->hasFocus()) { + Ui::InsertEmojiAtCursor(field->textCursor(), data.emoji); + } + }, _emojiPanelLifetime); + emojiPanel->selector()->customEmojiChosen( + ) | rpl::start_with_next([=](ChatHelpers::FileChosen data) { + if (field->hasFocus()) { + Data::InsertCustomEmoji(field, data.document); + } + }, _emojiPanelLifetime); + }, emojiToggle->lifetime()); + } + field->submits( + ) | rpl::start_with_next([=] { + const auto index = findField(field); + if (_list[index]->isGood() && index + 1 < _list.size()) { + _list[index + 1]->setFocus(); + } + }, field->lifetime()); + field->changes( + ) | rpl::start_with_next([=] { + Ui::PostponeCall(crl::guard(field, [=] { + validateState(); + })); + }, field->lifetime()); + field->focusedChanges( + ) | rpl::filter(rpl::mappers::_1) | rpl::start_with_next([=] { + _scrollToWidget.fire_copy(field); + }, field->lifetime()); + field->tabbed( + ) | rpl::start_with_next([=] { + const auto index = findField(field); + if (index + 1 < _list.size()) { + _list[index + 1]->setFocus(); + } else { + _tabbed.fire({}); + } + }, field->lifetime()); + base::install_event_filter(field, [=](not_null event) { + if (event->type() != QEvent::KeyPress + || !field->getLastText().isEmpty()) { + return base::EventFilterResult::Continue; + } + const auto key = static_cast(event.get())->key(); + if (key != Qt::Key_Backspace) { + return base::EventFilterResult::Continue; + } + + const auto index = findField(field); + if (index > 0) { + _list[index - 1]->setFocus(); + } else { + _backspaceInFront.fire({}); + } + return base::EventFilterResult::Cancel; + }); + + _list.back()->removeClicks( + ) | rpl::start_with_next([=] { + Ui::PostponeCall(crl::guard(field, [=] { + Expects(!_list.empty()); + + const auto item = begin(_list) + findField(field); + if (item == _list.end() - 1) { + (*item)->clearValue(); + return; + } + if ((*item)->hasFocus()) { + (*(item + 1))->setFocus(); + } + destroy(std::move(*item)); + _list.erase(item); + fixAfterErase(); + validateState(); + })); + }, field->lifetime()); + + _list.back()->show((_list.size() == 1) + ? anim::type::instant + : anim::type::normal); + fixShadows(); +} + +void Tasks::removeDestroyed(not_null task) { + const auto i = ranges::find( + _destroyed, + task.get(), + &std::unique_ptr::get); + Assert(i != end(_destroyed)); + _destroyed.erase(i); +} + +void Tasks::validateState() { + checkLastTask(); + _hasTasks = (ranges::count_if(_list, &Task::isGood) > 0); + _isValid = _hasTasks && ranges::none_of(_list, &Task::isTooLong); + + const auto lastEmpty = !_list.empty() && _list.back()->isEmpty(); + _usedCount = _list.size() - (lastEmpty ? 1 : 0); +} + +int Tasks::findField(not_null field) const { + const auto result = ranges::find( + _list, + field, + &Task::field) - begin(_list); + + Ensures(result >= 0 && result < _list.size()); + return result; +} + +void Tasks::checkLastTask() { + removeEmptyTail(); + addEmptyTask(); +} + +} // namespace + +CreateTodoListBox::CreateTodoListBox( + QWidget*, + not_null controller, + rpl::producer starsRequired, + Api::SendType sendType, + SendMenu::Details sendMenuDetails) +: _controller(controller) +, _sendType(sendType) +, _sendMenuDetails([result = sendMenuDetails] { return result; }) +, _starsRequired(std::move(starsRequired)) +, _titleLimit(controller->session().appConfig().todoListTitleLimit()) { +} + +auto CreateTodoListBox::submitRequests() const -> rpl::producer { + return _submitRequests.events(); +} + +void CreateTodoListBox::setInnerFocus() { + _setInnerFocus(); +} + +void CreateTodoListBox::submitFailed(const QString &error) { + showToast(error); +} + +not_null CreateTodoListBox::setupTitle( + not_null container) { + using namespace Settings; + + const auto session = &_controller->session(); + const auto isPremium = session->user()->isPremium(); + + const auto title = container->add( + object_ptr( + container, + st::createPollField, + Ui::InputField::Mode::MultiLine, + tr::lng_todo_create_title_placeholder()), + st::createPollFieldPadding + + (isPremium + ? QMargins(0, 0, st::defaultComposeFiles.emoji.inner.width, 0) + : QMargins())); + InitField(getDelegate()->outerContainer(), title, session); + title->setMaxLength(_titleLimit + kErrorLimit); + title->setSubmitSettings(Ui::InputField::SubmitSettings::Both); + title->customTab(true); + + if (isPremium) { + using Selector = ChatHelpers::TabbedSelector; + const auto outer = getDelegate()->outerContainer(); + _emojiPanel = base::make_unique_q( + outer, + _controller, + object_ptr( + nullptr, + _controller->uiShow(), + Window::GifPauseReason::Layer, + Selector::Mode::EmojiOnly)); + const auto emojiPanel = _emojiPanel.get(); + emojiPanel->setDesiredHeightValues( + 1., + st::emojiPanMinHeight / 2, + st::emojiPanMinHeight); + emojiPanel->hide(); + emojiPanel->selector()->setCurrentPeer(session->user()); + + const auto emojiToggle = Ui::AddEmojiToggleToField( + title, + this, + _controller, + emojiPanel, + st::createPollOptionFieldPremiumEmojiPosition); + emojiPanel->selector()->emojiChosen( + ) | rpl::start_with_next([=](ChatHelpers::EmojiChosen data) { + if (title->hasFocus()) { + Ui::InsertEmojiAtCursor(title->textCursor(), data.emoji); + } + }, emojiToggle->lifetime()); + emojiPanel->selector()->customEmojiChosen( + ) | rpl::start_with_next([=](ChatHelpers::FileChosen data) { + if (title->hasFocus()) { + Data::InsertCustomEmoji(title, data.document); + } + }, emojiToggle->lifetime()); + } + + const auto warning = CreateWarningLabel( + container, + title, + _titleLimit, + kWarnTitleLimit); + rpl::combine( + title->geometryValue(), + warning->sizeValue() + ) | rpl::start_with_next([=](QRect geometry, QSize label) { + warning->moveToLeft( + (container->width() + - label.width() + - st::createPollWarningPosition.x()), + (geometry.y() + - st::createPollFieldPadding.top() + - st::defaultSubsectionTitlePadding.bottom() + - st::defaultSubsectionTitle.style.font->height + + st::defaultSubsectionTitle.style.font->ascent + - st::createPollWarning.style.font->ascent), + geometry.width()); + }, warning->lifetime()); + + return title; +} + +object_ptr CreateTodoListBox::setupContent() { + using namespace Settings; + + const auto id = FullMsgId{ + PeerId(), + _controller->session().data().nextNonHistoryEntryId(), + }; + const auto error = lifetime().make_state(Error::Title); + + auto result = object_ptr(this); + const auto container = result.data(); + + const auto title = setupTitle(container); + Ui::AddDivider(container); + Ui::AddSkip(container); + container->add( + object_ptr( + container, + tr::lng_todo_create_list(), + st::defaultSubsectionTitle), + st::createPollFieldTitlePadding); + const auto tasks = lifetime().make_state( + this, + container, + _controller, + _emojiPanel ? _emojiPanel.get() : nullptr); + auto limit = tasks->usedCount() | rpl::after_next([=](int count) { + setCloseByEscape(!count); + setCloseByOutsideClick(!count); + }) | rpl::map([=](int count) { + const auto appConfig = &_controller->session().appConfig(); + const auto max = appConfig->todoListItemsLimit(); + return (count < max) + ? tr::lng_todo_create_limit(tr::now, lt_count, max - count) + : tr::lng_todo_create_maximum(tr::now); + }) | rpl::after_next([=] { + container->resizeToWidth(container->widthNoMargins()); + }); + container->add( + object_ptr( + container, + object_ptr( + container, + std::move(limit), + st::boxDividerLabel), + st::createPollLimitPadding)); + + title->tabbed( + ) | rpl::start_with_next([=] { + tasks->focusFirst(); + }, title->lifetime()); + + Ui::AddSkip(container); + Ui::AddSubsectionTitle(container, tr::lng_todo_create_settings()); + + const auto allowAdd = container->add( + object_ptr( + container, + tr::lng_todo_create_allow_add(tr::now), + true, + st::defaultCheckbox), + st::createPollCheckboxMargin); + const auto allowMark = container->add( + object_ptr( + container, + tr::lng_todo_create_allow_mark(tr::now), + true, + st::defaultCheckbox), + st::createPollCheckboxMargin); + + tasks->tabbed( + ) | rpl::start_with_next([=] { + title->setFocus(); + }, title->lifetime()); + + const auto isValidTitle = [=] { + const auto text = title->getLastText().trimmed(); + return !text.isEmpty() && (text.size() <= _titleLimit); + }; + title->submits( + ) | rpl::start_with_next([=] { + if (isValidTitle()) { + tasks->focusFirst(); + } + }, title->lifetime()); + + _setInnerFocus = [=] { + title->setFocusFast(); + }; + + const auto collectResult = [=] { + const auto textWithTags = title->getTextWithTags(); + using Flag = TodoListData::Flag; + auto result = TodoListData(&_controller->session().data(), id); + result.title.text = textWithTags.text; + result.title.entities = TextUtilities::ConvertTextTagsToEntities( + textWithTags.tags); + TextUtilities::Trim(result.title); + result.items = tasks->toTodoListItems(); + const auto allowAddTasks = allowAdd->checked(); + const auto allowMarkTasks = allowMark->checked(); + result.setFlags(Flag(0) + | (allowAddTasks ? Flag::OthersCanAppend : Flag(0)) + | (allowMarkTasks ? Flag::OthersCanComplete : Flag(0))); + return result; + }; + const auto collectError = [=] { + if (isValidTitle()) { + *error &= ~Error::Title; + } else { + *error |= Error::Title; + } + if (!tasks->hasTasks()) { + *error |= Error::Tasks; + } else if (!tasks->isValid()) { + *error |= Error::Other; + } else { + *error &= ~(Error::Tasks | Error::Other); + } + }; + const auto showError = [show = uiShow()]( + tr::phrase<> text) { + show->showToast(text(tr::now)); + }; + + const auto send = [=](Api::SendOptions sendOptions) { + collectError(); + if (*error & Error::Title) { + showError(tr::lng_todo_choose_title); + title->setFocus(); + } else if (*error & Error::Tasks) { + showError(tr::lng_todo_choose_tasks); + tasks->focusFirst(); + } else if (!*error) { + _submitRequests.fire({ collectResult(), sendOptions }); + } + }; + const auto sendAction = SendMenu::DefaultCallback( + _controller->uiShow(), + crl::guard(this, send)); + + tasks->scrollToWidget( + ) | rpl::start_with_next([=](not_null widget) { + scrollToWidget(widget); + }, lifetime()); + + tasks->backspaceInFront( + ) | rpl::start_with_next([=] { + FocusAtEnd(title); + }, lifetime()); + + const auto isNormal = (_sendType == Api::SendType::Normal); + const auto schedule = [=] { + sendAction( + { .type = SendMenu::ActionType::Schedule }, + _sendMenuDetails()); + }; + const auto submit = addButton( + tr::lng_todo_create_button(), + [=] { isNormal ? send({}) : schedule(); }); + submit->setText(PaidSendButtonText(_starsRequired.value(), isNormal + ? tr::lng_todo_create_button() + : tr::lng_schedule_button())); + const auto sendMenuDetails = [=] { + collectError(); + return (*error) ? SendMenu::Details() : _sendMenuDetails(); + }; + SendMenu::SetupMenuAndShortcuts( + submit.data(), + _controller->uiShow(), + sendMenuDetails, + sendAction); + addButton(tr::lng_cancel(), [=] { closeBox(); }); + + return result; +} + +void CreateTodoListBox::prepare() { + setTitle(tr::lng_todo_create_title()); + + const auto inner = setInnerWidget(setupContent()); + + setDimensionsToContent(st::boxWideWidth, inner); +} diff --git a/Telegram/SourceFiles/boxes/create_todo_list_box.h b/Telegram/SourceFiles/boxes/create_todo_list_box.h new file mode 100644 index 0000000000..3b89ca5e35 --- /dev/null +++ b/Telegram/SourceFiles/boxes/create_todo_list_box.h @@ -0,0 +1,78 @@ +/* +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 "ui/layers/box_content.h" +#include "api/api_common.h" +#include "data/data_todo_list.h" +#include "base/flags.h" + +struct TodoListData; + +namespace ChatHelpers { +class TabbedPanel; +} // namespace ChatHelpers + +namespace Ui { +class VerticalLayout; +} // namespace Ui + +namespace Window { +class SessionController; +} // namespace Window + +namespace SendMenu { +struct Details; +} // namespace SendMenu + +class CreateTodoListBox : public Ui::BoxContent { +public: + struct Result { + TodoListData todolist; + Api::SendOptions options; + }; + + CreateTodoListBox( + QWidget*, + not_null controller, + rpl::producer starsRequired, + Api::SendType sendType, + SendMenu::Details sendMenuDetails); + + [[nodiscard]] rpl::producer submitRequests() const; + void submitFailed(const QString &error); + + void setInnerFocus() override; + +protected: + void prepare() override; + +private: + enum class Error { + Title = 0x01, + Tasks = 0x02, + Other = 0x04, + }; + friend constexpr inline bool is_flag_type(Error) { return true; } + using Errors = base::flags; + + [[nodiscard]] object_ptr setupContent(); + [[nodiscard]] not_null setupTitle( + not_null container); + + const not_null _controller; + const Api::SendType _sendType = Api::SendType(); + const Fn _sendMenuDetails; + rpl::variable _starsRequired; + base::unique_qptr _emojiPanel; + Fn _setInnerFocus; + Fn()> _dataIsValidValue; + rpl::event_stream _submitRequests; + int _titleLimit = 0; + +}; diff --git a/Telegram/SourceFiles/data/data_peer.cpp b/Telegram/SourceFiles/data/data_peer.cpp index 46ec093b1e..2927add840 100644 --- a/Telegram/SourceFiles/data/data_peer.cpp +++ b/Telegram/SourceFiles/data/data_peer.cpp @@ -684,6 +684,10 @@ bool PeerData::canCreatePolls() const { return Data::CanSend(this, ChatRestriction::SendPolls); } +bool PeerData::canCreateTodoLists() const { + return Data::CanSend(this, ChatRestriction::SendPolls) || isUser(); +} + bool PeerData::canCreateTopics() const { if (const auto channel = asChannel()) { return channel->isForum() diff --git a/Telegram/SourceFiles/data/data_peer.h b/Telegram/SourceFiles/data/data_peer.h index 207b7361e2..66f9d91ef9 100644 --- a/Telegram/SourceFiles/data/data_peer.h +++ b/Telegram/SourceFiles/data/data_peer.h @@ -429,6 +429,7 @@ public: [[nodiscard]] bool canPinMessages() const; [[nodiscard]] bool canEditMessagesIndefinitely() const; [[nodiscard]] bool canCreatePolls() const; + [[nodiscard]] bool canCreateTodoLists() const; [[nodiscard]] bool canCreateTopics() const; [[nodiscard]] bool canManageTopics() const; [[nodiscard]] bool canManageGifts() const; diff --git a/Telegram/SourceFiles/history/view/history_view_top_bar_widget.cpp b/Telegram/SourceFiles/history/view/history_view_top_bar_widget.cpp index a04c003cec..2ecbe5ce3a 100644 --- a/Telegram/SourceFiles/history/view/history_view_top_bar_widget.cpp +++ b/Telegram/SourceFiles/history/view/history_view_top_bar_widget.cpp @@ -1144,6 +1144,9 @@ void TopBarWidget::updateControlsVisibility() { const auto hasPollsMenu = (_activeChat.key.peer() && _activeChat.key.peer()->canCreatePolls()) || (topic && Data::CanSend(topic, ChatRestriction::SendPolls)); + const auto hasTodoListsMenu = (_activeChat.key.peer() + && _activeChat.key.peer()->canCreateTodoLists()) + || (topic && Data::CanSend(topic, ChatRestriction::SendPolls)); const auto hasTopicMenu = [&] { if (!topic || section != Section::Replies) { return false; @@ -1163,9 +1166,9 @@ void TopBarWidget::updateControlsVisibility() { && (section == Section::History ? true : (section == Section::Scheduled) - ? hasPollsMenu + ? (hasPollsMenu || hasTodoListsMenu) : (section == Section::Replies) - ? (hasPollsMenu || hasTopicMenu) + ? (hasPollsMenu || hasTodoListsMenu || hasTopicMenu) : (section == Section::ChatsList) ? (_activeChat.key.peer() && _activeChat.key.peer()->isForum()) : false); diff --git a/Telegram/SourceFiles/history/view/media/history_view_todo_list.cpp b/Telegram/SourceFiles/history/view/media/history_view_todo_list.cpp index af6ad3c56f..ae40429964 100644 --- a/Telegram/SourceFiles/history/view/media/history_view_todo_list.cpp +++ b/Telegram/SourceFiles/history/view/media/history_view_todo_list.cpp @@ -121,7 +121,7 @@ void TodoList::setupPreviousState(const std::vector &info) { } QSize TodoList::countOptimalSize() { - updateTexts(); + updateTexts(); const auto paddings = st::msgPadding.left() + st::msgPadding.right(); diff --git a/Telegram/SourceFiles/inline_bots/bot_attach_web_view.cpp b/Telegram/SourceFiles/inline_bots/bot_attach_web_view.cpp index aa99af3ee8..88d3a75fc9 100644 --- a/Telegram/SourceFiles/inline_bots/bot_attach_web_view.cpp +++ b/Telegram/SourceFiles/inline_bots/bot_attach_web_view.cpp @@ -2626,6 +2626,26 @@ std::unique_ptr MakeAttachBotsMenu( { sendMenuType }); }, &st::menuIconCreatePoll); } + if (peer->canCreateTodoLists()) { + ++minimal; + raw->addAction(tr::lng_todo_create(tr::now), [=] { + const auto action = actionFactory(); + const auto source = action.options.scheduled + ? Api::SendType::Scheduled + : Api::SendType::Normal; + const auto sendMenuType = (action.replyTo.topicRootId + || action.history->peer->starsPerMessageChecked()) + ? SendMenu::Type::SilentOnly + : SendMenu::Type::Scheduled; + const auto replyTo = action.replyTo; + Window::PeerMenuCreateTodoList( + controller, + peer, + replyTo, + source, + { sendMenuType }); + }, &st::menuIconCreateTodoList); + } const auto session = &controller->session(); const auto locationType = ChatRestriction::SendOther; const auto config = ResolveMapsConfig(session); diff --git a/Telegram/SourceFiles/main/main_app_config.cpp b/Telegram/SourceFiles/main/main_app_config.cpp index 3e53a82bfb..8875884f24 100644 --- a/Telegram/SourceFiles/main/main_app_config.cpp +++ b/Telegram/SourceFiles/main/main_app_config.cpp @@ -145,9 +145,21 @@ int AppConfig::giftResaleReceiveThousandths() const { } int AppConfig::pollOptionsLimit() const { + return get(u"poll_answers_max"_q, 12); +} + +int AppConfig::todoListItemsLimit() const { return get( - u"poll_answers_max"_q, - _account->mtp().isTestMode() ? 12 : 10); + u"todo_items_max"_q, + _account->mtp().isTestMode() ? 10 : 30); +} + +int AppConfig::todoListTitleLimit() const { + return get(u"todo_title_length_max"_q, 32); +} + +int AppConfig::todoListItemTextLimit() const { + return get(u"todo_item_length_max"_q, 64); } void AppConfig::refresh(bool force) { diff --git a/Telegram/SourceFiles/main/main_app_config.h b/Telegram/SourceFiles/main/main_app_config.h index 8c460a3963..bf0053d79b 100644 --- a/Telegram/SourceFiles/main/main_app_config.h +++ b/Telegram/SourceFiles/main/main_app_config.h @@ -84,6 +84,9 @@ public: [[nodiscard]] int giftResaleReceiveThousandths() const; [[nodiscard]] int pollOptionsLimit() const; + [[nodiscard]] int todoListItemsLimit() const; + [[nodiscard]] int todoListTitleLimit() const; + [[nodiscard]] int todoListItemTextLimit() const; void refresh(bool force = false); diff --git a/Telegram/SourceFiles/ui/menu_icons.style b/Telegram/SourceFiles/ui/menu_icons.style index c54c168f81..1a50e96203 100644 --- a/Telegram/SourceFiles/ui/menu_icons.style +++ b/Telegram/SourceFiles/ui/menu_icons.style @@ -57,6 +57,7 @@ menuIconStats: icon {{ "menu/stats", menuIconColor }}; menuIconBoosts: icon {{ "menu/boosts", menuIconColor }}; menuIconEarn: icon {{ "menu/earn", menuIconColor }}; menuIconCreatePoll: icon {{ "menu/create_poll", menuIconColor }}; +menuIconCreateTodoList: icon {{ "menu/select", menuIconColor }}; menuIconQrCode: icon {{ "menu/qr_code", menuIconColor }}; menuIconExpand: icon {{ "menu/expand", menuIconColor }}; menuIconCollapse: icon {{ "menu/collapse", menuIconColor }}; diff --git a/Telegram/SourceFiles/window/window_peer_menu.cpp b/Telegram/SourceFiles/window/window_peer_menu.cpp index 958cb3179b..61085178e6 100644 --- a/Telegram/SourceFiles/window/window_peer_menu.cpp +++ b/Telegram/SourceFiles/window/window_peer_menu.cpp @@ -29,6 +29,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/moderate_messages_box.h" #include "boxes/choose_filter_box.h" #include "boxes/create_poll_box.h" +#include "boxes/create_todo_list_box.h" #include "boxes/pin_messages_box.h" #include "boxes/premium_limits_box.h" #include "boxes/report_messages_box.h" @@ -59,6 +60,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_blocked_peers.h" #include "api/api_chat_filters.h" #include "api/api_polls.h" +#include "api/api_todo_lists.h" #include "api/api_updates.h" #include "mtproto/mtproto_config.h" #include "history/history.h" @@ -290,6 +292,7 @@ private: void addManageTopic(); void addManageChat(); void addCreatePoll(); + void addCreateTodoList(); void addThemeEdit(); void addBlockUser(); void addViewDiscussion(); @@ -315,6 +318,8 @@ private: void addViewStatistics(); void addBoostChat(); + [[nodiscard]] bool skipCreateActions() const; + not_null _controller; Dialogs::EntryState _request; Data::Thread *_thread = nullptr; @@ -1165,7 +1170,7 @@ void Filler::addViewStatistics() { } } -void Filler::addCreatePoll() { +bool Filler::skipCreateActions() const { const auto isJoinChannel = [&] { if (_request.section != Section::Replies) { if (const auto c = _peer->asChannel(); c && !c->amIn()) { @@ -1190,10 +1195,13 @@ void Filler::addCreatePoll() { const auto isBlocked = [&] { return _peer && _peer->isUser() && _peer->asUser()->isBlocked(); }(); - if (isBlocked || isJoinChannel || isBotStart) { + return isBlocked || isJoinChannel || isBotStart; +} + +void Filler::addCreatePoll() { + if (skipCreateActions()) { return; } - const auto can = _topic ? Data::CanSend(_topic, ChatRestriction::SendPolls) : _peer->canCreatePolls(); @@ -1229,6 +1237,42 @@ void Filler::addCreatePoll() { &st::menuIconCreatePoll); } +void Filler::addCreateTodoList() { + if (skipCreateActions()) { + return; + } + const auto can = _topic + ? Data::CanSend(_topic, ChatRestriction::SendPolls) + : _peer->canCreateTodoLists(); + if (!can) { + return; + } + const auto peer = _peer; + const auto controller = _controller; + const auto source = (_request.section == Section::Scheduled) + ? Api::SendType::Scheduled + : Api::SendType::Normal; + const auto sendMenuType = (_request.section == Section::Scheduled) + ? SendMenu::Type::Disabled + : (_request.section == Section::Replies + || _peer->starsPerMessageChecked()) + ? SendMenu::Type::SilentOnly + : SendMenu::Type::Scheduled; + const auto replyTo = _request.currentReplyTo; + auto callback = [=] { + PeerMenuCreateTodoList( + controller, + peer, + replyTo, + source, + { sendMenuType }); + }; + _addAction( + tr::lng_todo_create(tr::now), + std::move(callback), + &st::menuIconCreateTodoList); +} + void Filler::addThemeEdit() { if (_peer->isVerifyCodes() || _peer->isRepliesChat()) { return; @@ -1481,6 +1525,7 @@ void Filler::fillHistoryActions() { addSupportInfo(); addBoostChat(); addCreatePoll(); + addCreateTodoList(); addThemeEdit(); addViewDiscussion(); addDirectMessages(); @@ -1525,12 +1570,14 @@ void Filler::fillRepliesActions() { } addBoostChat(); addCreatePoll(); + addCreateTodoList(); addToggleTopicClosed(); addDeleteTopic(); } void Filler::fillScheduledActions() { addCreatePoll(); + addCreateTodoList(); } void Filler::fillArchiveActions() { @@ -1873,6 +1920,74 @@ void PeerMenuCreatePoll( controller->show(std::move(box), Ui::LayerOption::CloseOther); } +void PeerMenuCreateTodoList( + not_null controller, + not_null peer, + FullReplyTo replyTo, + Api::SendType sendType, + SendMenu::Details sendMenuDetails) { + auto starsRequired = peer->session().changes().peerFlagsValue( + peer, + Data::PeerUpdate::Flag::FullInfo + | Data::PeerUpdate::Flag::StarsPerMessage + ) | rpl::map([=] { + return peer->starsPerMessageChecked(); + }); + auto box = Box( + controller, + std::move(starsRequired), + sendType, + sendMenuDetails); + struct State { + Fn create; + SendPaymentHelper sendPayment; + bool lock = false; + }; + const auto weak = QPointer(box); + const auto state = box->lifetime().make_state(); + state->create = [=](const CreateTodoListBox::Result &result) { + const auto withPaymentApproved = crl::guard(weak, [=](int stars) { + if (const auto onstack = state->create) { + auto copy = result; + copy.options.starsApproved = stars; + onstack(copy); + } + }); + const auto checked = state->sendPayment.check( + controller, + peer, + 1, + result.options.starsApproved, + withPaymentApproved); + if (!checked || std::exchange(state->lock, true)) { + return; + } + auto action = Api::SendAction( + peer->owner().history(peer), + result.options); + action.replyTo = replyTo; + const auto local = action.history->localDraft( + replyTo.topicRootId, + replyTo.monoforumPeerId); + if (local) { + action.clearDraft = local->textWithTags.text.isEmpty(); + } else { + action.clearDraft = false; + } + const auto api = &peer->session().api(); + api->todoLists().create(result.todolist, action, crl::guard(weak, [=] { + state->create = nullptr; + weak->closeBox(); + }), crl::guard(weak, [=] { + state->lock = false; + weak->submitFailed(tr::lng_attach_failed(tr::now)); + })); + }; + box->submitRequests( + ) | rpl::start_with_next(state->create, box->lifetime()); + controller->show(std::move(box), Ui::LayerOption::CloseOther); +} + void PeerMenuBlockUserBox( not_null box, not_null window, diff --git a/Telegram/SourceFiles/window/window_peer_menu.h b/Telegram/SourceFiles/window/window_peer_menu.h index 15435b34ba..59beef33b7 100644 --- a/Telegram/SourceFiles/window/window_peer_menu.h +++ b/Telegram/SourceFiles/window/window_peer_menu.h @@ -111,6 +111,12 @@ void PeerMenuCreatePoll( PollData::Flags disabled = PollData::Flags(), Api::SendType sendType = Api::SendType::Normal, SendMenu::Details sendMenuDetails = SendMenu::Details()); +void PeerMenuCreateTodoList( + not_null controller, + not_null peer, + FullReplyTo replyTo = FullReplyTo(), + Api::SendType sendType = Api::SendType::Normal, + SendMenu::Details sendMenuDetails = SendMenu::Details()); void PeerMenuDeleteTopicWithConfirmation( not_null navigation, not_null topic);