From 248fe1b53fdf3c571dfd82818ed0ad4d271cb145 Mon Sep 17 00:00:00 2001 From: John Preston Date: Thu, 12 Jun 2025 12:22:06 +0400 Subject: [PATCH] Add tasks to todo lists. --- Telegram/SourceFiles/api/api_todo_lists.cpp | 35 ++- Telegram/SourceFiles/api/api_todo_lists.h | 8 +- .../boxes/create_todo_list_box.cpp | 235 +++++++++++++++--- .../SourceFiles/boxes/create_todo_list_box.h | 29 +++ Telegram/SourceFiles/data/data_todo_list.cpp | 21 +- Telegram/SourceFiles/data/data_todo_list.h | 3 + .../history/history_inner_widget.cpp | 20 ++ .../history/view/history_view_element.cpp | 2 +- .../SourceFiles/window/window_peer_menu.cpp | 27 +- .../SourceFiles/window/window_peer_menu.h | 3 + 10 files changed, 325 insertions(+), 58 deletions(-) diff --git a/Telegram/SourceFiles/api/api_todo_lists.cpp b/Telegram/SourceFiles/api/api_todo_lists.cpp index e72e8294ad..123b9d10f6 100644 --- a/Telegram/SourceFiles/api/api_todo_lists.cpp +++ b/Telegram/SourceFiles/api/api_todo_lists.cpp @@ -42,7 +42,7 @@ void TodoLists::create( const TodoListData &data, SendAction action, Fn done, - Fn fail) { + Fn fail) { _session->api().sendAction(action); const auto history = action.history; @@ -118,7 +118,9 @@ void TodoLists::create( (action.options.scheduled ? Data::HistoryUpdate::Flag::ScheduledSent : Data::HistoryUpdate::Flag::MessageSent)); - done(); + if (const auto onstack = done) { + onstack(); + } }, [=](const MTP::Error &error, const MTP::Response &response) { if (clearCloudDraft) { history->finishSavingCloudDraft( @@ -126,10 +128,37 @@ void TodoLists::create( monoforumPeerId, UnixtimeFromMsgId(response.outerMsgId)); } - fail(); + if (const auto onstack = fail) { + onstack(error.type()); + } }); } +void TodoLists::add( + not_null item, + const std::vector &items, + Fn done, + Fn fail) { + if (items.empty()) { + return; + } + const auto session = _session; + _session->api().request(MTPmessages_AppendTodoList( + item->history()->peer->input, + MTP_int(item->id.bare), + TodoListItemsToMTP(&item->history()->session(), items) + )).done([=](const MTPUpdates &result) { + session->api().applyUpdates(result); + if (const auto onstack = done) { + onstack(); + } + }).fail([=](const MTP::Error &error) { + if (const auto onstack = fail) { + onstack(error.type()); + } + }).send(); +} + void TodoLists::toggleCompletion(FullMsgId itemId, int id, bool completed) { auto &entry = _toggles[itemId]; if (completed) { diff --git a/Telegram/SourceFiles/api/api_todo_lists.h b/Telegram/SourceFiles/api/api_todo_lists.h index abb8c72d2a..7331a28407 100644 --- a/Telegram/SourceFiles/api/api_todo_lists.h +++ b/Telegram/SourceFiles/api/api_todo_lists.h @@ -12,6 +12,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL class ApiWrap; class HistoryItem; +struct TodoListItem; struct TodoListData; namespace Main { @@ -30,7 +31,12 @@ public: const TodoListData &data, SendAction action, Fn done, - Fn fail); + Fn fail); + void add( + not_null item, + const std::vector &items, + Fn done, + Fn fail); void toggleCompletion(FullMsgId itemId, int id, bool completed); private: diff --git a/Telegram/SourceFiles/boxes/create_todo_list_box.cpp b/Telegram/SourceFiles/boxes/create_todo_list_box.cpp index d251893d95..846c3aabea 100644 --- a/Telegram/SourceFiles/boxes/create_todo_list_box.cpp +++ b/Telegram/SourceFiles/boxes/create_todo_list_box.cpp @@ -17,11 +17,14 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "chat_helpers/tabbed_selector.h" #include "core/application.h" #include "core/core_settings.h" +#include "data/data_changes.h" +#include "data/data_media_types.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 "history/history_item.h" #include "lang/lang_keys.h" #include "main/main_app_config.h" #include "main/main_session.h" @@ -60,7 +63,9 @@ public: not_null box, not_null container, not_null controller, - ChatHelpers::TabbedPanel *emojiPanel); + ChatHelpers::TabbedPanel *emojiPanel, + std::vector existing = {}, + bool existingLocked = false); [[nodiscard]] bool hasTasks() const; [[nodiscard]] bool isValid() const; @@ -79,7 +84,10 @@ private: not_null outer, not_null container, not_null session, - int position); + int id, + TextWithEntities text, + int position, + bool locked); Task(const Task &other) = delete; Task &operator=(const Task &other) = delete; @@ -93,6 +101,8 @@ private: void createShadow(); void destroyShadow(); + [[nodiscard]] int id() const; + [[nodiscard]] bool locked() const; [[nodiscard]] bool isEmpty() const; [[nodiscard]] bool isGood() const; [[nodiscard]] bool isTooLong() const; @@ -105,7 +115,7 @@ private: [[nodiscard]] not_null field() const; - [[nodiscard]] TodoListItem toTodoListItem(int index) const; + [[nodiscard]] TodoListItem toTodoListItem(int nextId) const; [[nodiscard]] rpl::producer removeClicks() const; @@ -114,6 +124,7 @@ private: void createWarning(); void updateFieldGeometry(); + int _id = 0; base::unique_qptr> _wrap; not_null _content; Ui::InputField *_field = nullptr; @@ -129,6 +140,11 @@ private: void fixShadows(); void removeEmptyTail(); void addEmptyTask(); + void addTask( + int id, + TextWithEntities text, + anim::type animated); + void initTaskField(not_null task); void checkLastTask(); void validateState(); void fixAfterErase(); @@ -139,6 +155,8 @@ private: not_null _box; not_null _container; const not_null _controller; + const int _existingCount = 0; + const bool _existingLocked = false; ChatHelpers::TabbedPanel * const _emojiPanel; int _position = 0; int _tasksLimit = 0; @@ -210,8 +228,12 @@ Tasks::Task::Task( not_null outer, not_null container, not_null session, - int position) -: _wrap(container->insert( + int id, + TextWithEntities text, + int position, + bool locked) +: _id(id) +, _wrap(container->insert( position, object_ptr>( container, @@ -228,8 +250,17 @@ Tasks::Task::Task( , _limit(session->appConfig().todoListItemTextLimit()) { InitField(outer, _field, session); _field->setMaxLength(_limit + kErrorLimit); + _field->setTextWithTags({ + text.text, + TextUtilities::ConvertEntitiesToTextTags(text.entities) + }); + _field->finishAnimating(); _field->show(); - _field->customTab(true); + if (locked) { + _field->setDisabled(true); + } else { + _field->customTab(true); + } _wrap->hide(anim::type::instant); @@ -244,8 +275,10 @@ Tasks::Task::Task( }, _field->lifetime()); createShadow(); - createRemove(); - createWarning(); + if (!locked) { + createRemove(); + createWarning(); + } updateFieldGeometry(); } @@ -345,7 +378,9 @@ bool Tasks::Task::isEmpty() const { } bool Tasks::Task::isGood() const { - return !field()->getLastText().trimmed().isEmpty() && !isTooLong(); + return !locked() + && !field()->getLastText().trimmed().isEmpty() + && !isTooLong(); } bool Tasks::Task::isTooLong() const { @@ -357,7 +392,9 @@ bool Tasks::Task::hasFocus() const { } void Tasks::Task::setFocus() const { - FocusAtEnd(field()); + if (!locked()) { + FocusAtEnd(field()); + } } void Tasks::Task::clearValue() { @@ -369,7 +406,9 @@ void Tasks::Task::setPlaceholder() const { } void Tasks::Task::toggleRemoveAlways(bool toggled) { - *_removeAlways = toggled; + if (_removeAlways) { + *_removeAlways = toggled; + } } void Tasks::Task::updateFieldGeometry() { @@ -385,37 +424,49 @@ void Tasks::Task::removePlaceholder() const { field()->setPlaceholder(rpl::single(QString())); } -TodoListItem Tasks::Task::toTodoListItem(int index) const { - Expects(index >= 0 && index < kMaxOptionsCount); +int Tasks::Task::id() const { + return _id; +} +bool Tasks::Task::locked() const { + return !_remove; +} + +TodoListItem Tasks::Task::toTodoListItem(int nextId) const { const auto text = field()->getTextWithTags(); - auto result = TodoListItem{ .text = TextWithEntities{ .text = text.text, .entities = TextUtilities::ConvertTextTagsToEntities(text.tags), }, - .id = (index + 1) + .id = _id ? _id : nextId, }; TextUtilities::Trim(result.text); return result; } rpl::producer Tasks::Task::removeClicks() const { - return _remove->clicks(); + return _remove ? _remove->clicks() : rpl::never(); } Tasks::Tasks( not_null box, not_null container, not_null controller, - ChatHelpers::TabbedPanel *emojiPanel) + ChatHelpers::TabbedPanel *emojiPanel, + std::vector existing, + bool existingLocked) : _box(box) , _container(container) , _controller(controller) +, _existingCount(existing.size()) +, _existingLocked(existingLocked) , _emojiPanel(emojiPanel) , _position(_container->count()) , _tasksLimit(controller->session().appConfig().todoListItemsLimit()) { + for (const auto &task : existing) { + addTask(task.id, task.text, anim::type::instant); + } checkLastTask(); } @@ -466,22 +517,21 @@ void Tasks::Task::destroy(FnMut 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)); + auto usedId = 0; + for (const auto &task : _list) { + if (task->isGood()) { + result.push_back(task->toTodoListItem(++usedId)); + } else if (const auto id = task->id()) { + usedId = std::max(usedId, id); + } + } return result; } void Tasks::focusFirst() { - Expects(!_list.empty()); - - _list.front()->setFocus(); + const auto locked = _existingLocked ? _existingCount : 0; + Assert(locked < _list.size()); + FocusAtEnd((_list.begin() + locked)->get()->field()); } bool Tasks::correctShadows() const { @@ -549,21 +599,45 @@ void Tasks::fixAfterErase() { } void Tasks::addEmptyTask() { - if (full()) { + if (!_list.empty() && _list.back()->isEmpty()) { return; - } else if (!_list.empty() && _list.back()->isEmpty()) { + } + const auto locked = _existingLocked ? _existingCount : 0; + addTask( + 0, // id + TextWithEntities(), + (locked < _list.size()) ? anim::type::normal : anim::type::instant); +} + +void Tasks::addTask( + int id, + TextWithEntities text, + anim::type animated) { + if (full()) { return; } if (_list.size() > 1) { (*(_list.end() - 2))->removePlaceholder(); (*(_list.end() - 2))->toggleRemoveAlways(true); } + const auto locked = id && _existingLocked; _list.push_back(std::make_unique( _box, _container, &_controller->session(), - _position + _list.size() + _destroyed.size())); - const auto field = _list.back()->field(); + id, + std::move(text), + _position + _list.size() + _destroyed.size(), + locked)); + if (!locked) { + initTaskField(_list.back().get()); + } + _list.back()->show(animated); + fixShadows(); +} + +void Tasks::initTaskField(not_null task) { + const auto field = task->field(); if (const auto emojiPanel = _emojiPanel) { const auto emojiToggle = Ui::AddEmojiToggleToField( field, @@ -637,7 +711,7 @@ void Tasks::addEmptyTask() { return base::EventFilterResult::Cancel; }); - _list.back()->removeClicks( + task->removeClicks( ) | rpl::start_with_next([=] { Ui::PostponeCall(crl::guard(field, [=] { Expects(!_list.empty()); @@ -656,11 +730,6 @@ void Tasks::addEmptyTask() { validateState(); })); }, field->lifetime()); - - _list.back()->show((_list.size() == 1) - ? anim::type::instant - : anim::type::normal); - fixShadows(); } void Tasks::removeDestroyed(not_null task) { @@ -678,7 +747,9 @@ void Tasks::validateState() { _isValid = _hasTasks && ranges::none_of(_list, &Task::isTooLong); const auto lastEmpty = !_list.empty() && _list.back()->isEmpty(); - _usedCount = _list.size() - (lastEmpty ? 1 : 0); + _usedCount = _list.size() + - (lastEmpty ? 1 : 0) + - (_existingLocked ? _existingCount : 0); } int Tasks::findField(not_null field) const { @@ -728,7 +799,7 @@ not_null CreateTodoListBox::setupTitle( using namespace Settings; const auto session = &_controller->session(); - const auto isPremium = session->user()->isPremium(); + const auto isPremium = session->premium(); const auto title = container->add( object_ptr( @@ -993,3 +1064,85 @@ void CreateTodoListBox::prepare() { setDimensionsToContent(st::boxWideWidth, inner); } + +AddTodoListTasksBox::AddTodoListTasksBox( + QWidget*, + not_null controller, + not_null item) +: _controller(controller) +, _item(item) { + _controller->session().changes().messageUpdates( + Data::MessageUpdate::Flag::Destroyed + ) | rpl::start_with_next([=](const Data::MessageUpdate &update) { + if (update.item == item) { + closeBox(); + } + }, lifetime()); +} + +void AddTodoListTasksBox::prepare() { + setTitle(tr::lng_todo_add_title()); + + const auto inner = setInnerWidget(setupContent()); + + setDimensionsToContent(st::boxWideWidth, inner); + + scrollToY(ScrollMax); +} + +object_ptr AddTodoListTasksBox::setupContent() { + auto result = object_ptr(this); + const auto container = result.data(); + + const auto tasks = lifetime().make_state( + this, + container, + _controller, + _emojiPanel ? _emojiPanel.get() : nullptr, + _item->media()->todolist()->items, + true); + 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)); + + _setInnerFocus = [=] { + tasks->focusFirst(); + }; + + tasks->scrollToWidget( + ) | rpl::start_with_next([=](not_null widget) { + scrollToWidget(widget); + }, lifetime()); + + const auto submit = addButton(tr::lng_settings_save(), [=] { + _submitRequests.fire({ tasks->toTodoListItems() }); + }); + addButton(tr::lng_cancel(), [=] { closeBox(); }); + + return result; +} + +auto AddTodoListTasksBox::submitRequests() const -> rpl::producer { + return _submitRequests.events(); +} + +void AddTodoListTasksBox::setInnerFocus() { + _setInnerFocus(); +} diff --git a/Telegram/SourceFiles/boxes/create_todo_list_box.h b/Telegram/SourceFiles/boxes/create_todo_list_box.h index 3b89ca5e35..3b9078150e 100644 --- a/Telegram/SourceFiles/boxes/create_todo_list_box.h +++ b/Telegram/SourceFiles/boxes/create_todo_list_box.h @@ -76,3 +76,32 @@ private: int _titleLimit = 0; }; + +class AddTodoListTasksBox : public Ui::BoxContent { +public: + struct Result { + std::vector items; + }; + + AddTodoListTasksBox( + QWidget*, + not_null controller, + not_null item); + + [[nodiscard]] rpl::producer submitRequests() const; + + void setInnerFocus() override; + +protected: + void prepare() override; + +private: + [[nodiscard]] object_ptr setupContent(); + + const not_null _controller; + const not_null _item; + base::unique_qptr _emojiPanel; + Fn _setInnerFocus; + rpl::event_stream _submitRequests; + +}; diff --git a/Telegram/SourceFiles/data/data_todo_list.cpp b/Telegram/SourceFiles/data/data_todo_list.cpp index 1bb1a86859..d50bcf6296 100644 --- a/Telegram/SourceFiles/data/data_todo_list.cpp +++ b/Telegram/SourceFiles/data/data_todo_list.cpp @@ -177,22 +177,23 @@ bool TodoListData::othersCanComplete() const { return (_flags & Flag::OthersCanComplete); } -MTPTodoList TodoListDataToMTP(not_null todolist) { +MTPVector TodoListItemsToMTP( + not_null session, + const std::vector &tasks) { 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))); + Api::EntitiesToMTP(session, item.text.entities))); }; auto items = QVector(); - items.reserve(todolist->items.size()); - ranges::transform( - todolist->items, - ranges::back_inserter(items), - convert); + items.reserve(tasks.size()); + ranges::transform(tasks, ranges::back_inserter(items), convert); + return MTP_vector(items); +} + +MTPTodoList TodoListDataToMTP(not_null todolist) { using Flag = MTPDtodoList::Flag; const auto flags = Flag() | (todolist->othersCanAppend() @@ -208,7 +209,7 @@ MTPTodoList TodoListDataToMTP(not_null todolist) { Api::EntitiesToMTP( &todolist->session(), todolist->title.entities)), - MTP_vector(items)); + TodoListItemsToMTP(&todolist->session(), todolist->items)); } MTPInputMedia TodoListDataToInputMedia( diff --git a/Telegram/SourceFiles/data/data_todo_list.h b/Telegram/SourceFiles/data/data_todo_list.h index 3ba84c6c78..a94d8c6760 100644 --- a/Telegram/SourceFiles/data/data_todo_list.h +++ b/Telegram/SourceFiles/data/data_todo_list.h @@ -70,6 +70,9 @@ private: }; +[[nodiscard]] MTPVector TodoListItemsToMTP( + not_null session, + const std::vector &tasks); [[nodiscard]] MTPTodoList TodoListDataToMTP( not_null todolist); [[nodiscard]] MTPInputMedia TodoListDataToInputMedia( diff --git a/Telegram/SourceFiles/history/history_inner_widget.cpp b/Telegram/SourceFiles/history/history_inner_widget.cpp index 3978655b7b..b00d999063 100644 --- a/Telegram/SourceFiles/history/history_inner_widget.cpp +++ b/Telegram/SourceFiles/history/history_inner_widget.cpp @@ -94,6 +94,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/data_file_click_handler.h" #include "data/data_histories.h" #include "data/data_changes.h" +#include "data/data_todo_list.h" #include "dialogs/ui/dialogs_video_userpic.h" #include "styles/style_chat.h" #include "styles/style_menu_icons.h" @@ -2719,6 +2720,24 @@ void HistoryInner::showContextMenu(QContextMenuEvent *e, bool showFromTouch) { } }; + const auto addTodoListAction = [&](HistoryItem *item) { + const auto media = item ? item->media() : nullptr; + const auto todolist = media ? media->todolist() : nullptr; + if (!todolist + || !item->isRegular() + || (!item->out() && !todolist->othersCanAppend())) { + return; + } + const auto itemId = item->fullId(); + _menu->addAction( + tr::lng_todo_add_title(tr::now), + crl::guard(this, [=] { + if (const auto item = session->data().message(itemId)) { + Window::PeerMenuAddTodoListTasks(_controller, item); + } + }), + &st::menuIconCreateTodoList); + }; const auto lnkPhoto = link ? reinterpret_cast( link->property(kPhotoLinkMediaProperty).toULongLong()) @@ -2889,6 +2908,7 @@ void HistoryInner::showContextMenu(QContextMenuEvent *e, bool showFromTouch) { addItemActions(item, item); } else { addReplyAction(partItemOrLeader); + addTodoListAction(partItemOrLeader); addItemActions(item, albumPartItem); if (item && !isUponSelected) { const auto media = (view ? view->media() : nullptr); diff --git a/Telegram/SourceFiles/history/view/history_view_element.cpp b/Telegram/SourceFiles/history/view/history_view_element.cpp index cd5d3bc339..08a2373df5 100644 --- a/Telegram/SourceFiles/history/view/history_view_element.cpp +++ b/Telegram/SourceFiles/history/view/history_view_element.cpp @@ -620,7 +620,7 @@ int ServicePreMessage::resizeToWidth(int newWidth, ElementChatMode mode) { st::msgMaxWidth + 2 * st::msgPhotoSkip + 2 * st::msgMargin.left()); } auto contentWidth = width; - contentWidth -= st::msgServiceMargin.left() + st::msgServiceMargin.left(); // two small margins + contentWidth -= st::msgServiceMargin.left() + st::msgServiceMargin.right(); if (contentWidth < st::msgServicePadding.left() + st::msgServicePadding.right() + 1) { contentWidth = st::msgServicePadding.left() + st::msgServicePadding.right() + 1; } diff --git a/Telegram/SourceFiles/window/window_peer_menu.cpp b/Telegram/SourceFiles/window/window_peer_menu.cpp index f869102e19..bb9604b89d 100644 --- a/Telegram/SourceFiles/window/window_peer_menu.cpp +++ b/Telegram/SourceFiles/window/window_peer_menu.cpp @@ -2017,9 +2017,9 @@ void PeerMenuCreateTodoList( api->todoLists().create(result.todolist, action, crl::guard(weak, [=] { state->create = nullptr; weak->closeBox(); - }), crl::guard(weak, [=] { + }), crl::guard(weak, [=](const QString &error) { state->lock = false; - weak->submitFailed(tr::lng_attach_failed(tr::now)); + weak->submitFailed(error); })); }; box->submitRequests( @@ -2027,6 +2027,29 @@ void PeerMenuCreateTodoList( controller->show(std::move(box), Ui::LayerOption::CloseOther); } +void PeerMenuAddTodoListTasks( + not_null controller, + not_null item) { + const auto session = &item->history()->session(); + if (!session->premium()) { + PeerMenuTodoWantsPremium(TodoWantsPremium::Add); + return; + } + auto box = Box(controller, item); + const auto raw = box.data(); + box->submitRequests( + ) | rpl::start_with_next([=](const AddTodoListTasksBox::Result &result) { + const auto show = raw->uiShow(); + raw->closeBox(); + session->api().todoLists().add( + item, + result.items, + [] {}, + [=](const QString &error) { show->showToast(error); }); + }, 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 18da8845c6..4b5419ff8a 100644 --- a/Telegram/SourceFiles/window/window_peer_menu.h +++ b/Telegram/SourceFiles/window/window_peer_menu.h @@ -123,6 +123,9 @@ void PeerMenuCreateTodoList( FullReplyTo replyTo = FullReplyTo(), Api::SendType sendType = Api::SendType::Normal, SendMenu::Details sendMenuDetails = SendMenu::Details()); +void PeerMenuAddTodoListTasks( + not_null controller, + not_null item); void PeerMenuDeleteTopicWithConfirmation( not_null navigation, not_null topic);