diff --git a/Telegram/SourceFiles/auth_session.cpp b/Telegram/SourceFiles/auth_session.cpp index 7d59ccfb1d..5271e3e8db 100644 --- a/Telegram/SourceFiles/auth_session.cpp +++ b/Telegram/SourceFiles/auth_session.cpp @@ -25,6 +25,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/send_files_box.h" #include "ui/widgets/input_fields.h" #include "support/support_common.h" +#include "support/support_templates.h" #include "observer_peer.h" namespace { @@ -322,7 +323,11 @@ AuthSession::AuthSession(const MTPUser &user) , _storage(std::make_unique()) , _notifications(std::make_unique(this)) , _data(std::make_unique(this)) -, _changelogs(Core::Changelogs::Create(this)) { +, _changelogs(Core::Changelogs::Create(this)) +, _supportTemplates( + (Support::ValidateAccount(user) + ? std::make_unique(this) + : nullptr)) { App::feedUser(user); _saveDataTimer.setCallback([=] { @@ -422,8 +427,13 @@ void AuthSession::checkAutoLockIn(TimeMs time) { } bool AuthSession::supportMode() const { - return true; AssertIsDebug(); - return _user->phone().startsWith(qstr("424")); + return (_supportTemplates != nullptr); +} + +not_null AuthSession::supportTemplates() const { + Expects(supportMode()); + + return _supportTemplates.get(); } AuthSession::~AuthSession() = default; diff --git a/Telegram/SourceFiles/auth_session.h b/Telegram/SourceFiles/auth_session.h index 4aea4755ba..3e66fc1f7b 100644 --- a/Telegram/SourceFiles/auth_session.h +++ b/Telegram/SourceFiles/auth_session.h @@ -21,6 +21,7 @@ enum class InputSubmitSettings; namespace Support { enum class SwitchSettings; +class Templates; } // namespace Support namespace Data { @@ -295,6 +296,7 @@ public: base::Observable, MsgId>> messageIdChanging; bool supportMode() const; + not_null supportTemplates() const; ~AuthSession(); @@ -321,6 +323,8 @@ private: // _changelogs depends on _data, subscribes on chats loading event. const std::unique_ptr _changelogs; + const std::unique_ptr _supportTemplates; + rpl::lifetime _lifetime; }; diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index b673bcd1e1..9d4ba12f5d 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -256,3 +256,9 @@ mentionFg: windowSubTextFg; mentionFgOver: windowSubTextFgOver; mentionFgActive: windowActiveTextFg; mentionFgOverActive: windowActiveTextFg; + +autocompleteSearchPadding: margins(16px, 5px, 16px, 5px); +autocompleteRowPadding: margins(16px, 5px, 16px, 5px); +autocompleteRowTitle: semiboldTextStyle; +autocompleteRowKeys: defaultTextStyle; +autocompleteRowAnswer: defaultTextStyle; diff --git a/Telegram/SourceFiles/history/history_widget.cpp b/Telegram/SourceFiles/history/history_widget.cpp index 8fb5de07d3..a320874b06 100644 --- a/Telegram/SourceFiles/history/history_widget.cpp +++ b/Telegram/SourceFiles/history/history_widget.cpp @@ -70,6 +70,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "chat_helpers/emoji_suggestions_widget.h" #include "core/crash_reports.h" #include "support/support_common.h" +#include "support/support_autocomplete.h" #include "dialogs/dialogs_key.h" #include "styles/style_history.h" #include "styles/style_dialogs.h" @@ -421,6 +422,9 @@ HistoryWidget::HistoryWidget( , _historyDown(_scroll, st::historyToDown) , _unreadMentions(_scroll, st::historyUnreadMentions) , _fieldAutocomplete(this) +, _supportAutocomplete(Auth().supportMode() + ? object_ptr(this, &Auth()) + : nullptr) , _send(this) , _unblock(this, lang(lng_unblock_button).toUpper(), st::historyUnblock) , _botStart(this, lang(lng_bot_start).toUpper(), st::historyComposeButton) @@ -523,6 +527,14 @@ HistoryWidget::HistoryWidget( connect(_fieldAutocomplete, SIGNAL(botCommandChosen(QString,FieldAutocomplete::ChooseMethod)), this, SLOT(onHashtagOrBotCommandInsert(QString,FieldAutocomplete::ChooseMethod))); connect(_fieldAutocomplete, SIGNAL(stickerChosen(not_null,FieldAutocomplete::ChooseMethod)), this, SLOT(onStickerOrGifSend(not_null))); connect(_fieldAutocomplete, SIGNAL(moderateKeyActivate(int,bool*)), this, SLOT(onModerateKeyActivate(int,bool*))); + if (_supportAutocomplete) { + _supportAutocomplete->hide(); + _supportAutocomplete->insertRequests( + ) | rpl::start_with_next([=](const QString &text) { + _field->setFocus(); + _field->textCursor().insertText(text); + }, lifetime()); + } _fieldLinksParser = std::make_unique(_field); _fieldLinksParser->list().changes( ) | rpl::start_with_next([=](QStringList &&parsed) { @@ -2178,6 +2190,9 @@ void HistoryWidget::updateControlsVisibility() { } _kbShown = false; _fieldAutocomplete->hide(); + if (_supportAutocomplete) { + _supportAutocomplete->hide(); + } _send->hide(); if (_silent) { _silent->hide(); @@ -2270,6 +2285,9 @@ void HistoryWidget::updateControlsVisibility() { } } else { _fieldAutocomplete->hide(); + if (_supportAutocomplete) { + _supportAutocomplete->hide(); + } _send->hide(); _unblock->hide(); _botStart->hide(); @@ -3006,6 +3024,9 @@ bool HistoryWidget::saveEditMsgFail(History *history, const RPCError &error, mtp void HistoryWidget::hideSelectorControlsAnimated() { _fieldAutocomplete->hideAnimated(); + if (_supportAutocomplete) { + _supportAutocomplete->hide(); + } if (_tabbedPanel) { _tabbedPanel->hideAnimated(); } @@ -4818,6 +4839,9 @@ void HistoryWidget::updateControlsGeometry() { if (_scroll->y() != scrollAreaTop) { _scroll->moveToLeft(0, scrollAreaTop); _fieldAutocomplete->setBoundings(_scroll->geometry()); + if (_supportAutocomplete) { + _supportAutocomplete->setBoundings(_scroll->geometry()); + } } if (_reportSpamPanel) { _reportSpamPanel->setGeometryToLeft(0, _scroll->y(), width(), _reportSpamPanel->height()); @@ -4998,6 +5022,9 @@ void HistoryWidget::updateHistoryGeometry(bool initial, bool loadedDown, const S } _fieldAutocomplete->setBoundings(_scroll->geometry()); + if (_supportAutocomplete) { + _supportAutocomplete->setBoundings(_scroll->geometry()); + } if (!_historyDownShown.animating()) { // _historyDown is a child widget of _scroll, not me. _historyDown->moveToRight(st::historyToDownPosition.x(), _scroll->height() - _historyDown->height() - st::historyToDownPosition.y()); @@ -5412,7 +5439,9 @@ void HistoryWidget::replyToNextMessage() { } void HistoryWidget::onFieldTabbed() { - if (!_fieldAutocomplete->isHidden()) { + if (_supportAutocomplete) { + _supportAutocomplete->activate(); + } else if (!_fieldAutocomplete->isHidden()) { _fieldAutocomplete->chooseSelected(FieldAutocomplete::ChooseMethod::ByTab); } } diff --git a/Telegram/SourceFiles/history/history_widget.h b/Telegram/SourceFiles/history/history_widget.h index dbe3894075..49b2133c95 100644 --- a/Telegram/SourceFiles/history/history_widget.h +++ b/Telegram/SourceFiles/history/history_widget.h @@ -36,6 +36,10 @@ namespace Data { struct Draft; } // namespace Data +namespace Support { +class Autocomplete; +} // namespace Support + namespace Ui { class AbstractButton; class InnerDropdown; @@ -781,6 +785,7 @@ private: object_ptr _unreadMentions; object_ptr _fieldAutocomplete; + object_ptr _supportAutocomplete; std::unique_ptr _fieldLinksParser; UserData *_inlineBot = nullptr; diff --git a/Telegram/SourceFiles/support/support_autocomplete.cpp b/Telegram/SourceFiles/support/support_autocomplete.cpp new file mode 100644 index 0000000000..476b3a4fe5 --- /dev/null +++ b/Telegram/SourceFiles/support/support_autocomplete.cpp @@ -0,0 +1,361 @@ +/* +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 "support/support_autocomplete.h" + +#include "ui/widgets/scroll_area.h" +#include "ui/widgets/input_fields.h" +#include "ui/wrap/padding_wrap.h" +#include "support/support_templates.h" +#include "auth_session.h" +#include "styles/style_chat_helpers.h" +#include "styles/style_window.h" + +namespace Support { +namespace { + +class Inner : public Ui::RpWidget { +public: + Inner(QWidget *parent); + + using Question = details::TemplatesQuestion; + void showRows(std::vector &&rows); + + std::pair moveSelection(int delta); + + std::optional selected() const; + + auto activated() const { + return _activated.events(); + } + +protected: + void paintEvent(QPaintEvent *e) override; + void mouseMoveEvent(QMouseEvent *e) override; + void mousePressEvent(QMouseEvent *e) override; + void mouseReleaseEvent(QMouseEvent *e) override; + void leaveEventHook(QEvent *e) override; + + int resizeGetHeight(int newWidth) override; + +private: + struct Row { + Question data; + Text question = { st::windowMinWidth / 2 }; + Text keys = { st::windowMinWidth / 2 }; + Text answer = { st::windowMinWidth / 2 }; + int top = 0; + int height = 0; + }; + + void prepareRow(Row &row); + int resizeRowGetHeight(Row &row, int newWidth); + void setSelected(int selected); + + std::vector _rows; + int _selected = -1; + int _pressed = -1; + bool _selectByKeys = false; + rpl::event_stream<> _activated; + +}; + +Inner::Inner(QWidget *parent) : RpWidget(parent) { + setMouseTracking(true); +} + +void Inner::showRows(std::vector &&rows) { + _rows.resize(0); + _rows.reserve(rows.size()); + for (auto &row : rows) { + _rows.push_back({ std::move(row) }); + auto &added = _rows.back(); + prepareRow(added); + } + resizeToWidth(width()); + update(); + _selected = _pressed = -1; +} + +std::pair Inner::moveSelection(int delta) { + const auto selected = _selected + delta; + if (selected >= 0 && selected < _rows.size()) { + _selectByKeys = true; + setSelected(selected); + const auto top = _rows[_selected].top; + return { top, top + _rows[_selected].height }; + } + return { -1, -1 }; +} + +auto Inner::selected() const -> std::optional { + if (_rows.empty()) { + return std::nullopt; + } else if (_selected < 0) { + return _rows[0].data; + } + return _rows[_selected].data; +} + +void Inner::prepareRow(Row &row) { + row.question.setText(st::autocompleteRowTitle, row.data.question); + row.keys.setText( + st::autocompleteRowKeys, + row.data.keys.join(qstr(", "))); + row.answer.setText(st::autocompleteRowAnswer, row.data.value); +} + +int Inner::resizeRowGetHeight(Row &row, int newWidth) { + const auto available = newWidth + - st::autocompleteRowPadding.left() + - st::autocompleteRowPadding.right(); + return row.height = st::autocompleteRowPadding.top() + + row.question.countHeight(available) + + row.keys.countHeight(available) + + row.answer.countHeight(available) + + st::autocompleteRowPadding.bottom() + + st::lineWidth; +} + +int Inner::resizeGetHeight(int newWidth) { + auto top = 0; + for (auto &row : _rows) { + row.top = top; + top += resizeRowGetHeight(row, newWidth); + } + return top ? (top - st::lineWidth) : (3 * st::mentionHeight); +} + +void Inner::paintEvent(QPaintEvent *e) { + Painter p(this); + + if (_rows.empty()) { + p.setFont(st::boxTextFont); + p.setPen(st::windowSubTextFg); + p.drawText( + rect(), + "Search by question, keys or value", + style::al_center); + return; + } + + const auto clip = e->rect(); + const auto from = ranges::upper_bound( + _rows, + clip.y(), + std::less<>(), + [](const Row &row) { return row.top + row.height; }); + const auto till = ranges::lower_bound( + _rows, + clip.y() + clip.height(), + std::less<>(), + [](const Row &row) { return row.top; }); + if (from == end(_rows)) { + return; + } + p.translate(0, from->top); + const auto padding = st::autocompleteRowPadding; + const auto available = width() - padding.left() - padding.right(); + auto top = padding.top(); + const auto drawText = [&](const Text &text) { + text.drawLeft( + p, + padding.left(), + top, + available, + width()); + top += text.countHeight(available); + }; + for (auto i = from; i != till; ++i) { + const auto over = (i - begin(_rows) == _selected); + if (over) { + p.fillRect(0, 0, width(), i->height, st::windowBgOver); + } + p.setPen(st::mentionNameFg); + drawText(i->question); + p.setPen(over ? st::mentionFgOver : st::mentionFg); + drawText(i->keys); + p.setPen(st::windowFg); + drawText(i->answer); + + p.translate(0, i->height); + top = padding.top(); + + if (i - begin(_rows) + 1 == _selected) { + p.fillRect( + 0, + -st::lineWidth, + width(), + st::lineWidth, + st::windowBgOver); + } else if (!over) { + p.fillRect( + padding.left(), + -st::lineWidth, + available, + st::lineWidth, + st::shadowFg); + } + } +} + +void Inner::mouseMoveEvent(QMouseEvent *e) { + static auto lastGlobalPos = QPoint(); + const auto moved = (e->globalPos() != lastGlobalPos); + if (!moved && _selectByKeys) { + return; + } + _selectByKeys = false; + lastGlobalPos = e->globalPos(); + const auto i = ranges::upper_bound( + _rows, + e->pos().y(), + std::less<>(), + [](const Row &row) { return row.top + row.height; }); + setSelected((i == end(_rows)) ? -1 : (i - begin(_rows))); +} + +void Inner::leaveEventHook(QEvent *e) { + setSelected(-1); +} + +void Inner::setSelected(int selected) { + if (_selected != selected) { + _selected = selected; + update(); + } +} + +void Inner::mousePressEvent(QMouseEvent *e) { + _pressed = _selected; +} + +void Inner::mouseReleaseEvent(QMouseEvent *e) { + const auto pressed = base::take(_pressed); + if (pressed == _selected && pressed >= 0) { + _activated.fire({}); + } +} + +} // namespace + +Autocomplete::Autocomplete(QWidget *parent, not_null session) +: RpWidget(parent) +, _session(session) { + setupContent(); +} + +void Autocomplete::activate() { + _activate(); +} + +void Autocomplete::deactivate() { + _deactivate(); +} + +void Autocomplete::setBoundings(QRect rect) { + const auto maxHeight = int(4.5 * st::mentionHeight); + const auto height = std::min(rect.height(), maxHeight); + setGeometry( + rect.x(), + rect.y() + rect.height() - height, + rect.width(), + height); +} + +rpl::producer Autocomplete::insertRequests() { + return _insertRequests.events(); +} + +void Autocomplete::keyPressEvent(QKeyEvent *e) { + if (e->key() == Qt::Key_Up) { + _moveSelection(-1); + } else if (e->key() == Qt::Key_Down) { + _moveSelection(1); + } +} + +void Autocomplete::setupContent() { + const auto inputWrap = Ui::CreateChild>( + this, + object_ptr( + this, + st::gifsSearchField, + [] { return "Search for templates"; }), + st::autocompleteSearchPadding); + const auto input = inputWrap->entity(); + const auto scroll = Ui::CreateChild( + this, + st::mentionScroll); + + const auto inner = scroll->setOwnedWidget(object_ptr(scroll)); + + const auto submit = [=] { + if (const auto question = inner->selected()) { + _insertRequests.fire_copy(question->value); + } + }; + + const auto refresh = [=] { + inner->showRows( + _session->supportTemplates()->query(input->getLastText())); + scroll->scrollToY(0); + }; + + inner->activated() | rpl::start_with_next(submit, lifetime()); + connect(input, &Ui::InputField::blurred, [=] { + App::CallDelayed(10, this, [=] { + if (!input->hasFocus()) { + deactivate(); + } + }); + }); + connect(input, &Ui::InputField::cancelled, [=] { deactivate(); }); + connect(input, &Ui::InputField::changed, refresh); + connect(input, &Ui::InputField::submitted, submit); + input->customUpDown(true); + + _activate = [=] { + input->setText(QString()); + show(); + input->setFocus(); + }; + _deactivate = [=] { + hide(); + }; + _moveSelection = [=](int delta) { + const auto range = inner->moveSelection(delta); + if (range.second > range.first) { + scroll->scrollToY(range.first, range.second); + } + }; + + paintRequest( + ) | rpl::start_with_next([=](QRect clip) { + QPainter p(this); + p.fillRect( + clip.intersected(QRect(0, st::lineWidth, width(), height())), + st::mentionBg); + p.fillRect( + clip.intersected(QRect(0, 0, width(), st::lineWidth)), + st::shadowFg); + }, lifetime()); + + sizeValue( + ) | rpl::start_with_next([=](QSize size) { + inputWrap->resizeToWidth(size.width()); + inputWrap->moveToLeft(0, st::lineWidth, size.width()); + scroll->setGeometry( + 0, + inputWrap->height(), + size.width(), + size.height() - inputWrap->height() - st::lineWidth); + inner->resizeToWidth(size.width()); + }, lifetime()); +} + +} // namespace Support diff --git a/Telegram/SourceFiles/support/support_autocomplete.h b/Telegram/SourceFiles/support/support_autocomplete.h new file mode 100644 index 0000000000..79d202c6f1 --- /dev/null +++ b/Telegram/SourceFiles/support/support_autocomplete.h @@ -0,0 +1,45 @@ +/* +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/rp_widget.h" + +class AuthSession; + +namespace Ui { +class ScrollArea; +} // namespace Ui + +namespace Support { + +class Autocomplete : public Ui::RpWidget { +public: + Autocomplete(QWidget *parent, not_null session); + + void activate(); + void deactivate(); + void setBoundings(QRect rect); + + rpl::producer insertRequests(); + +protected: + void keyPressEvent(QKeyEvent *e) override; + +private: + void setupContent(); + + not_null _session; + Fn _activate; + Fn _deactivate; + Fn _moveSelection; + + rpl::event_stream _insertRequests; + +}; + +} //namespace Support diff --git a/Telegram/SourceFiles/support/support_common.cpp b/Telegram/SourceFiles/support/support_common.cpp index e1ca3e60a3..90e30753d4 100644 --- a/Telegram/SourceFiles/support/support_common.cpp +++ b/Telegram/SourceFiles/support/support_common.cpp @@ -11,6 +11,15 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Support { +bool ValidateAccount(const MTPUser &self) { + return true; AssertIsDebug(); + return self.match([](const MTPDuser &data) { + return data.has_phone() && qs(data.vphone).startsWith(qstr("424")); + }, [](const MTPDuserEmpty &data) { + return false; + }); +} + void PerformSwitch(SwitchSettings value) { switch (value) { case SwitchSettings::Next: diff --git a/Telegram/SourceFiles/support/support_common.h b/Telegram/SourceFiles/support/support_common.h index baba305d58..db95dfe61d 100644 --- a/Telegram/SourceFiles/support/support_common.h +++ b/Telegram/SourceFiles/support/support_common.h @@ -9,6 +9,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Support { +bool ValidateAccount(const MTPUser &self); + enum class SwitchSettings { None, Next, diff --git a/Telegram/SourceFiles/support/support_templates.cpp b/Telegram/SourceFiles/support/support_templates.cpp new file mode 100644 index 0000000000..6ecf752660 --- /dev/null +++ b/Telegram/SourceFiles/support/support_templates.cpp @@ -0,0 +1,302 @@ +/* +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 "support/support_templates.h" + +namespace Support { +namespace details { +namespace { + +constexpr auto kQueryLimit = 10; +constexpr auto kWeightStep = 1000; + +bool IsTemplatesFile(const QString &file) { + return file.startsWith(qstr("tl_"), Qt::CaseInsensitive) + && file.endsWith(qstr(".txt"), Qt::CaseInsensitive); +} + +QString NormalizeQuestion(const QString &question) { + auto result = QString(); + result.reserve(question.size()); + for (const auto ch : question) { + if (ch.isLetterOrNumber()) { + result.append(ch.toLower()); + } + } + return result; +} + +struct FileResult { + TemplatesFile result; + QStringList errors; +}; + +FileResult ReadFromBlob(const QByteArray &blob) { + auto result = FileResult(); + const auto lines = blob.split('\n'); + + enum class State { + None, + Question, + Keys, + Value, + MoreValue, + Url, + }; + auto state = State::None; + QStringList keys; + QString question, value; + const auto pushQuestion = [&] { + const auto normalized = NormalizeQuestion(question); + if (!normalized.isEmpty()) { + result.result.questions.emplace( + normalized, + TemplatesQuestion{ question, keys, value }); + } + question = value = QString(); + keys = QStringList(); + }; + for (const auto &utf : lines) { + const auto line = QString::fromUtf8(utf).trimmed(); + const auto match = QRegularExpression( + qsl("^\\{([A-Z_]+)\\}$") + ).match(line); + if (match.hasMatch()) { + const auto token = match.captured(1); + if (state == State::Value || state == State::MoreValue) { + pushQuestion(); + } + if (token == qstr("VALUE")) { + state = value.isEmpty() ? State::Value : State::None; + } else if (token == qstr("KEYS")) { + state = keys.isEmpty() ? State::Keys : State::None; + } else if (token == qstr("QUESTION")) { + state = State::Question; + } else if (token == qstr("URL")) { + state = State::Url; + } else { + state = State::None; + } + continue; + } + + switch (state) { + case State::Keys: + if (!line.isEmpty()) { + keys.push_back(line); + } + break; + case State::MoreValue: + value += '\n'; + [[fallthrough]]; + case State::Value: + value += line; + state = State::MoreValue; + break; + case State::Question: + if (question.isEmpty()) question = line; + break; + case State::Url: + if (result.result.url.isEmpty()) { + result.result.url = line; + } + break; + } + } + pushQuestion(); + return result; +} + +FileResult ReadFile(const QString &path) { + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + auto result = FileResult(); + result.errors.push_back( + qsl("Couldn't open '%1' for reading!").arg(path)); + return result; + } + + const auto blob = f.readAll(); + f.close(); + + return ReadFromBlob(blob); +} + +struct FilesResult { + TemplatesData result; + TemplatesIndex index; + QStringList errors; +}; + +FilesResult ReadFiles(const QString &folder) { + auto result = FilesResult(); + const auto files = QDir(folder).entryList(QDir::Files); + for (const auto &path : files) { + if (!IsTemplatesFile(path)) { + continue; + } + auto file = ReadFile(folder + '/' + path); + if (!file.result.url.isEmpty() || !file.result.questions.empty()) { + result.result.files[path] = std::move(file.result); + } + result.errors.append(std::move(file.errors)); + } + return result; +} + +TemplatesIndex ComputeIndex(const TemplatesData &data) { + using Id = TemplatesIndex::Id; + using Term = TemplatesIndex::Term; + + auto uniqueFirst = std::map>(); + auto uniqueFull = std::map>(); + const auto pushString = [&]( + const Id &id, + const QString &string, + int weight) { + const auto list = TextUtilities::PrepareSearchWords(string); + for (const auto &word : list) { + uniqueFirst[word[0]].emplace(id); + uniqueFull[id].emplace(std::make_pair(word, weight)); + } + }; + for (const auto &[path, file] : data.files) { + for (const auto &[normalized, question] : file.questions) { + const auto id = std::make_pair(path, normalized); + for (const auto &key : question.keys) { + pushString(id, key, kWeightStep * kWeightStep); + } + pushString(id, question.question, kWeightStep); + pushString(id, question.value, 1); + } + } + + const auto to_vector = [](auto &&range) { + return range | ranges::to_vector; + }; + auto result = TemplatesIndex(); + for (const auto &[ch, unique] : uniqueFirst) { + result.first.emplace(ch, to_vector(unique)); + } + for (const auto &[id, unique] : uniqueFull) { + result.full.emplace(id, to_vector(unique)); + } + return result; +} + +} // namespace +} // namespace details + +Templates::Templates(not_null session) : _session(session) { + reload(); +} + +void Templates::reload() { + if (_reloadAfterRead) { + return; + } else if (_reading.alive()) { + _reloadAfterRead = true; + return; + } + + auto [left, right] = base::make_binary_guard(); + _reading = std::move(left); + crl::async([=, guard = std::move(right)]() mutable { + auto result = details::ReadFiles(cWorkingDir() + "TEMPLATES"); + result.index = details::ComputeIndex(result.result); + crl::on_main([ + =, + result = std::move(result), + guard = std::move(guard) + ]() mutable { + if (!guard.alive()) { + return; + } + _data = std::move(result.result); + _index = std::move(result.index); + _errors.fire(std::move(result.errors)); + crl::on_main(this, [=] { + if (base::take(_reloadAfterRead)) { + reload(); + } else { + update(); + } + }); + }); + }); +} + +auto Templates::query(const QString &text) const -> std::vector { + const auto words = TextUtilities::PrepareSearchWords(text); + const auto questions = [&](const QString &word) { + const auto i = _index.first.find(word[0]); + return (i == end(_index.first)) ? 0 : i->second.size(); + }; + const auto best = ranges::min_element(words, std::less<>(), questions); + if (best == std::end(words)) { + return {}; + } + const auto narrowed = _index.first.find((*best)[0]); + if (narrowed == end(_index.first)) { + return {}; + } + using Id = details::TemplatesIndex::Id; + using Term = details::TemplatesIndex::Term; + const auto questionById = [&](const Id &id) { + return _data.files.at(id.first).questions.at(id.second); + }; + + using Pair = std::pair; + const auto computeWeight = [&](const Id &id) { + auto result = 0; + const auto full = _index.full.find(id); + for (const auto &word : words) { + const auto from = ranges::lower_bound( + full->second, + word, + std::less<>(), + [](const Term &term) { return term.first; }); + const auto till = std::find_if( + from, + end(full->second), + [&](const Term &term) { + return !term.first.startsWith(word); + }); + const auto weight = std::max_element( + from, + till, + [](const Term &a, const Term &b) { + return a.second < b.second; + }); + if (weight == till) { + return 0; + } + result += weight->second * (weight->first == word ? 2 : 1); + } + return result; + }; + const auto pairById = [&](const Id &id) { + return std::make_pair(questionById(id), computeWeight(id)); + }; + const auto good = narrowed->second | ranges::view::transform( + pairById + ) | ranges::view::filter([](const Pair &pair) { + return pair.second > 0; + }) | ranges::to_vector | ranges::action::sort( + std::greater<>(), + [](const Pair &pair) { return pair.second; } + ); + return good | ranges::view::transform([](const Pair &pair) { + return pair.first; + }) | ranges::view::take(details::kQueryLimit) | ranges::to_vector; +} + +void Templates::update() { + +} + +} // namespace Support diff --git a/Telegram/SourceFiles/support/support_templates.h b/Telegram/SourceFiles/support/support_templates.h new file mode 100644 index 0000000000..4d5b366b98 --- /dev/null +++ b/Telegram/SourceFiles/support/support_templates.h @@ -0,0 +1,68 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "base/binary_guard.h" + +class AuthSession; + +namespace Support { +namespace details { + +struct TemplatesQuestion { + QString question; + QStringList keys; + QString value; +}; + +struct TemplatesFile { + QString url; + std::map questions; +}; + +struct TemplatesData { + std::map files; +}; + +struct TemplatesIndex { + using Id = std::pair; // filename, normalized question + using Term = std::pair; // search term, weight + + std::map> first; + std::map> full; +}; + +} // namespace details + +class Templates : public base::has_weak_ptr { +public: + explicit Templates(not_null session); + + void reload(); + + using Question = details::TemplatesQuestion; + std::vector query(const QString &text) const; + + auto errors() const { + return _errors.events(); + } + +private: + void update(); + + not_null _session; + + details::TemplatesData _data; + details::TemplatesIndex _index; + rpl::event_stream _errors; + base::binary_guard _reading; + bool _reloadAfterRead = false; + +}; + +} // namespace Support diff --git a/Telegram/gyp/telegram_sources.txt b/Telegram/gyp/telegram_sources.txt index f844f31fe5..a21f20b561 100644 --- a/Telegram/gyp/telegram_sources.txt +++ b/Telegram/gyp/telegram_sources.txt @@ -576,8 +576,12 @@ <(src_loc)/storage/storage_sparse_ids_list.h <(src_loc)/storage/storage_user_photos.cpp <(src_loc)/storage/storage_user_photos.h +<(src_loc)/support/support_autocomplete.cpp +<(src_loc)/support/support_autocomplete.h <(src_loc)/support/support_common.cpp <(src_loc)/support/support_common.h +<(src_loc)/support/support_templates.cpp +<(src_loc)/support/support_templates.h <(src_loc)/ui/effects/cross_animation.cpp <(src_loc)/ui/effects/cross_animation.h <(src_loc)/ui/effects/fade_animation.cpp