diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 60f3eac6e4..d1e1c7a823 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -3444,6 +3444,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_chatbots_not_found" = "Chatbot not found."; "lng_chatbots_not_supported" = "This bot doesn't support Telegram Business yet."; "lng_chatbots_add" = "Add"; +"lng_chatbots_leave_without_added_title" = "No Bot Added"; +"lng_chatbots_leave_without_added_text" = "You haven't added a bot to manage your account. Leave anyway?"; +"lng_chatbots_added_success" = "{bot} now manages your account."; "lng_chat_automation_title" = "Chat Automation"; "lng_chat_automation_about" = "Add a bot to answer messages on your behalf."; diff --git a/Telegram/SourceFiles/settings/business/settings_chatbots.cpp b/Telegram/SourceFiles/settings/business/settings_chatbots.cpp index 67aac5c652..41c7d42827 100644 --- a/Telegram/SourceFiles/settings/business/settings_chatbots.cpp +++ b/Telegram/SourceFiles/settings/business/settings_chatbots.cpp @@ -11,6 +11,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/peers/edit_peer_permissions_box.h" #include "boxes/peers/prepare_short_info_box.h" #include "boxes/peer_list_box.h" +#include "config.h" #include "core/application.h" #include "data/business/data_business_chatbots.h" #include "data/data_session.h" @@ -21,6 +22,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/boxes/confirm_box.h" #include "ui/effects/ripple_animation.h" #include "ui/text/text_utilities.h" +#include "ui/toast/toast.h" #include "ui/widgets/fields/input_field.h" #include "ui/widgets/buttons.h" #include "ui/wrap/slide_wrap.h" @@ -29,6 +31,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/vertical_list.h" #include "window/window_session_controller.h" #include "styles/style_boxes.h" +#include "styles/style_chat.h" #include "styles/style_layers.h" #include "styles/style_settings.h" @@ -36,6 +39,7 @@ namespace Settings { namespace { constexpr auto kDebounceTimeout = crl::time(400); +constexpr auto kMaxSearchBots = 3; enum class LookupState { Empty, @@ -44,9 +48,10 @@ enum class LookupState { Ready, }; -struct BotState { - UserData *bot = nullptr; +struct BotSearchState { + std::vector bots; LookupState state = LookupState::Empty; + bool exactUnsupported = false; }; enum class PreviewActionKind { @@ -66,11 +71,14 @@ public: ~Chatbots(); [[nodiscard]] bool closeByOutsideClick() const override; + void checkBeforeClose(Fn close) override; [[nodiscard]] rpl::producer title() override; + void setInnerFocus() override; const Ui::RoundRect *bottomSkipRounding() const override; private: + [[nodiscard]] bool shouldConfirmLeaveWithoutAddedBot() const; void setupContent(); void refreshDetails(); void save(); @@ -79,13 +87,15 @@ private: Ui::VerticalLayout *_detailsWrap = nullptr; Ui::VerticalLayout *_permissionsWrap = nullptr; + Ui::SlideWrap *_usernameWrap = nullptr; rpl::variable _recipients; rpl::variable _committedBot; Data::BusinessRecipients _committedRecipients; Data::ChatbotsPermissions _committedPermissions = Defaults(); rpl::variable _usernameValue; - rpl::variable _botValue; + rpl::variable _lookupState; + rpl::variable _chooserVisible = false; rpl::variable _permissions = Defaults(); Fn _resolvePermissions; @@ -94,10 +104,10 @@ private: class PreviewController final : public PeerListController { public: PreviewController( - not_null peer, - PreviewActionKind kind, + std::vector bots, + UserData *committedBot, Fn resetBot, - Fn addBot); + Fn addBot); void prepare() override; void loadMoreRows() override; @@ -106,10 +116,10 @@ public: Main::Session &session() const override; private: - const not_null _peer; - const PreviewActionKind _kind; + const std::vector _bots; + UserData *const _committedBot = nullptr; const Fn _resetBot; - const Fn _addBot; + const Fn _addBot; rpl::lifetime _lifetime; }; @@ -153,6 +163,10 @@ PreviewRow::PreviewRow( , _addTextWidth((_kind == PreviewActionKind::Add) ? st::settingsChatbotsAddButton.style.font->width(_addText) : 0) { + const auto username = peer->username(); + if (!username.isEmpty()) { + setCustomStatus('@' + username); + } } QSize PreviewRow::addPillSize() const { @@ -246,19 +260,24 @@ void PreviewRow::rightActionStopLastRipple() { } PreviewController::PreviewController( - not_null peer, - PreviewActionKind kind, + std::vector bots, + UserData *committedBot, Fn resetBot, - Fn addBot) -: _peer(peer) -, _kind(kind) + Fn addBot) +: _bots(std::move(bots)) +, _committedBot(committedBot) , _resetBot(std::move(resetBot)) , _addBot(std::move(addBot)) { } void PreviewController::prepare() { - delegate()->peerListAppendRow( - std::make_unique(_peer, _kind)); + for (const auto bot : _bots) { + const auto kind = (bot == _committedBot) + ? PreviewActionKind::Remove + : PreviewActionKind::Add; + delegate()->peerListAppendRow( + std::make_unique(bot, kind)); + } delegate()->peerListRefreshRows(); } @@ -270,15 +289,19 @@ void PreviewController::rowClicked(not_null row) { void PreviewController::rowRightActionClicked( not_null row) { - if (_kind == PreviewActionKind::Add) { - _addBot(); - } else { + const auto user = row->peer()->asUser(); + if (!user) { + return; + } + if (user == _committedBot) { _resetBot(); + } else { + _addBot(user); } } Main::Session &PreviewController::session() const { - return _peer->session(); + return _bots.front()->session(); } [[nodiscard]] rpl::producer DebouncedValue( @@ -311,86 +334,191 @@ Main::Session &PreviewController::session() const { }; } -[[nodiscard]] QString ExtractUsername(QString text) { +struct ParsedUsernameQuery { + QString value; + bool valid = false; +}; + +[[nodiscard]] ParsedUsernameQuery ParseUsernameQuery(QString text) { text = text.trimmed(); if (text.startsWith(QChar('@'))) { - return text.mid(1); + text = text.mid(1); + } else { + static const auto extractExpression = QRegularExpression( + "^(https://)?([a-zA-Z0-9\\.]+/)?([a-zA-Z0-9_\\.]+)"); + const auto match = extractExpression.match(text); + text = match.hasMatch() ? match.captured(3) : text; } - static const auto expression = QRegularExpression( - "^(https://)?([a-zA-Z0-9\\.]+/)?([a-zA-Z0-9_\\.]+)"); - const auto match = expression.match(text); - return match.hasMatch() ? match.captured(3) : text; + static const auto validateExpression = QRegularExpression( + "^[a-zA-Z0-9_\\.]+$"); + return { + .value = text, + .valid = !text.isEmpty() + && validateExpression.match(text).hasMatch(), + }; } -[[nodiscard]] rpl::producer LookupBot( +[[nodiscard]] bool IsUsableBusinessBot(UserData *user) { + return user + && user->isBot() + && user->botInfo->supportsBusiness; +} + +[[nodiscard]] UserData *FirstBot(const BotSearchState &state) { + return state.bots.empty() ? nullptr : state.bots.front(); +} + +[[nodiscard]] BotSearchState SingleBotState(UserData *bot) { + auto result = BotSearchState{ + .state = bot ? LookupState::Ready : LookupState::Empty, + }; + if (bot) { + result.bots.push_back(bot); + } + return result; +} + +[[nodiscard]] std::pair PreviewState( + BotSearchState lookupState, + UserData *committed, + bool chooserVisible, + const QString &usernameText) { + if (committed && !chooserVisible) { + return { SingleBotState(committed), committed }; + } + if (chooserVisible && ParseUsernameQuery(usernameText).valid) { + return { std::move(lookupState), nullptr }; + } + return { BotSearchState(), nullptr }; +} + +void AppendUniqueBot( + std::vector &bots, + UserData *user) { + if (!IsUsableBusinessBot(user) + || ranges::contains(bots, user) + || bots.size() >= kMaxSearchBots) { + return; + } + bots.push_back(user); +} + +[[nodiscard]] UserData *UserFromPeer( + not_null session, + const MTPPeer &peer) { + return session->data().peer(peerFromMTP(peer))->asUser(); +} + +struct BotSearchRequest { + UserData *exact = nullptr; + std::vector my; + std::vector global; + mtpRequestId exactRequestId = 0; + mtpRequestId searchRequestId = 0; + bool exactUnsupported = false; + bool exactDone = false; + bool searchDone = false; +}; + +[[nodiscard]] BotSearchState MakeReadySearchState( + const BotSearchRequest &request) { + auto bots = std::vector(); + AppendUniqueBot(bots, request.exact); + for (const auto user : request.my) { + AppendUniqueBot(bots, user); + } + for (const auto user : request.global) { + AppendUniqueBot(bots, user); + } + const auto unsupported = bots.empty() + && request.exactUnsupported; + return { + .bots = std::move(bots), + .state = unsupported ? LookupState::Unsupported : LookupState::Ready, + .exactUnsupported = request.exactUnsupported, + }; +} + +void AppendUsersFromPeerList( + not_null session, + const MTPVector &list, + std::vector &users) { + users.reserve(users.size() + list.v.size()); + for (const auto &mtpPeer : list.v) { + if (const auto user = UserFromPeer(session, mtpPeer)) { + users.push_back(user); + } + } +} + +[[nodiscard]] rpl::producer LookupBots( not_null session, rpl::producer usernameChanges) { - using Cache = base::flat_map; - const auto cache = std::make_shared(); return std::move( usernameChanges - ) | rpl::map([=](const QString &username) -> rpl::producer { - const auto extracted = ExtractUsername(username); - const auto owner = &session->data(); - static const auto expression = QRegularExpression( - "^[a-zA-Z0-9_\\.]+$"); - if (!expression.match(extracted).hasMatch()) { - return rpl::single(BotState()); - } else if (const auto peer = owner->peerByUsername(extracted)) { - if (const auto user = peer->asUser(); user && user->isBot()) { - if (user->botInfo->supportsBusiness) { - return rpl::single(BotState{ - .bot = user, - .state = LookupState::Ready, - }); - } - return rpl::single(BotState{ - .state = LookupState::Unsupported, - }); - } - return rpl::single(BotState{ - .state = LookupState::Ready, - }); - } else if (const auto i = cache->find(extracted); i != end(*cache)) { - return rpl::single(BotState{ - .bot = i->second, - .state = LookupState::Ready, - }); + ) | rpl::map([=]( + const QString &username) -> rpl::producer { + const auto query = ParseUsernameQuery(username); + if (!query.valid) { + return rpl::single(BotSearchState()); } + const auto extracted = query.value; return [=](auto consumer) { auto result = rpl::lifetime(); - const auto requestId = result.make_state(); - *requestId = session->api().request(MTPcontacts_ResolveUsername( + const auto data = result.make_state(); + const auto finish = [=] { + if (!data->exactDone || !data->searchDone) { + return; + } + consumer.put_next(MakeReadySearchState(*data)); + }; + + consumer.put_next(BotSearchState{ + .state = LookupState::Loading, + }); + + data->exactRequestId = session->api().request(MTPcontacts_ResolveUsername( MTP_flags(0), MTP_string(extracted), MTP_string() )).done([=](const MTPcontacts_ResolvedPeer &result) { - const auto &data = result.data(); - session->data().processUsers(data.vusers()); - session->data().processChats(data.vchats()); - const auto peerId = peerFromMTP(data.vpeer()); - const auto peer = session->data().peer(peerId); - if (const auto user = peer->asUser()) { - if (user->isBot()) { - cache->emplace(extracted, user); - consumer.put_next(BotState{ - .bot = user, - .state = LookupState::Ready, - }); - return; + const auto &resolved = result.data(); + session->data().processUsers(resolved.vusers()); + session->data().processChats(resolved.vchats()); + if (const auto user = UserFromPeer(session, resolved.vpeer())) { + if (IsUsableBusinessBot(user)) { + data->exact = user; + } else if (user->isBot()) { + data->exactUnsupported = true; } } - cache->emplace(extracted, nullptr); - consumer.put_next(BotState{ .state = LookupState::Ready }); + data->exactDone = true; + finish(); }).fail([=] { - cache->emplace(extracted, nullptr); - consumer.put_next(BotState{ .state = LookupState::Ready }); + data->exactDone = true; + finish(); + }).send(); + data->searchRequestId = session->api().request(MTPcontacts_Search( + MTP_string(extracted), + MTP_int(SearchPeopleLimit) + )).done([=](const MTPcontacts_Found &result) { + const auto &found = result.data(); + session->data().processUsers(found.vusers()); + session->data().processChats(found.vchats()); + AppendUsersFromPeerList(session, found.vmy_results(), data->my); + AppendUsersFromPeerList(session, found.vresults(), data->global); + data->searchDone = true; + finish(); + }).fail([=] { + data->searchDone = true; + finish(); }).send(); result.add([=] { - session->api().request(*requestId).cancel(); + session->api().request(data->exactRequestId).cancel(); + session->api().request(data->searchRequestId).cancel(); }); return result; }; @@ -399,9 +527,9 @@ Main::Session &PreviewController::session() const { [[nodiscard]] object_ptr MakeBotPreview( not_null parent, - rpl::producer> stateAndKind, + rpl::producer> stateAndBot, Fn resetBot, - Fn addBot) { + Fn addBot) { auto result = object_ptr>( parent.get(), object_ptr(parent.get())); @@ -410,22 +538,22 @@ Main::Session &PreviewController::session() const { raw->hide(anim::type::instant); const auto child = inner->lifetime().make_state(nullptr); - std::move(stateAndKind) | rpl::filter([=](const auto &pair) { - return pair.first.state != LookupState::Loading; - }) | rpl::on_next([=](std::pair pair) { + std::move(stateAndBot) | rpl::on_next([=]( + std::pair pair) { const auto &state = pair.first; - const auto kind = pair.second; - raw->toggle( - (state.state == LookupState::Ready - || state.state == LookupState::Unsupported), - anim::type::normal); - if (state.bot) { + const auto committed = pair.second; + const auto hasBots = !state.bots.empty(); + const auto hasLabel = !hasBots + && (state.state == LookupState::Ready + || state.state == LookupState::Unsupported); + raw->toggle(hasBots || hasLabel, anim::type::normal); + if (hasBots) { const auto delegate = parent->lifetime().make_state< PeerListContentDelegateSimple >(); const auto controller = parent->lifetime().make_state< PreviewController - >(state.bot, kind, resetBot, addBot); + >(state.bots, committed, resetBot, addBot); controller->setStyleOverrides(&st::peerListSingleRow); const auto content = Ui::CreateChild( inner, @@ -434,8 +562,7 @@ Main::Session &PreviewController::session() const { controller->setDelegate(delegate); delete base::take(*child); *child = content; - } else if (state.state == LookupState::Ready - || state.state == LookupState::Unsupported) { + } else if (hasLabel) { const auto content = Ui::CreateChild(inner); const auto label = Ui::CreateChild( content, @@ -457,6 +584,7 @@ Main::Session &PreviewController::session() const { delete base::take(*child); *child = content; } else { + delete base::take(*child); return; } (*child)->show(); @@ -467,7 +595,7 @@ Main::Session &PreviewController::session() const { (*child)->heightValue() | rpl::on_next([=](int height) { inner->resize(inner->width(), height + st::contactSkip); - }, inner->lifetime()); + }, (*child)->lifetime()); }, inner->lifetime()); raw->finishAnimating(); @@ -488,14 +616,49 @@ Chatbots::~Chatbots() { } } +bool Chatbots::shouldConfirmLeaveWithoutAddedBot() const { + if (!_chooserVisible.current() || _committedBot.current()) { + return false; + } + const auto parsed = ParseUsernameQuery(_usernameValue.current()); + const auto ¤t = _lookupState.current(); + return parsed.valid + && !current.bots.empty(); +} + bool Chatbots::closeByOutsideClick() const { return false; } +void Chatbots::checkBeforeClose(Fn close) { + if (!shouldConfirmLeaveWithoutAddedBot()) { + close(); + return; + } + controller()->show(Ui::MakeConfirmBox({ + .text = tr::lng_chatbots_leave_without_added_text(), + .confirmed = crl::guard(this, [=](Fn closeBox) { + closeBox(); + close(); + }), + .confirmText = tr::lng_box_leave(), + .cancelText = tr::lng_cancel(), + .title = tr::lng_chatbots_leave_without_added_title(), + })); +} + rpl::producer Chatbots::title() { return tr::lng_chat_automation_title(); } +void Chatbots::setInnerFocus() { + if (_chooserVisible.current()) { + _usernameWrap->entity()->setFocus(); + } else { + AbstractSection::setInnerFocus(); + } +} + const Ui::RoundRect *Chatbots::bottomSkipRounding() const { return _permissionsWrap->count() ? nullptr : &_bottomSkipRounding; } @@ -521,58 +684,81 @@ void Chatbots::setupContent() { .aboutMargins = st::peerAppearanceCoverLabelMargin, }); - const auto username = content->add( - object_ptr( + const auto usernameWrap = content->add( + object_ptr>( content, - st::settingsChatbotsUsername, - tr::lng_chatbots_placeholder(), - (current.bot - ? current.bot->session().createInternalLink( - current.bot->username()) - : QString())), - st::settingsChatbotsUsernameMargins); + object_ptr( + content, + st::settingsChatbotsUsername, + tr::lng_chatbots_placeholder(), + QString()), + st::settingsChatbotsUsernameMargins), + QMargins(0, st::settingsChatbotsUsernameMargins.bottom(), 0, 0)); + usernameWrap->setDuration(0); + _usernameWrap = usernameWrap; + _chooserVisible = !current.bot; + _usernameWrap->toggle(_chooserVisible.current(), anim::type::instant); + const auto username = usernameWrap->entity(); - _usernameValue = DebouncedValue(username); - _botValue = rpl::single(BotState{ - current.bot, - current.bot ? LookupState::Ready : LookupState::Empty - }) | rpl::then( - LookupBot(&controller()->session(), _usernameValue.changes()) + _usernameValue = rpl::single( + username->getLastText() + ) | rpl::then( + username->changes() | rpl::map([=] { + return username->getLastText(); + })); + _lookupState = LookupBots(&controller()->session(), DebouncedValue(username) ); const auto resetBot = [=] { username->setText(QString()); - username->setFocus(); _committedBot = nullptr; _recipients = Data::BusinessRecipients::MakeValid({}); _permissions = Defaults(); _committedRecipients = _recipients.current(); _committedPermissions = _permissions.current(); + _chooserVisible = true; + _usernameWrap->toggle(true, anim::type::instant); + username->setFocus(); }; - const auto addBot = [=] { - const auto resolved = _botValue.current().bot; + const auto addBot = [=](UserData *resolved) { if (!resolved) { return; } _committedBot = resolved; _committedRecipients = _recipients.current(); _committedPermissions = _resolvePermissions(); + _chooserVisible = false; + _usernameWrap->toggle(false, anim::type::instant); + controller()->showToast(Ui::Toast::Config{ + .text = { tr::lng_chatbots_added_success( + tr::now, + lt_bot, + resolved->name()) }, + .icon = &st::toastCheckIcon, + }); }; - auto stateAndKind = rpl::combine( - _botValue.value(), - _committedBot.value() - ) | rpl::map([](BotState state, UserData *committed) { - const auto kind = (state.bot && state.bot == committed) - ? PreviewActionKind::Remove - : PreviewActionKind::Add; - return std::make_pair(state, kind); + auto stateAndBot = rpl::combine( + _lookupState.value(), + _committedBot.value(), + _chooserVisible.value(), + _usernameValue.value() + ) | rpl::map([]( + BotSearchState lookupState, + UserData *committed, + bool chooserVisible, + const QString &usernameText) { + return PreviewState( + std::move(lookupState), + committed, + chooserVisible, + usernameText); }); content->add(object_ptr>( content, MakeBotPreview( content, - std::move(stateAndKind), + std::move(stateAndBot), resetBot, addBot))); @@ -603,8 +789,10 @@ void Chatbots::setupContent() { refreshDetails(); rpl::merge( + _chooserVisible.changes() | rpl::to_empty, _committedBot.changes() | rpl::to_empty, - _botValue.changes() | rpl::to_empty + _lookupState.changes() | rpl::to_empty, + _usernameValue.changes() | rpl::to_empty ) | rpl::on_next([=] { refreshDetails(); }, lifetime()); @@ -620,9 +808,13 @@ void Chatbots::refreshDetails() { delete _permissionsWrap->widgetAt(0); } - const auto bot = _botValue.current().bot - ? _botValue.current().bot - : _committedBot.current(); + auto bot = _committedBot.current(); + if (!bot && _chooserVisible.current()) { + const auto parsed = ParseUsernameQuery(_usernameValue.current()); + if (parsed.valid) { + bot = FirstBot(_lookupState.current()); + } + } if (!bot) { _permissionsWrap->resizeToWidth(width()); return; diff --git a/Telegram/SourceFiles/settings/settings.style b/Telegram/SourceFiles/settings/settings.style index 7ea1a1112c..f10a841895 100644 --- a/Telegram/SourceFiles/settings/settings.style +++ b/Telegram/SourceFiles/settings/settings.style @@ -666,7 +666,7 @@ settingsChatbotsAccess: Checkbox(defaultCheckbox) { } settingsLocationAddress: InputField(defaultMultiSelectSearchField) { } -settingsChatbotsUsernameMargins: margins(20px, 8px, 20px, 8px); +settingsChatbotsUsernameMargins: margins(20px, 0px, 20px, 8px); settingsChatbotsAccessMargins: margins(22px, 5px, 22px, 9px); settingsChatbotsAccessSkip: 4px; settingsChatbotsBottomTextMargin: margins(22px, 8px, 22px, 3px);