From 4069e4412c827390a2c91ab990b63c82961e3025 Mon Sep 17 00:00:00 2001 From: John Preston Date: Fri, 20 Mar 2026 21:22:21 +0700 Subject: [PATCH] Initial attempt to add ai controls. --- Telegram/CMakeLists.txt | 6 + Telegram/Resources/icons/menu/edit_stars.svg | 9 + .../Resources/icons/menu/search_check.svg | 7 + Telegram/Resources/langs/cloud_lang.strings | 6 + Telegram/Resources/langs/lang.strings | 12 + .../SourceFiles/api/api_compose_with_ai.cpp | 117 ++ .../SourceFiles/api/api_compose_with_ai.h | 75 + Telegram/SourceFiles/apiwrap.cpp | 6 + Telegram/SourceFiles/apiwrap.h | 3 + Telegram/SourceFiles/boxes/boxes.style | 102 ++ Telegram/SourceFiles/boxes/compose_ai_box.cpp | 1260 +++++++++++++++++ Telegram/SourceFiles/boxes/compose_ai_box.h | 33 + .../chat_helpers/chat_helpers.style | 37 + Telegram/SourceFiles/core/core_settings.cpp | 9 +- Telegram/SourceFiles/core/core_settings.h | 10 + .../SourceFiles/history/history_widget.cpp | 131 ++ Telegram/SourceFiles/history/history_widget.h | 11 + .../history_view_compose_ai_button.cpp | 97 ++ .../controls/history_view_compose_ai_button.h | 30 + .../history_view_compose_controls.cpp | 125 ++ .../controls/history_view_compose_controls.h | 11 + Telegram/SourceFiles/main/main_app_config.cpp | 48 + Telegram/SourceFiles/main/main_app_config.h | 7 + Telegram/lib_ui | 2 +- 24 files changed, 2152 insertions(+), 2 deletions(-) create mode 100644 Telegram/Resources/icons/menu/edit_stars.svg create mode 100644 Telegram/Resources/icons/menu/search_check.svg create mode 100644 Telegram/SourceFiles/api/api_compose_with_ai.cpp create mode 100644 Telegram/SourceFiles/api/api_compose_with_ai.h create mode 100644 Telegram/SourceFiles/boxes/compose_ai_box.cpp create mode 100644 Telegram/SourceFiles/boxes/compose_ai_box.h create mode 100644 Telegram/SourceFiles/history/view/controls/history_view_compose_ai_button.cpp create mode 100644 Telegram/SourceFiles/history/view/controls/history_view_compose_ai_button.h diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 273b999533..4a3423535c 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -130,6 +130,8 @@ PRIVATE api/api_common.h api/api_confirm_phone.cpp api/api_confirm_phone.h + api/api_compose_with_ai.cpp + api/api_compose_with_ai.h api/api_credits.cpp api/api_credits.h api/api_credits_history_entry.cpp @@ -372,6 +374,8 @@ PRIVATE boxes/stickers_box.h boxes/transfer_gift_box.cpp boxes/transfer_gift_box.h + boxes/compose_ai_box.cpp + boxes/compose_ai_box.h boxes/translate_box.cpp boxes/translate_box.h boxes/url_auth_box.cpp @@ -816,6 +820,8 @@ PRIVATE history/admin_log/history_admin_log_section.cpp history/admin_log/history_admin_log_section.h history/view/controls/compose_controls_common.h + history/view/controls/history_view_compose_ai_button.cpp + history/view/controls/history_view_compose_ai_button.h history/view/controls/history_view_compose_controls.cpp history/view/controls/history_view_compose_controls.h history/view/controls/history_view_compose_media_edit_manager.cpp diff --git a/Telegram/Resources/icons/menu/edit_stars.svg b/Telegram/Resources/icons/menu/edit_stars.svg new file mode 100644 index 0000000000..228638acb7 --- /dev/null +++ b/Telegram/Resources/icons/menu/edit_stars.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/menu/search_check.svg b/Telegram/Resources/icons/menu/search_check.svg new file mode 100644 index 0000000000..b5e6fa0dd2 --- /dev/null +++ b/Telegram/Resources/icons/menu/search_check.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Telegram/Resources/langs/cloud_lang.strings b/Telegram/Resources/langs/cloud_lang.strings index 3776c023a4..3e4555a5f7 100644 --- a/Telegram/Resources/langs/cloud_lang.strings +++ b/Telegram/Resources/langs/cloud_lang.strings @@ -16,6 +16,12 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "cloud_lng_age_verify_about_gb#one" = "To access such content, you must confirm that you are at least **{count}** year old as required by UK law."; "cloud_lng_age_verify_about_gb#other" = "To access such content, you must confirm that you are at least **{count}** years old as required by UK law."; +"cloud_lng_ai_compose_style_biblical" = "Biblical"; +"cloud_lng_ai_compose_style_formal" = "Formal"; +"cloud_lng_ai_compose_style_posh" = "Posh"; +"cloud_lng_ai_compose_style_savage" = "Savage"; +"cloud_lng_ai_compose_style_short" = "Short"; + "cloud_lng_passport_in_ar" = "Arabic"; "cloud_lng_passport_in_az" = "Azerbaijani"; "cloud_lng_passport_in_bg" = "Bulgarian"; diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 4bfbb5e3b7..8542aad728 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -7759,5 +7759,17 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_sr_bot_verified_badge" = "Verified Bot"; "lng_sr_profile_menu" = "Profile menu"; "lng_sr_close_panel" = "Close panel"; +"lng_ai_compose_title" = "AI Editor"; +"lng_ai_compose_apply" = "Apply"; +"lng_ai_compose_tab_translate" = "Translate"; +"lng_ai_compose_tab_style" = "Style"; +"lng_ai_compose_tab_fix" = "Fix"; +"lng_ai_compose_original" = "Original"; +"lng_ai_compose_result" = "Result"; +"lng_ai_compose_to_language" = "To {language}"; +"lng_ai_compose_emojify" = "emojify"; +"lng_ai_compose_loading" = "Working on it..."; +"lng_ai_compose_error" = "AI request failed."; +"lng_ai_compose_tooltip" = "Rewrite, translate, or correct your text using AI."; // Keys finished diff --git a/Telegram/SourceFiles/api/api_compose_with_ai.cpp b/Telegram/SourceFiles/api/api_compose_with_ai.cpp new file mode 100644 index 0000000000..dfebd67e5f --- /dev/null +++ b/Telegram/SourceFiles/api/api_compose_with_ai.cpp @@ -0,0 +1,117 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "api/api_compose_with_ai.h" + +#include "api/api_text_entities.h" +#include "apiwrap.h" + +namespace Api { +namespace { + +[[nodiscard]] MTPTextWithEntities Serialize( + not_null session, + const TextWithEntities &text) { + return MTP_textWithEntities( + MTP_string(text.text), + EntitiesToMTP(session, text.entities, ConvertOption::SkipLocal)); +} + +} // namespace + +ComposeWithAi::ComposeWithAi(not_null api) +: _session(&api->session()) +, _api(&api->instance()) { +} + +mtpRequestId ComposeWithAi::request( + Request request, + Fn done, + Fn fail) { + using Flag = MTPmessages_composeMessageWithAI::Flag; + auto flags = MTPmessages_composeMessageWithAI::Flags(0); + if (request.proofread) { + flags |= Flag::f_proofread; + } + if (!request.translateToLang.isEmpty()) { + flags |= Flag::f_translate_to_lang; + } + if (!request.changeTone.isEmpty()) { + flags |= Flag::f_change_tone; + } + if (request.emojify) { + flags |= Flag::f_emojify; + } + const auto session = _session; + return _api.request(MTPmessages_ComposeMessageWithAI( + MTP_flags(flags), + Serialize(session, request.text), + request.translateToLang.isEmpty() + ? MTPstring() + : MTP_string(request.translateToLang), + request.changeTone.isEmpty() + ? MTPstring() + : MTP_string(request.changeTone) + )).done([=, done = std::move(done)]( + const MTPmessages_ComposedMessageWithAI &result) mutable { + const auto &data = result.data(); + auto parsed = Result{ + .resultText = ParseTextWithEntities(session, data.vresult_text()), + }; + if (const auto diff = data.vdiff_text()) { + parsed.diffText = ParseDiff(session, *diff); + } + done(std::move(parsed)); + }).fail([=, fail = std::move(fail)](const MTP::Error &error) mutable { + if (fail) { + fail(error); + } + }).send(); +} + +void ComposeWithAi::cancel(mtpRequestId requestId) { + if (requestId) { + _api.request(requestId).cancel(); + } +} + +ComposeWithAi::Diff ComposeWithAi::ParseDiff( + not_null session, + const MTPTextWithEntities &text) { + const auto &data = text.data(); + auto result = Diff{ + .text = ParseTextWithEntities(session, text), + }; + const auto &entities = data.ventities().v; + result.entities.reserve(entities.size()); + for (const auto &entity : entities) { + entity.match([&](const MTPDmessageEntityDiffInsert &data) { + result.entities.push_back({ + .type = DiffEntity::Type::Insert, + .offset = data.voffset().v, + .length = data.vlength().v, + }); + }, [&](const MTPDmessageEntityDiffReplace &data) { + result.entities.push_back({ + .type = DiffEntity::Type::Replace, + .offset = data.voffset().v, + .length = data.vlength().v, + .oldText = qs(data.vold_text()), + }); + }, [&](const MTPDmessageEntityDiffDelete &data) { + result.entities.push_back({ + .type = DiffEntity::Type::Delete, + .offset = data.voffset().v, + .length = data.vlength().v, + }); + }, [](const auto &) { + }); + } + return result; +} + +} // namespace Api diff --git a/Telegram/SourceFiles/api/api_compose_with_ai.h b/Telegram/SourceFiles/api/api_compose_with_ai.h new file mode 100644 index 0000000000..9a87aa0542 --- /dev/null +++ b/Telegram/SourceFiles/api/api_compose_with_ai.h @@ -0,0 +1,75 @@ +/* +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 "mtproto/sender.h" +#include "ui/text/text_entity.h" + +#include +#include + +class ApiWrap; + +namespace Main { +class Session; +} // namespace Main + +namespace Api { + +class ComposeWithAi final { +public: + struct Request { + TextWithEntities text; + QString translateToLang; + QString changeTone; + bool proofread = false; + bool emojify = false; + }; + + struct DiffEntity { + enum class Type { + Insert, + Replace, + Delete, + }; + + Type type = Type::Insert; + int offset = 0; + int length = 0; + QString oldText; + }; + + struct Diff { + TextWithEntities text; + std::vector entities; + }; + + struct Result { + TextWithEntities resultText; + std::optional diffText; + }; + + explicit ComposeWithAi(not_null api); + + [[nodiscard]] mtpRequestId request( + Request request, + Fn done, + Fn fail = nullptr); + void cancel(mtpRequestId requestId); + +private: + [[nodiscard]] static Diff ParseDiff( + not_null session, + const MTPTextWithEntities &text); + + const not_null _session; + MTP::Sender _api; + +}; + +} // namespace Api diff --git a/Telegram/SourceFiles/apiwrap.cpp b/Telegram/SourceFiles/apiwrap.cpp index 1a2afb5f87..f6181512ee 100644 --- a/Telegram/SourceFiles/apiwrap.cpp +++ b/Telegram/SourceFiles/apiwrap.cpp @@ -32,6 +32,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_confirm_phone.h" #include "api/api_unread_things.h" #include "api/api_ringtones.h" +#include "api/api_compose_with_ai.h" #include "api/api_transcribes.h" #include "api/api_premium.h" #include "api/api_user_names.h" @@ -190,6 +191,7 @@ ApiWrap::ApiWrap(not_null session) , _chatParticipants(std::make_unique(this)) , _unreadThings(std::make_unique(this)) , _ringtones(std::make_unique(this)) +, _composeWithAi(std::make_unique(this)) , _transcribes(std::make_unique(this)) , _premium(std::make_unique(this)) , _usernames(std::make_unique(this)) @@ -5014,6 +5016,10 @@ Api::Ringtones &ApiWrap::ringtones() { return *_ringtones; } +Api::ComposeWithAi &ApiWrap::composeWithAi() { + return *_composeWithAi; +} + Api::Transcribes &ApiWrap::transcribes() { return *_transcribes; } diff --git a/Telegram/SourceFiles/apiwrap.h b/Telegram/SourceFiles/apiwrap.h index 19e43e5d3a..6f775ad193 100644 --- a/Telegram/SourceFiles/apiwrap.h +++ b/Telegram/SourceFiles/apiwrap.h @@ -81,6 +81,7 @@ class TodoLists; class ChatParticipants; class UnreadThings; class Ringtones; +class ComposeWithAi; class Transcribes; class Premium; class ReadMetrics; @@ -423,6 +424,7 @@ public: [[nodiscard]] Api::ChatParticipants &chatParticipants(); [[nodiscard]] Api::UnreadThings &unreadThings(); [[nodiscard]] Api::Ringtones &ringtones(); + [[nodiscard]] Api::ComposeWithAi &composeWithAi(); [[nodiscard]] Api::Transcribes &transcribes(); [[nodiscard]] Api::Premium &premium(); [[nodiscard]] Api::Usernames &usernames(); @@ -782,6 +784,7 @@ private: const std::unique_ptr _chatParticipants; const std::unique_ptr _unreadThings; const std::unique_ptr _ringtones; + const std::unique_ptr _composeWithAi; const std::unique_ptr _transcribes; const std::unique_ptr _premium; const std::unique_ptr _usernames; diff --git a/Telegram/SourceFiles/boxes/boxes.style b/Telegram/SourceFiles/boxes/boxes.style index 68d777ce9e..6c4636b287 100644 --- a/Telegram/SourceFiles/boxes/boxes.style +++ b/Telegram/SourceFiles/boxes/boxes.style @@ -1261,3 +1261,105 @@ managedBotIconEmoji: IconEmoji { icon: icon {{ "chat/code_tags", windowSubTextFg }}; padding: margins(-2px, -2px, -2px, 0px); } + +aiComposeBoxSectionSkip: 8px; +aiComposeBoxStyleTabsSkip: 8px; +aiComposeContentMargin: margins(16px, 0px, 16px, 0px); + +aiComposeButtonBgActive: windowActiveTextFg; +aiComposeButtonBgActiveOpacity: 0.1; +aiComposeButtonFg: windowBoldFg; +aiComposeButtonFgActive: windowActiveTextFg; +aiComposeButtonRippleInactive: RippleAnimation(defaultRippleAnimation) { + color: windowBoldFg; +} +aiComposeButtonRippleInactiveOpacity: 0.1; +aiComposeButtonRippleActive: RippleAnimation(defaultRippleAnimation) { + color: windowActiveTextFg; +} +aiComposeButtonRippleActiveOpacity: 0.1; + +aiComposeTabsBg: boxBg; +aiComposeTabsHeight: 58px; +aiComposeTabsRadius: 29px; +aiComposeTabsPadding: margins(6px, 6px, 6px, 6px); +aiComposeTabsSkip: 4px; +aiComposeTabButtonBgActive: aiComposeButtonBgActive; +aiComposeTabLabelFg: aiComposeButtonFg; +aiComposeTabLabelFgActive: aiComposeButtonFgActive; +aiComposeTabLabelFont: font(12px semibold); +aiComposeTabIconTop: 6px; +aiComposeTabLabelTop: 26px; +aiComposeTabTranslateIcon: icon {{ "menu/translate", aiComposeButtonFg }}; +aiComposeTabTranslateIconActive: icon {{ "menu/translate", aiComposeButtonFgActive }}; +aiComposeTabStyleIcon: icon {{ "menu/edit_stars", aiComposeButtonFg }}; +aiComposeTabStyleIconActive: icon {{ "menu/edit_stars", aiComposeButtonFgActive }}; +aiComposeTabFixIcon: icon {{ "menu/search_check", aiComposeButtonFg }}; +aiComposeTabFixIconActive: icon {{ "menu/search_check", aiComposeButtonFgActive }}; + +aiComposeStyleTabsBg: boxBg; +aiComposeStyleTabsHeight: 64px; +aiComposeStyleTabsRadius: 14px; +aiComposeStyleTabsPadding: margins(6px, 6px, 6px, 6px); +aiComposeStyleTabsSkip: 4px; +aiComposeStyleButtonBgActive: aiComposeButtonBgActive; +aiComposeStyleLabelFg: aiComposeButtonFg; +aiComposeStyleLabelFgActive: aiComposeButtonFgActive; +aiComposeStyleEmojiTop: 5px; +aiComposeStyleLabelTop: 30px; +aiComposeStyleEmojiFont: font(20px); +aiComposeStyleLabelFont: font(12px semibold); + +aiComposeCardBg: boxBg; +aiComposeCardRadius: 22px; +aiComposeCardPadding: margins(12px, 16px, 16px, 16px); +aiComposeCardDivider: shadowFg; +aiComposeCardSectionSkip: 12px; +aiComposeCardTextSkip: 0px; +aiComposeCardCopySkip: 0px; +aiComposeCardControlSkip: 8px; +aiComposeCardTitle: FlatLabel(defaultSubsectionTitle) { + textFg: windowFg; + minWidth: 0px; + maxHeight: 22px; +} +aiComposeEmojifyCheckbox: Checkbox(defaultBoxCheckbox) { + width: 0px; +} + +aiComposeExpandIcon: icon{{ "info/edit/expand_arrow_small", windowActiveTextFg }}; +aiComposeCollapseIcon: icon{{ "info/edit/expand_arrow_small-flip_vertical", windowActiveTextFg }}; +aiComposeExpandButton: IconButton(defaultIconButton) { + width: 32px; + height: 32px; + icon: aiComposeExpandIcon; + iconOver: aiComposeExpandIcon; + iconPosition: point(5px, 5px); + rippleAreaPosition: point(0px, 0px); + rippleAreaSize: 32px; + ripple: defaultRippleAnimationBgOver; +} + +aiComposeCopyIcon: icon{{ "menu/copy", windowActiveTextFg }}; +aiComposeCopyButton: IconButton(aiComposeExpandButton) { + icon: aiComposeCopyIcon; + iconOver: aiComposeCopyIcon; +} + +aiComposeBoxButton: RoundButton(defaultActiveButton) { + height: 42px; + textTop: 12px; + style: semiboldTextStyle; +} +aiComposeBox: Box(defaultBox) { + buttonPadding: margins(16px, 12px, 16px, 12px); + buttonHeight: 42px; + buttonWide: true; + button: aiComposeBoxButton; + shadowIgnoreTopSkip: true; + bg: boxDividerBg; +} +aiComposeBoxWithSend: Box(aiComposeBox) { + buttonPadding: margins(16px, 12px, 66px, 12px); +} +aiComposeSendButtonSkip: 8px; diff --git a/Telegram/SourceFiles/boxes/compose_ai_box.cpp b/Telegram/SourceFiles/boxes/compose_ai_box.cpp new file mode 100644 index 0000000000..7d14d0ba9d --- /dev/null +++ b/Telegram/SourceFiles/boxes/compose_ai_box.cpp @@ -0,0 +1,1260 @@ +/* +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/compose_ai_box.h" + +#include "api/api_compose_with_ai.h" +#include "apiwrap.h" +#include "core/ui_integration.h" +#include "lang/lang_instance.h" +#include "lang/lang_keys.h" +#include "main/main_app_config.h" +#include "main/main_session.h" +#include "spellcheck/platform/platform_language.h" +#include "ui/boxes/choose_language_box.h" +#include "ui/controls/send_button.h" +#include "ui/effects/ripple_animation.h" +#include "ui/layers/generic_box.h" +#include "ui/painter.h" +#include "ui/text/text_utilities.h" +#include "ui/vertical_list.h" +#include "ui/wrap/slide_wrap.h" +#include "ui/wrap/vertical_layout.h" +#include "ui/widgets/buttons.h" +#include "ui/widgets/checkbox.h" +#include "ui/widgets/labels.h" +#include "styles/style_basic.h" +#include "styles/style_boxes.h" +#include "styles/style_chat_helpers.h" +#include "styles/style_layers.h" +#include "styles/style_widgets.h" + +#include + +namespace HistoryView::Controls { +namespace { + +enum class ComposeAiMode { + Translate, + Style, + Fix, +}; + +[[nodiscard]] QString ToneTitle(const QString &key) { + const auto value = Lang::GetNonDefaultValue( + QByteArray("cloud_lng_ai_compose_style_") + key.toUtf8()); + if (!value.isEmpty()) { + return value; + } + if (key.isEmpty()) { + return QString(); + } + auto result = key; + result[0] = result[0].toUpper(); + return result; +} + +[[nodiscard]] TextWithEntities HighlightDiff(TextWithEntities text) { + return Ui::Text::Colorized( + Ui::Text::Wrapped(std::move(text), EntityType::Underline)); +} + +[[nodiscard]] TextWithEntities BuildDiffDisplay( + const Api::ComposeWithAi::Diff &diff) { + auto result = TextWithEntities(); + auto entities = diff.entities; + std::stable_sort( + entities.begin(), + entities.end(), + [](const auto &a, const auto &b) { + return a.offset < b.offset; + }); + const auto size = diff.text.text.size(); + auto taken = 0; + for (const auto &entity : entities) { + const auto offset = std::clamp(entity.offset, 0, size); + const auto length = std::clamp(entity.length, 0, size - offset); + if (offset > taken) { + result.append(Ui::Text::Mid(diff.text, taken, offset - taken)); + } + auto part = Ui::Text::Mid(diff.text, offset, length); + switch (entity.type) { + case Api::ComposeWithAi::DiffEntity::Type::Insert: + result.append(HighlightDiff(std::move(part))); + break; + case Api::ComposeWithAi::DiffEntity::Type::Replace: + if (!entity.oldText.isEmpty()) { + result.append( + Ui::Text::Wrapped( + TextWithEntities::Simple(entity.oldText), + EntityType::StrikeOut)); + } + result.append(HighlightDiff(std::move(part))); + break; + case Api::ComposeWithAi::DiffEntity::Type::Delete: + result.append( + Ui::Text::Wrapped(std::move(part), EntityType::StrikeOut)); + break; + } + taken = std::max(taken, offset + length); + } + if (taken < size) { + result.append(Ui::Text::Mid(diff.text, taken)); + } + return result; +} + +[[nodiscard]] QString FromTitle(LanguageId id) { + return tr::lng_ai_compose_original(tr::now); +} + +[[nodiscard]] TextWithEntities ToTitle(LanguageId id) { + return tr::lng_ai_compose_to_language( + tr::now, + lt_language, + tr::link(Ui::LanguageName(id)), + tr::marked); +} + +[[nodiscard]] LanguageId DefaultAiTranslateTo(LanguageId offeredFrom) { + const auto current = LanguageId{ + QLocale(Lang::LanguageIdOrDefault(Lang::Id())).language() + }; + if (current && (current != offeredFrom)) { + return current; + } + const auto english = LanguageId{ QLocale::English }; + if (english != offeredFrom) { + return english; + } + return LanguageId{ QLocale::Spanish }; +} + +[[nodiscard]] const style::icon &ModeIcon( + ComposeAiMode mode, + bool active) { + switch (mode) { + case ComposeAiMode::Translate: + return active + ? st::aiComposeTabTranslateIconActive + : st::aiComposeTabTranslateIcon; + case ComposeAiMode::Style: + return active + ? st::aiComposeTabStyleIconActive + : st::aiComposeTabStyleIcon; + case ComposeAiMode::Fix: + return active + ? st::aiComposeTabFixIconActive + : st::aiComposeTabFixIcon; + } + return active + ? st::aiComposeTabTranslateIconActive + : st::aiComposeTabTranslateIcon; +} + +[[nodiscard]] qreal ComposeAiPillRadius(int height) { + return height / 2.; +} + +[[nodiscard]] qreal ComposeAiStyleRadius() { + return st::aiComposeStyleTabsRadius; +} + +[[nodiscard]] QColor ComposeAiColorWithAlpha( + const style::color &color, + float64 alpha) { + auto result = color->c; + result.setAlphaF(result.alphaF() * alpha); + return result; +} + +[[nodiscard]] QColor ComposeAiActiveBackgroundColor( + const style::color &color) { + return ComposeAiColorWithAlpha( + color, + st::aiComposeButtonBgActiveOpacity); +} + +[[nodiscard]] QColor ComposeAiRippleColor( + const style::RippleAnimation &ripple, + float64 opacity) { + return ComposeAiColorWithAlpha( + ripple.color, + opacity); +} + +class ComposeAiModeButton final : public Ui::RippleButton { +public: + ComposeAiModeButton( + QWidget *parent, + ComposeAiMode mode, + QString label); + + void setSelected(bool selected); + [[nodiscard]] ComposeAiMode mode() const; + +protected: + void paintEvent(QPaintEvent *e) override; + [[nodiscard]] QImage prepareRippleMask() const override; + +private: + const ComposeAiMode _mode; + const QString _label; + bool _selected = false; + +}; + +class ComposeAiModeTabs final : public Ui::RpWidget { +public: + ComposeAiModeTabs(QWidget *parent); + + void setActive(ComposeAiMode mode); + void setChangedCallback(Fn callback); + +protected: + int resizeGetHeight(int newWidth) override; + void paintEvent(QPaintEvent *e) override; + +private: + const not_null _translate; + const not_null _style; + const not_null _fix; + Fn _changed; + ComposeAiMode _active = ComposeAiMode::Translate; + +}; + +class ComposeAiStyleButton final : public Ui::RippleButton { +public: + ComposeAiStyleButton( + QWidget *parent, + Main::AppConfig::AiComposeStyle style); + + void setSelected(bool selected); + [[nodiscard]] const Main::AppConfig::AiComposeStyle &style() const; + +protected: + void paintEvent(QPaintEvent *e) override; + [[nodiscard]] QImage prepareRippleMask() const override; + +private: + const Main::AppConfig::AiComposeStyle _style; + const QString _label; + bool _selected = false; + +}; + +class ComposeAiStyleTabs final : public Ui::RpWidget { +public: + ComposeAiStyleTabs( + QWidget *parent, + std::vector styles); + + void setChangedCallback(Fn callback); + void setActive(int index); + [[nodiscard]] QString currentTone() const; + +protected: + int resizeGetHeight(int newWidth) override; + void paintEvent(QPaintEvent *e) override; + +private: + QList> _buttons; + Fn _changed; + int _active = 0; + +}; + +class ComposeAiPreviewCard final : public Ui::RpWidget { +public: + ComposeAiPreviewCard( + QWidget *parent, + not_null session, + TextWithEntities original); + + void setResizeCallback(Fn callback); + void setChooseCallback(Fn callback); + void setCopyCallback(Fn callback); + void setEmojifyChangedCallback(Fn callback); + void setOriginalTitle(const QString &title); + void setOriginalVisible(bool visible); + void setResultTitle(const TextWithEntities &title); + void setEmojifyVisible(bool visible); + void setEmojifyChecked(bool checked); + void setLoading(bool loading); + void setResult(TextWithEntities text, bool copyVisible); + +protected: + int resizeGetHeight(int newWidth) override; + void paintEvent(QPaintEvent *e) override; + +private: + void refreshGeometry(); + void updateOriginalToggleIcon(); + + const Ui::Text::MarkedContext _context; + const TextWithEntities _original; + const not_null _originalTitle; + const not_null _originalBody; + const not_null _originalToggle; + const not_null _resultTitle; + const not_null _resultBody; + const not_null _copy; + const not_null _emojify; + Fn _resized; + Fn _chooseCallback; + Fn _copyCallback; + Fn _emojifyChanged; + bool _originalExpanded = false; + bool _originalVisible = true; + bool _loading = false; + bool _hasShownResult = false; + bool _copyVisible = false; + bool _emojifyVisible = false; + bool _dividerVisible = false; + int _dividerTop = 0; + +}; + +class ComposeAiContent final : public Ui::RpWidget { +public: + ComposeAiContent( + QWidget *parent, + not_null box, + ComposeAiBoxArgs args); + ~ComposeAiContent(); + + [[nodiscard]] bool hasResult() const; + [[nodiscard]] const TextWithEntities &result() const; + [[nodiscard]] const std::vector &stylesData() const; + void setReadyChangedCallback(Fn callback); + void setModeTabs(not_null tabs); + void setStyleTabs(not_null*> stylesWrap); + void start(); + +protected: + int resizeGetHeight(int newWidth) override; + +private: + void refreshLayout(); + void chooseLanguage(); + void copyResult(); + void setMode(ComposeAiMode mode); + void updateTitles(); + void updatePinnedTabs(anim::type animated); + void cancelRequest(); + void request(); + void applyResult(Api::ComposeWithAi::Result &&result); + void showError(const MTP::Error &error); + void notifyReadyChanged(); + + const not_null _box; + const not_null _session; + const TextWithEntities _original; + const LanguageId _detectedFrom; + LanguageId _to; + const std::vector _stylesData; + QPointer _tabs; + QPointer _styles; + QPointer> _stylesWrap; + const not_null _preview; + Fn _readyChanged; + ComposeAiMode _mode = ComposeAiMode::Translate; + int _styleIndex = 0; + bool _emojify = false; + bool _ready = false; + mtpRequestId _requestId = 0; + int _requestToken = 0; + TextWithEntities _result; + +}; + +// ComposeAiModeButton + +ComposeAiModeButton::ComposeAiModeButton( + QWidget *parent, + ComposeAiMode mode, + QString label) +: RippleButton(parent, st::aiComposeButtonRippleInactive) +, _mode(mode) +, _label(std::move(label)) { + setCursor(style::cur_pointer); +} + +void ComposeAiModeButton::setSelected(bool selected) { + if (_selected == selected) { + return; + } + _selected = selected; + update(); +} + +ComposeAiMode ComposeAiModeButton::mode() const { + return _mode; +} + +void ComposeAiModeButton::paintEvent(QPaintEvent *e) { + Painter p(this); + PainterHighQualityEnabler hq(p); + + const auto radius = ComposeAiPillRadius(height()); + if (_selected) { + p.setPen(Qt::NoPen); + p.setBrush(ComposeAiActiveBackgroundColor( + st::aiComposeTabButtonBgActive)); + p.drawRoundedRect( + rect(), + radius, + radius); + } + const auto ripple = ComposeAiRippleColor( + _selected + ? st::aiComposeButtonRippleActive + : st::aiComposeButtonRippleInactive, + _selected + ? st::aiComposeButtonRippleActiveOpacity + : st::aiComposeButtonRippleInactiveOpacity); + paintRipple(p, 0, 0, &ripple); + + const auto &icon = ModeIcon(_mode, _selected); + const auto iconLeft = (width() - icon.width()) / 2; + icon.paint(p, iconLeft, st::aiComposeTabIconTop, width()); + + p.setPen(_selected + ? st::aiComposeTabLabelFgActive + : st::aiComposeTabLabelFg); + p.setFont(st::aiComposeTabLabelFont); + p.drawText( + QRect( + 0, + st::aiComposeTabLabelTop, + width(), + height() - st::aiComposeTabLabelTop), + Qt::AlignHCenter | Qt::AlignTop, + _label); +} + +QImage ComposeAiModeButton::prepareRippleMask() const { + return Ui::RippleAnimation::MaskByDrawer(size(), false, [&](QPainter &p) { + p.setPen(Qt::NoPen); + p.setBrush(Qt::white); + const auto radius = ComposeAiPillRadius(height()); + p.drawRoundedRect( + rect(), + radius, + radius); + }); +} + +// ComposeAiModeTabs + +ComposeAiModeTabs::ComposeAiModeTabs(QWidget *parent) +: RpWidget(parent) +, _translate(Ui::CreateChild( + this, + ComposeAiMode::Translate, + tr::lng_ai_compose_tab_translate(tr::now))) +, _style(Ui::CreateChild( + this, + ComposeAiMode::Style, + tr::lng_ai_compose_tab_style(tr::now))) +, _fix(Ui::CreateChild( + this, + ComposeAiMode::Fix, + tr::lng_ai_compose_tab_fix(tr::now))) { + const auto bind = [=](not_null button) { + button->setClickedCallback([=] { + setActive(button->mode()); + if (_changed) { + _changed(button->mode()); + } + }); + }; + bind(_translate); + bind(_style); + bind(_fix); + setActive(ComposeAiMode::Translate); +} + +void ComposeAiModeTabs::setActive(ComposeAiMode mode) { + _active = mode; + _translate->setSelected(mode == ComposeAiMode::Translate); + _style->setSelected(mode == ComposeAiMode::Style); + _fix->setSelected(mode == ComposeAiMode::Fix); +} + +void ComposeAiModeTabs::setChangedCallback(Fn callback) { + _changed = std::move(callback); +} + +int ComposeAiModeTabs::resizeGetHeight(int newWidth) { + const auto padding = st::aiComposeTabsPadding; + const auto skip = st::aiComposeTabsSkip; + const auto innerWidth = newWidth - padding.left() - padding.right(); + const auto buttonWidth = (innerWidth - (2 * skip)) / 3; + const auto buttonHeight = st::aiComposeTabsHeight + - padding.top() + - padding.bottom(); + const auto top = padding.top(); + auto left = padding.left(); + for (const auto button : { _translate, _style, _fix }) { + button->setGeometry(left, top, buttonWidth, buttonHeight); + left += buttonWidth + skip; + } + return st::aiComposeTabsHeight; +} + +void ComposeAiModeTabs::paintEvent(QPaintEvent *e) { + Painter p(this); + PainterHighQualityEnabler hq(p); + p.setPen(Qt::NoPen); + p.setBrush(st::aiComposeTabsBg); + const auto radius = st::aiComposeTabsRadius; + p.drawRoundedRect( + rect(), + radius, + radius); +} + +// ComposeAiStyleButton + +ComposeAiStyleButton::ComposeAiStyleButton( + QWidget *parent, + Main::AppConfig::AiComposeStyle style) +: RippleButton(parent, st::aiComposeButtonRippleInactive) +, _style(std::move(style)) +, _label(ToneTitle(_style.key)) { + setCursor(style::cur_pointer); +} + +void ComposeAiStyleButton::setSelected(bool selected) { + if (_selected == selected) { + return; + } + _selected = selected; + update(); +} + +const Main::AppConfig::AiComposeStyle &ComposeAiStyleButton::style() const { + return _style; +} + +void ComposeAiStyleButton::paintEvent(QPaintEvent *e) { + Painter p(this); + PainterHighQualityEnabler hq(p); + + const auto radius = ComposeAiStyleRadius(); + if (_selected) { + p.setPen(Qt::NoPen); + p.setBrush(ComposeAiActiveBackgroundColor( + st::aiComposeStyleButtonBgActive)); + p.drawRoundedRect( + rect(), + radius, + radius); + } + const auto ripple = ComposeAiRippleColor( + _selected + ? st::aiComposeButtonRippleActive + : st::aiComposeButtonRippleInactive, + _selected + ? st::aiComposeButtonRippleActiveOpacity + : st::aiComposeButtonRippleInactiveOpacity); + paintRipple(p, 0, 0, &ripple); + + const auto emojiRect = QRect( + 0, + st::aiComposeStyleEmojiTop, + width(), + st::aiComposeStyleEmojiFont->height); + p.setPen(_selected + ? st::aiComposeStyleLabelFgActive + : st::aiComposeStyleLabelFg); + p.setFont(st::aiComposeStyleEmojiFont); + p.drawText(emojiRect, Qt::AlignHCenter | Qt::AlignTop, _style.emoji); + + p.setPen(_selected + ? st::aiComposeStyleLabelFgActive + : st::aiComposeStyleLabelFg); + p.setFont(st::aiComposeStyleLabelFont); + p.drawText( + QRect( + 0, + st::aiComposeStyleLabelTop, + width(), + height() - st::aiComposeStyleLabelTop), + Qt::AlignHCenter | Qt::AlignTop, + _label); +} + +QImage ComposeAiStyleButton::prepareRippleMask() const { + return Ui::RippleAnimation::MaskByDrawer(size(), false, [&](QPainter &p) { + p.setPen(Qt::NoPen); + p.setBrush(Qt::white); + const auto radius = ComposeAiStyleRadius(); + p.drawRoundedRect( + rect(), + radius, + radius); + }); +} + +// ComposeAiStyleTabs + +ComposeAiStyleTabs::ComposeAiStyleTabs( + QWidget *parent, + std::vector styles) +: RpWidget(parent) { + _buttons.reserve(styles.size()); + for (auto &style : styles) { + const auto button = Ui::CreateChild( + this, + std::move(style)); + button->setClickedCallback([=] { + const auto index = _buttons.indexOf(button); + if (index < 0) { + return; + } + setActive(index); + if (_changed) { + _changed(index); + } + }); + _buttons.push_back(button); + } + setActive(0); +} + +void ComposeAiStyleTabs::setChangedCallback(Fn callback) { + _changed = std::move(callback); +} + +void ComposeAiStyleTabs::setActive(int index) { + if (index < 0 || index >= _buttons.size()) { + return; + } + _active = index; + for (auto i = 0; i != _buttons.size(); ++i) { + _buttons[i]->setSelected(i == index); + } +} + +QString ComposeAiStyleTabs::currentTone() const { + return (_active >= 0 && _active < _buttons.size()) + ? _buttons[_active]->style().key + : QString(); +} + +int ComposeAiStyleTabs::resizeGetHeight(int newWidth) { + const auto padding = st::aiComposeStyleTabsPadding; + const auto skip = st::aiComposeStyleTabsSkip; + const auto count = std::max(_buttons.size(), 1); + const auto innerWidth = newWidth - padding.left() - padding.right(); + const auto width = (innerWidth - ((count - 1) * skip)) / count; + const auto height = st::aiComposeStyleTabsHeight + - padding.top() + - padding.bottom(); + auto left = padding.left(); + for (const auto button : _buttons) { + button->setGeometry(left, padding.top(), width, height); + left += width + skip; + } + return st::aiComposeStyleTabsHeight; +} + +void ComposeAiStyleTabs::paintEvent(QPaintEvent *e) { + Painter p(this); + PainterHighQualityEnabler hq(p); + p.setPen(Qt::NoPen); + p.setBrush(st::aiComposeStyleTabsBg); + const auto radius = ComposeAiStyleRadius(); + p.drawRoundedRect( + rect(), + radius, + radius); +} + +// ComposeAiPreviewCard + +ComposeAiPreviewCard::ComposeAiPreviewCard( + QWidget *parent, + not_null session, + TextWithEntities original) +: RpWidget(parent) +, _context(Core::TextContext({ .session = session })) +, _original(std::move(original)) +, _originalTitle(Ui::CreateChild( + this, + st::aiComposeCardTitle)) +, _originalBody(Ui::CreateChild( + this, + st::aboutLabel)) +, _originalToggle(Ui::CreateChild( + this, + st::aiComposeExpandButton)) +, _resultTitle(Ui::CreateChild( + this, + st::aiComposeCardTitle)) +, _resultBody(Ui::CreateChild( + this, + st::aboutLabel)) +, _copy(Ui::CreateChild( + this, + st::aiComposeCopyButton)) +, _emojify( + Ui::CreateChild( + this, + tr::lng_ai_compose_emojify(tr::now), + st::aiComposeEmojifyCheckbox, + std::make_unique(st::defaultCheck,false))) { + _originalBody->setSelectable(true); + _originalBody->setMarkedText(_original, _context); + _resultTitle->setClickHandlerFilter([=](const auto &...) { + if (_chooseCallback) { + _chooseCallback(); + } + return false; + }); + _resultBody->setSelectable(true); + _originalToggle->setClickedCallback([=] { + _originalExpanded = !_originalExpanded; + updateOriginalToggleIcon(); + if (_resized) { + _resized(); + } + }); + _copy->setClickedCallback([=] { + if (_copyCallback) { + _copyCallback(); + } + }); + _emojify->checkedChanges( + ) | rpl::on_next([=](bool checked) { + if (_emojifyChanged) { + _emojifyChanged(checked); + } + }, _emojify->lifetime()); + setOriginalTitle(tr::lng_ai_compose_original(tr::now)); + setResultTitle(tr::lng_ai_compose_result(tr::now, tr::marked)); + setLoading(true); + updateOriginalToggleIcon(); +} + +void ComposeAiPreviewCard::setResizeCallback(Fn callback) { + _resized = std::move(callback); +} + +void ComposeAiPreviewCard::setChooseCallback(Fn callback) { + _chooseCallback = std::move(callback); +} + +void ComposeAiPreviewCard::setCopyCallback(Fn callback) { + _copyCallback = std::move(callback); +} + +void ComposeAiPreviewCard::setEmojifyChangedCallback(Fn callback) { + _emojifyChanged = std::move(callback); +} + +void ComposeAiPreviewCard::setOriginalTitle(const QString &title) { + _originalTitle->setText(title); + refreshGeometry(); +} + +void ComposeAiPreviewCard::setOriginalVisible(bool visible) { + if (_originalVisible == visible) { + return; + } + _originalVisible = visible; + _originalTitle->setVisible(visible); + _originalBody->setVisible(visible); + _originalToggle->setVisible(false); + refreshGeometry(); +} + +void ComposeAiPreviewCard::setResultTitle(const TextWithEntities &title) { + _resultTitle->setMarkedText(title); + refreshGeometry(); +} + +void ComposeAiPreviewCard::setEmojifyVisible(bool visible) { + _emojifyVisible = visible; + _emojify->setVisible(visible); + refreshGeometry(); +} + +void ComposeAiPreviewCard::setEmojifyChecked(bool checked) { + _emojify->setChecked(checked, Ui::Checkbox::NotifyAboutChange::DontNotify); + refreshGeometry(); +} + +void ComposeAiPreviewCard::setLoading(bool loading) { + _loading = loading; + if (loading && !_hasShownResult) { + _resultBody->setMarkedText( + Ui::Text::Italic(tr::lng_ai_compose_loading(tr::now)), + _context); + } + _copy->setVisible(!loading && _copyVisible); + refreshGeometry(); +} + +void ComposeAiPreviewCard::setResult(TextWithEntities text, bool copyVisible) { + _loading = false; + _hasShownResult = true; + _copyVisible = copyVisible; + _resultBody->setMarkedText(text, _context); + _copy->setVisible(_copyVisible); + refreshGeometry(); +} + +int ComposeAiPreviewCard::resizeGetHeight(int newWidth) { + const auto padding = st::aiComposeCardPadding; + const auto contentWidth = newWidth - padding.left() - padding.right(); + auto y = padding.top(); + + _dividerVisible = false; + if (_originalVisible) { + _originalTitle->show(); + _originalBody->show(); + _originalTitle->resizeToWidth(contentWidth); + _originalToggle->setVisible(false); + _originalToggle->moveToRight( + padding.right(), + (y + (_originalTitle->height() - _originalToggle->height()) / 2), + newWidth); + const auto originalTitleWidth = contentWidth + - _originalToggle->width() + - st::aiComposeCardControlSkip; + _originalTitle->setGeometryToLeft( + padding.left(), + y, + std::max(originalTitleWidth, 0), + _originalTitle->height(), + newWidth); + y += std::max(_originalTitle->height(), _originalToggle->height()); + y += st::aiComposeCardTextSkip; + + _originalBody->resizeToWidth(contentWidth); + const auto lineHeight = _originalBody->st().style.lineHeight; + const auto fullOriginalHeight = _originalBody->height(); + const auto originalHeight = _originalExpanded + ? fullOriginalHeight + : std::min(fullOriginalHeight, lineHeight); + _originalBody->setGeometryToLeft( + padding.left(), + y, + contentWidth, + originalHeight, + newWidth); + const auto expandable = fullOriginalHeight > lineHeight; + _originalToggle->setVisible(expandable); + y += originalHeight + st::aiComposeCardSectionSkip; + _dividerTop = y; + _dividerVisible = true; + y += st::lineWidth + st::aiComposeCardSectionSkip; + } else { + _originalTitle->hide(); + _originalBody->hide(); + _originalToggle->hide(); + } + + auto controlsWidth = 0; + if (_emojifyVisible) { + _emojify->show(); + _emojify->resizeToNaturalWidth(contentWidth); + controlsWidth += _emojify->width() + st::aiComposeCardControlSkip; + } else { + _emojify->hide(); + } + const auto resultTitleWidth = std::max(contentWidth - controlsWidth, 0); + _resultTitle->resizeToWidth(resultTitleWidth); + auto right = padding.right(); + if (_emojifyVisible) { + _emojify->moveToRight(right, y, newWidth); + right += _emojify->width() + st::aiComposeCardControlSkip; + } + _resultTitle->setGeometryToLeft( + padding.left(), + y, + resultTitleWidth, + _resultTitle->height(), + newWidth); + y += std::max({ + _resultTitle->height(), + _emojifyVisible ? _emojify->height() : 0, + }); + y += st::aiComposeCardTextSkip; + + _resultBody->resizeToWidth(contentWidth); + _resultBody->setGeometryToLeft( + padding.left(), + y, + contentWidth, + _resultBody->height(), + newWidth); + y += _resultBody->height(); + + if (_copy->isVisible()) { + y += st::aiComposeCardCopySkip; + _copy->moveToRight(padding.right(), y, newWidth); + y += _copy->height(); + } + + return y + padding.bottom(); +} + +void ComposeAiPreviewCard::paintEvent(QPaintEvent *e) { + Painter p(this); + PainterHighQualityEnabler hq(p); + p.setPen(Qt::NoPen); + p.setBrush(st::aiComposeCardBg); + p.drawRoundedRect( + rect(), + st::aiComposeCardRadius, + st::aiComposeCardRadius); + if (_dividerVisible) { + p.setBrush(Qt::NoBrush); + p.setPen(st::aiComposeCardDivider); + p.drawLine( + st::aiComposeCardPadding.left(), + _dividerTop, + width() - st::aiComposeCardPadding.right(), + _dividerTop); + } +} + +void ComposeAiPreviewCard::refreshGeometry() { + if (width() > 0) { + resizeToWidth(width()); + } + if (_resized) { + _resized(); + } +} + +void ComposeAiPreviewCard::updateOriginalToggleIcon() { + _originalToggle->setIconOverride( + _originalExpanded ? &st::aiComposeCollapseIcon : nullptr, + _originalExpanded ? &st::aiComposeCollapseIcon : nullptr); +} + +// ComposeAiContent + +ComposeAiContent::ComposeAiContent( + QWidget *parent, + not_null box, + ComposeAiBoxArgs args) +: RpWidget(parent) +, _box(box) +, _session(args.session) +, _original(std::move(args.text)) +, _detectedFrom(Platform::Language::Recognize(_original.text)) +, _to(DefaultAiTranslateTo(_detectedFrom)) +, _stylesData(_session->appConfig().aiComposeStyles()) +, _preview(Ui::CreateChild(this, _session, _original)) { + _preview->setResizeCallback([=] { refreshLayout(); }); + _preview->setChooseCallback([=] { chooseLanguage(); }); + _preview->setCopyCallback([=] { copyResult(); }); + _preview->setEmojifyChangedCallback([=](bool checked) { + _emojify = checked; + if (_mode != ComposeAiMode::Fix) { + request(); + } + }); +} + +ComposeAiContent::~ComposeAiContent() { + cancelRequest(); +} + +bool ComposeAiContent::hasResult() const { + return _ready; +} + +const TextWithEntities &ComposeAiContent::result() const { + return _result; +} + +const std::vector &ComposeAiContent::stylesData() const { + return _stylesData; +} + +void ComposeAiContent::setReadyChangedCallback(Fn callback) { + _readyChanged = std::move(callback); +} + +void ComposeAiContent::setModeTabs(not_null tabs) { + _tabs = tabs; + _tabs->setChangedCallback([=](ComposeAiMode mode) { + setMode(mode); + }); + _tabs->setActive(_mode); +} + +void ComposeAiContent::setStyleTabs( + not_null*> stylesWrap) { + _stylesWrap = stylesWrap; + _stylesWrap->setDuration(0); + _styles = stylesWrap->entity(); + _styles->setChangedCallback([=](int index) { + if (index >= 0 && index < _stylesData.size()) { + _styleIndex = index; + if (_mode == ComposeAiMode::Style) { + request(); + } + } + }); + _styles->setActive(_styleIndex); + _stylesWrap->toggle(_mode == ComposeAiMode::Style, anim::type::instant); +} + +void ComposeAiContent::start() { + updatePinnedTabs(anim::type::instant); + updateTitles(); + request(); +} + +int ComposeAiContent::resizeGetHeight(int newWidth) { + _preview->resizeToWidth(newWidth); + _preview->moveToLeft(0, 0, newWidth); + return _preview->height(); +} + +void ComposeAiContent::refreshLayout() { + if (width() > 0) { + resizeToWidth(width()); + } +} + +void ComposeAiContent::chooseLanguage() { + if (_mode != ComposeAiMode::Translate) { + return; + } + const auto weak = QPointer(this); + _box->uiShow()->showBox(Box([=](not_null box) { + Ui::ChooseLanguageBox( + box, + tr::lng_languages(), + [=](std::vector ids) { + if (!weak || ids.empty()) { + return; + } + weak->_to = ids.front(); + weak->updateTitles(); + weak->request(); + }, + { _to }, + false, + nullptr); + })); +} + +void ComposeAiContent::copyResult() { + if (!_ready) { + return; + } + TextUtilities::SetClipboardText( + TextForMimeData::WithExpandedLinks(_result)); +} + +void ComposeAiContent::setMode(ComposeAiMode mode) { + if (_mode == mode) { + return; + } + _mode = mode; + updatePinnedTabs(anim::type::normal); + updateTitles(); + refreshLayout(); + request(); +} + +void ComposeAiContent::updateTitles() { + _preview->setOriginalVisible(_mode != ComposeAiMode::Style); + _preview->setOriginalTitle( + (_mode == ComposeAiMode::Translate) + ? FromTitle(_detectedFrom) + : tr::lng_ai_compose_original(tr::now)); + _preview->setResultTitle( + (_mode == ComposeAiMode::Translate) + ? ToTitle(_to) + : tr::lng_ai_compose_result(tr::now, tr::marked)); + _preview->setEmojifyVisible(_mode != ComposeAiMode::Fix); + _preview->setEmojifyChecked(_emojify); +} + +void ComposeAiContent::updatePinnedTabs(anim::type animated) { + if (_tabs) { + _tabs->setActive(_mode); + } + if (_styles) { + _styles->setActive(_styleIndex); + } + if (_stylesWrap) { + _stylesWrap->toggle(_mode == ComposeAiMode::Style, animated); + } +} + +void ComposeAiContent::cancelRequest() { + ++_requestToken; + if (_requestId) { + _session->api().composeWithAi().cancel(_requestId); + _requestId = 0; + } +} + +void ComposeAiContent::request() { + cancelRequest(); + _ready = false; + _result = {}; + _preview->setLoading(true); + notifyReadyChanged(); + + auto request = Api::ComposeWithAi::Request{ + .text = _original, + .emojify = (_mode != ComposeAiMode::Fix) && _emojify, + }; + switch (_mode) { + case ComposeAiMode::Translate: + request.translateToLang = _to.twoLetterCode(); + break; + case ComposeAiMode::Style: + request.changeTone = _stylesData[_styleIndex].key; + break; + case ComposeAiMode::Fix: + request.proofread = true; + break; + } + + const auto token = ++_requestToken; + const auto weak = QPointer(this); + _requestId = _session->api().composeWithAi().request( + std::move(request), + [=](Api::ComposeWithAi::Result &&result) { + if (!weak || weak->_requestToken != token) { + return; + } + weak->_requestId = 0; + weak->applyResult(std::move(result)); + }, + [=](const MTP::Error &error) { + if (!weak || weak->_requestToken != token) { + return; + } + weak->_requestId = 0; + weak->showError(error); + }); +} + +void ComposeAiContent::applyResult(Api::ComposeWithAi::Result &&result) { + _result = std::move(result.resultText); + auto display = (_mode == ComposeAiMode::Fix && result.diffText) + ? BuildDiffDisplay(*result.diffText) + : _result; + _preview->setResult(std::move(display), true); + _ready = !_result.text.isEmpty(); + notifyReadyChanged(); + refreshLayout(); +} + +void ComposeAiContent::showError(const MTP::Error &error) { + _preview->setResult( + Ui::Text::Italic(tr::lng_ai_compose_error(tr::now)), + false); + _ready = false; + notifyReadyChanged(); + refreshLayout(); + _box->showToast(error.type().isEmpty() + ? tr::lng_ai_compose_error(tr::now) + : error.type()); +} + +void ComposeAiContent::notifyReadyChanged() { + if (_readyChanged) { + _readyChanged(_ready); + } +} + +} // namespace + +void ComposeAiBox(not_null box, ComposeAiBoxArgs &&args) { + box->setStyle(st::aiComposeBoxWithSend); + box->setNoContentMargin(true); + box->setWidth(st::boxWideWidth); + box->setTitle(tr::lng_ai_compose_title()); + box->addTopButton(st::boxTitleClose, [=] { + box->closeBox(); + }); + + const auto body = box->verticalLayout(); + Ui::AddSkip(body, st::aiComposeBoxSectionSkip); + const auto pinnedToTop = box->setPinnedToTopContent( + object_ptr(box)); + const auto tabs = pinnedToTop->add( + object_ptr(pinnedToTop), + st::aiComposeContentMargin); + const auto content = body->add( + object_ptr(box, box, args), + st::aiComposeContentMargin); + const auto stylesWrap = pinnedToTop->add( + object_ptr>( + pinnedToTop, + object_ptr( + pinnedToTop, + content->stylesData()), + style::margins(0, st::aiComposeBoxStyleTabsSkip, 0, 0)), + st::aiComposeContentMargin); + stylesWrap->hide(anim::type::instant); + content->setModeTabs(tabs); + content->setStyleTabs(stylesWrap); + const auto applyAndClose = [=] { + if (!content->hasResult()) { + return; + } + args.apply(TextWithEntities(content->result())); + box->closeBox(); + }; + const auto apply = box->addButton( + tr::lng_ai_compose_apply(), + applyAndClose); + apply->setDisabled(!content->hasResult()); + apply->setFullRadius(true); + + const auto send = Ui::CreateChild( + apply->parentWidget(), + st::aiComposeSendButton); + send->setState({ .type = Ui::SendButton::Type::Send }); + send->show(); + send->setAttribute(Qt::WA_TransparentForMouseEvents, !content->hasResult()); + + apply->geometryValue( + ) | rpl::on_next([=](QRect geometry) { + const auto size = st::aiComposeBoxWithSend.buttonHeight; + send->resize(size, size); + send->moveToLeft( + geometry.x() + geometry.width() + st::aiComposeSendButtonSkip, + geometry.y() + (geometry.height() - size) / 2); + }, send->lifetime()); + + send->setClickedCallback(applyAndClose); + + content->setReadyChangedCallback([=](bool ready) { + if (apply) { + apply->setDisabled(!ready); + } + send->setAttribute(Qt::WA_TransparentForMouseEvents, !ready); + }); + content->start(); +} + +void ShowComposeAiBox( + std::shared_ptr show, + ComposeAiBoxArgs &&args) { + show->show(Box(ComposeAiBox, std::move(args))); +} + +} // namespace HistoryView::Controls diff --git a/Telegram/SourceFiles/boxes/compose_ai_box.h b/Telegram/SourceFiles/boxes/compose_ai_box.h new file mode 100644 index 0000000000..91e10b4735 --- /dev/null +++ b/Telegram/SourceFiles/boxes/compose_ai_box.h @@ -0,0 +1,33 @@ +/* +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/object_ptr.h" +#include "ui/text/text_entity.h" + +namespace Main { +class Session; +} // namespace Main + +namespace Ui { +class GenericBox; +class Show; +} // namespace Ui + +namespace HistoryView::Controls { + +struct ComposeAiBoxArgs { + not_null session; + TextWithEntities text; + Fn apply; +}; + +void ComposeAiBox(not_null box, ComposeAiBoxArgs &&args); +void ShowComposeAiBox(std::shared_ptr show, ComposeAiBoxArgs &&args); + +} // namespace HistoryView::Controls diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index 48acb15505..b0eb4c9420 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -1420,6 +1420,29 @@ historySend: SendButton { sendDisabledFg: historyComposeIconFg; sendIconFg: historySendIconFg; } +historyAiComposeButton: IconButton(historyAttach) { + width: 34px; + height: 34px; + icon: historySendIcon; + iconOver: historySendIconOver; + iconPosition: point(10px, 10px); + rippleAreaPosition: point(0px, 0px); + rippleAreaSize: 34px; + ripple: defaultRippleAnimationBgOver; +} +historyAiComposeButtonBg: transparent; +historyAiComposeButtonBgOver: transparent; +historyAiComposeButtonTextFg: historyComposeIconFg; +historyAiComposeButtonTextFgOver: historyComposeIconFgOver; +historyAiComposeButtonFont: font(15px semibold); +historyAiComposeButtonTextShift: point(1px, 1px); +historyAiComposeButtonSparkleBigPosition: point(10px, 12px); +historyAiComposeButtonSparkleBigRadius: 3px; +historyAiComposeButtonSparkleSmallPosition: point(16px, 8px); +historyAiComposeButtonSparkleSmallRadius: 2px; +historyAiComposeButtonSparkleStroke: 1px; +historyAiComposeButtonTop: -4px; +historyAiComposeTooltipSkip: 8px; historyRecordFrameIndex: 30; defaultComposeFilesMenu: IconButton(defaultIconButton) { @@ -1768,3 +1791,17 @@ topPeersSelectorImportantTooltipLabel: FlatLabel(defaultImportantTooltipLabel) { font: font(semibold 12px); } } + +aiComposeSendButton: SendButton(historySend) { + sendIconPosition: point(9px, 9px); + sendIconFillPadding: 5px; + inner: IconButton(historyAttach) { + width: 42px; + height: 42px; + icon: icon {{ "chat/input_send_round", windowFgActive }}; + iconOver: icon {{ "chat/input_send_round", windowFgActive }}; + rippleAreaSize: 40px; + rippleAreaPosition: point(1px, 1px); + } + sendIconFg: windowFgActive; +} diff --git a/Telegram/SourceFiles/core/core_settings.cpp b/Telegram/SourceFiles/core/core_settings.cpp index 593271e4bf..d6add5a014 100644 --- a/Telegram/SourceFiles/core/core_settings.cpp +++ b/Telegram/SourceFiles/core/core_settings.cpp @@ -245,7 +245,7 @@ QByteArray Settings::serialize() const { for (const auto &id : _recentEmojiSkip) { size += Serialize::stringSize(id); } - size += sizeof(qint32) * 2 + size += sizeof(qint32) * 3 + Serialize::stringSize(_playbackDeviceId.current()) + Serialize::stringSize(_captureDeviceId.current()) + Serialize::stringSize(_callPlaybackDeviceId.current()) @@ -400,6 +400,7 @@ QByteArray Settings::serialize() const { stream << qint32(_trayIconMonochrome.current() ? 1 : 0) << qint32(_ttlVoiceClickTooltipHidden.current() ? 1 : 0) + << qint32(_aiComposeTooltipHidden.current() ? 1 : 0) << _playbackDeviceId.current() << _captureDeviceId.current() << _callPlaybackDeviceId.current() @@ -549,6 +550,7 @@ void Settings::addFromSerialized(const QByteArray &serialized) { base::flat_set recentEmojiSkip; qint32 trayIconMonochrome = (_trayIconMonochrome.current() ? 1 : 0); qint32 ttlVoiceClickTooltipHidden = _ttlVoiceClickTooltipHidden.current() ? 1 : 0; + qint32 aiComposeTooltipHidden = _aiComposeTooltipHidden.current() ? 1 : 0; QByteArray ivPosition; QByteArray callPanelPosition; QString customFontFamily = _customFontFamily; @@ -828,6 +830,9 @@ void Settings::addFromSerialized(const QByteArray &serialized) { if (!stream.atEnd()) { stream >> ttlVoiceClickTooltipHidden; } + if (!stream.atEnd()) { + stream >> aiComposeTooltipHidden; + } if (!stream.atEnd()) { stream >> playbackDeviceId @@ -1131,6 +1136,7 @@ void Settings::addFromSerialized(const QByteArray &serialized) { _recentEmojiSkip = std::move(recentEmojiSkip); _trayIconMonochrome = (trayIconMonochrome == 1); _ttlVoiceClickTooltipHidden = (ttlVoiceClickTooltipHidden == 1); + _aiComposeTooltipHidden = (aiComposeTooltipHidden == 1); if (!ivPosition.isEmpty()) { _ivPosition = Deserialize(ivPosition); } @@ -1538,6 +1544,7 @@ void Settings::resetOnLastLogout() { _hiddenGroupCallTooltips = 0; _storiesClickTooltipHidden = false; _ttlVoiceClickTooltipHidden = false; + _aiComposeTooltipHidden = false; _ivZoom = 0; _recordVideoMessages = false; _videoQuality = {}; diff --git a/Telegram/SourceFiles/core/core_settings.h b/Telegram/SourceFiles/core/core_settings.h index 83bd41c850..2ffa8f5eb7 100644 --- a/Telegram/SourceFiles/core/core_settings.h +++ b/Telegram/SourceFiles/core/core_settings.h @@ -924,6 +924,15 @@ public: void setTtlVoiceClickTooltipHidden(bool value) { _ttlVoiceClickTooltipHidden = value; } + [[nodiscard]] bool aiComposeTooltipHidden() const { + return _aiComposeTooltipHidden.current(); + } + [[nodiscard]] rpl::producer aiComposeTooltipHiddenValue() const { + return _aiComposeTooltipHidden.value(); + } + void setAiComposeTooltipHidden(bool value) { + _aiComposeTooltipHidden = value; + } [[nodiscard]] const WindowPosition &ivPosition() const { return _ivPosition; @@ -1125,6 +1134,7 @@ private: std::optional _macRoundIconDigest; rpl::variable _storiesClickTooltipHidden = false; rpl::variable _ttlVoiceClickTooltipHidden = false; + rpl::variable _aiComposeTooltipHidden = false; WindowPosition _ivPosition; WindowPosition _callPanelPosition; QString _customFontFamily; diff --git a/Telegram/SourceFiles/history/history_widget.cpp b/Telegram/SourceFiles/history/history_widget.cpp index e52226ce51..2c310f3041 100644 --- a/Telegram/SourceFiles/history/history_widget.cpp +++ b/Telegram/SourceFiles/history/history_widget.cpp @@ -16,6 +16,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_send_progress.h" #include "api/api_unread_things.h" #include "base/random.h" +#include "boxes/compose_ai_box.h" #include "ui/boxes/confirm_box.h" #include "boxes/delete_messages_box.h" #include "boxes/send_credits_box.h" @@ -29,6 +30,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/star_gift_box.h" #include "boxes/peers/edit_peer_permissions_box.h" // ShowAboutGigagroup. #include "boxes/peers/edit_peer_requests_box.h" +#include "core/core_settings.h" #include "core/file_utilities.h" #include "core/mime_type.h" #include "history/view/history_view_draw_to_reply.h" @@ -43,6 +45,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/effects/ripple_animation.h" #include "ui/effects/message_sending_animation_controller.h" #include "ui/text/format_values.h" +#include "ui/text/text_utilities.h" #include "ui/chat/message_bar.h" #include "ui/chat/attach/attach_send_files_way.h" #include "ui/chat/choose_send_as.h" @@ -99,6 +102,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history_unread_things.h" #include "history/admin_log/history_admin_log_section.h" #include "history/view/controls/history_view_characters_limit.h" +#include "history/view/controls/history_view_compose_ai_button.h" #include "history/view/controls/history_view_compose_search.h" #include "history/view/controls/history_view_forward_panel.h" #include "history/view/controls/history_view_draft_options.h" @@ -161,6 +165,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/checkbox.h" #include "ui/widgets/elastic_scroll.h" #include "ui/widgets/popup_menu.h" +#include "ui/widgets/tooltip.h" #include "ui/item_text_options.h" #include "main/main_session.h" #include "main/main_session_settings.h" @@ -260,6 +265,9 @@ HistoryWidget::HistoryWidget( ? object_ptr(this, &session()) : nullptr) , _send(std::make_shared(this, st::historySend)) +, _aiButton(Ui::CreateChild( + this, + st::historyAiComposeButton)) , _unblock( this, tr::lng_unblock_button(tr::now).toUpper(), @@ -496,6 +504,7 @@ HistoryWidget::HistoryWidget( InitMessageFieldFade(_field, st::historyComposeField.textBg); setupFastButtonMode(); + initAiButton(); _fieldCharsCountManager.limitExceeds( ) | rpl::on_next([=] { @@ -1277,11 +1286,44 @@ void HistoryWidget::initVoiceRecordBar() { ) | rpl::on_next([=](bool active) { _field->setDisabled(active); controller()->widget()->setInnerFocus(); + updateAiButtonVisibility(); }, lifetime()); _voiceRecordBar->hideFast(); } +void HistoryWidget::initAiButton() { + _aiButton->hide(); + _aiButton->setAccessibleName(tr::lng_ai_compose_title(tr::now)); + _aiButton->setClickedCallback([=] { + if (!Core::App().settings().aiComposeTooltipHidden()) { + Core::App().settings().setAiComposeTooltipHidden(true); + Core::App().saveSettingsDelayed(); + } + updateAiButtonVisibility(); + showAiComposeBox(); + }); + + _aiTooltip.reset(Ui::CreateChild( + this, + object_ptr>( + this, + Ui::MakeNiceTooltipLabel( + this, + tr::lng_ai_compose_tooltip(tr::rich), + st::historyMessagesTTLLabel.minWidth, + st::ttlMediaImportantTooltipLabel), + st::defaultImportantTooltip.padding), + st::historyRecordTooltip)); + _aiTooltip->toggleFast(false); + _aiButton->geometryValue( + ) | rpl::on_next([=](const QRect &geometry) { + if (!geometry.isEmpty()) { + updateAiTooltipGeometry(); + } + }, _aiTooltip->lifetime()); +} + void HistoryWidget::initTabbedSelector() { refreshTabbedPanel(); @@ -1808,6 +1850,7 @@ void HistoryWidget::applyInlineBotQuery(UserData *bot, const QString &query) { void HistoryWidget::orderWidgets() { _voiceRecordBar->raise(); _send->raise(); + _aiButton->raise(); _topBars->raise(); if (_businessBotStatus) { _businessBotStatus->bar().raise(); @@ -1858,6 +1901,9 @@ void HistoryWidget::orderWidgets() { if (_attachBotsMenu) { _attachBotsMenu->raise(); } + if (_aiTooltip) { + _aiTooltip->raise(); + } _attachDragAreas.document->raise(); _attachDragAreas.photo->raise(); } @@ -1931,6 +1977,7 @@ void HistoryWidget::fieldChanged() { updateControlsVisibility(); updateControlsGeometry(); } + updateAiButtonVisibility(); _saveCloudDraftTimer.cancel(); if (!_peer || !(_textUpdateEvents & TextUpdateEvent::SaveDraft)) { @@ -3721,6 +3768,7 @@ void HistoryWidget::updateControlsVisibility() { hideFieldIfVisible(); } //checkTabbedSelectorToggleTooltip(); + updateAiButtonVisibility(); updateMouseTracking(); } @@ -4593,6 +4641,23 @@ TextWithEntities HistoryWidget::prepareTextForEditMsg() const { return left; } +void HistoryWidget::showAiComposeBox() { + const auto text = prepareTextForEditMsg(); + if (text.text.isEmpty()) { + return; + } + HistoryView::Controls::ShowComposeAiBox(controller()->uiShow(), { + .session = &session(), + .text = text, + .apply = crl::guard(this, [=](TextWithEntities &&result) { + setFieldText({ + result.text, + TextUtilities::ConvertEntitiesToTextTags(result.entities), + }, 0, Ui::InputField::HistoryAction::NewEntry); + }), + }); +} + void HistoryWidget::saveEditMessage(Api::SendOptions options) { Expects(_history != nullptr); @@ -5975,6 +6040,7 @@ void HistoryWidget::toggleKeyboard(bool manual) { } } updateControlsGeometry(); + updateAiButtonVisibility(); updateFieldPlaceholder(); if (_botKeyboardHide->isHidden() && canWriteMessage() @@ -6118,6 +6184,69 @@ bool HistoryWidget::fieldOrDisabledShown() const { return !_field->isHidden() || _fieldDisabled; } +bool HistoryWidget::hasEnoughLinesForAi() const { + if (!_history || _voiceRecordBar->isActive()) { + return false; + } + const auto &style = _field->st().style; + const auto lineHeight = style.lineHeight + ? style.lineHeight + : style.font->height; + const auto margins = _field->fullTextMargins(); + const auto contentHeight = _field->height() + - margins.top() + - margins.bottom(); + return contentHeight >= (3 * lineHeight); +} + +void HistoryWidget::updateAiButtonVisibility() { + const auto shown = hasEnoughLinesForAi() + && _send->isVisible() + && _field->isVisible(); + _aiButton->setVisible(shown); + if (_aiTooltip) { + if (shown) { + updateAiButtonGeometry(); + } + const auto showTooltip = shown + && !Core::App().settings().aiComposeTooltipHidden(); + if (showTooltip) { + updateAiTooltipGeometry(); + } + if ((showTooltip != _aiTooltipShown) + || (showTooltip && _aiTooltip->isHidden())) { + _aiTooltipShown = showTooltip; + _aiTooltip->toggleAnimated(showTooltip); + } + } +} + +void HistoryWidget::updateAiButtonGeometry() { + if (_aiButton->isHidden()) { + return; + } + _aiButton->move( + _send->x() + _send->width() - _aiButton->width(), + _field->y() + st::historyAiComposeButtonTop); + updateAiTooltipGeometry(); +} + +void HistoryWidget::updateAiTooltipGeometry() { + if (!_aiTooltip || _aiButton->isHidden()) { + return; + } + const auto geometry = _aiButton->geometry(); + const auto countPosition = [=](QSize size) { + const auto left = geometry.x() + + geometry.width() + - size.width(); + return QPoint( + std::clamp(left, 0, width() - size.width()), + geometry.y() - size.height() - st::historyAiComposeTooltipSkip); + }; + _aiTooltip->pointAt(geometry, RectPart::Top, countPosition); +} + void HistoryWidget::moveFieldControls() { auto keyboardHeight = 0; auto bottom = height(); @@ -6187,6 +6316,7 @@ void HistoryWidget::moveFieldControls() { if (_ttlInfo) { _ttlInfo->move(width() - right - _ttlInfo->width(), buttonsBottom); } + updateAiButtonGeometry(); _fieldBarCancel->moveToRight( 0, @@ -6290,6 +6420,7 @@ void HistoryWidget::inlineBotChanged() { void HistoryWidget::fieldResized() { moveFieldControls(); + updateAiButtonVisibility(); updateHistoryGeometry(); updateField(); } diff --git a/Telegram/SourceFiles/history/history_widget.h b/Telegram/SourceFiles/history/history_widget.h index 6eba009091..bc41793d96 100644 --- a/Telegram/SourceFiles/history/history_widget.h +++ b/Telegram/SourceFiles/history/history_widget.h @@ -62,6 +62,7 @@ namespace Ui { class InnerDropdown; class DropdownMenu; class PlainShadow; +class ImportantTooltip; class IconButton; class EmojiButton; class SendButton; @@ -124,6 +125,7 @@ class TTLButton; class WebpageProcessor; class CharactersLimitLabel; class PhotoEditSpoilerManager; +class ComposeAiButton; struct VoiceToSend; } // namespace HistoryView::Controls @@ -509,10 +511,16 @@ private: // like send button, emoji button and others. void moveFieldControls(); void updateFieldSize(); + void initAiButton(); + void updateAiButtonVisibility(); + void updateAiButtonGeometry(); + void updateAiTooltipGeometry(); + void showAiComposeBox(); [[nodiscard]] MsgId resolveReplyToTopicRootId(); [[nodiscard]] Data::ForumTopic *resolveReplyToTopic(); [[nodiscard]] bool canWriteMessage() const; + [[nodiscard]] bool hasEnoughLinesForAi() const; void orderWidgets(); [[nodiscard]] InlineBotQuery parseInlineBotQuery() const; @@ -812,6 +820,7 @@ private: std::unique_ptr _businessBotStatus; const std::shared_ptr _send; + HistoryView::Controls::ComposeAiButton * const _aiButton = nullptr; object_ptr _unblock; object_ptr _botStart; object_ptr _joinChannel; @@ -843,6 +852,8 @@ private: std::unique_ptr _subsectionTabs; rpl::lifetime _subsectionTabsLifetime; rpl::lifetime _subsectionCheckLifetime; + base::unique_qptr _aiTooltip; + bool _aiTooltipShown = false; bool _cmdStartShown = false; object_ptr _field; base::unique_qptr _fieldDisabled; diff --git a/Telegram/SourceFiles/history/view/controls/history_view_compose_ai_button.cpp b/Telegram/SourceFiles/history/view/controls/history_view_compose_ai_button.cpp new file mode 100644 index 0000000000..5e764f206e --- /dev/null +++ b/Telegram/SourceFiles/history/view/controls/history_view_compose_ai_button.cpp @@ -0,0 +1,97 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "history/view/controls/history_view_compose_ai_button.h" + +#include "ui/effects/ripple_animation.h" +#include "ui/painter.h" +#include "styles/style_chat_helpers.h" + +namespace HistoryView::Controls { +namespace { + +void PaintSparkle(QPainter &p, QPoint center, int radius) { + p.drawLine( + center.x() - radius, + center.y(), + center.x() + radius, + center.y()); + p.drawLine( + center.x(), + center.y() - radius, + center.x(), + center.y() + radius); +} + +} // namespace + +ComposeAiButton::ComposeAiButton( + QWidget *parent, + const style::IconButton &st) +: RippleButton(parent, st.ripple) +, _st(st) { + resize(_st.width, _st.height); + setCursor(style::cur_pointer); +} + +void ComposeAiButton::paintEvent(QPaintEvent *e) { + Painter p(this); + PainterHighQualityEnabler hq(p); + + const auto over = isDown() || isOver() || forceRippled(); + const auto bg = over + ? st::historyAiComposeButtonBgOver + : st::historyAiComposeButtonBg; + if (bg->c.alpha()) { + p.setPen(Qt::NoPen); + p.setBrush(bg); + p.drawEllipse(rect()); + } + paintRipple(p, _st.rippleAreaPosition); + + const auto fg = over + ? st::historyAiComposeButtonTextFgOver + : st::historyAiComposeButtonTextFg; + auto pen = QPen(fg); + pen.setWidth(st::historyAiComposeButtonSparkleStroke); + pen.setCapStyle(Qt::RoundCap); + p.setPen(pen); + PaintSparkle( + p, + st::historyAiComposeButtonSparkleBigPosition, + st::historyAiComposeButtonSparkleBigRadius); + PaintSparkle( + p, + st::historyAiComposeButtonSparkleSmallPosition, + st::historyAiComposeButtonSparkleSmallRadius); + + p.setFont(st::historyAiComposeButtonFont); + p.drawText( + rect().translated(st::historyAiComposeButtonTextShift), + Qt::AlignCenter, + u"Ai"_q); +} + +void ComposeAiButton::onStateChanged(State was, StateChangeSource source) { + RippleButton::onStateChanged(was, source); + update(); +} + +QImage ComposeAiButton::prepareRippleMask() const { + return Ui::RippleAnimation::EllipseMask( + QSize(_st.rippleAreaSize, _st.rippleAreaSize)); +} + +QPoint ComposeAiButton::prepareRippleStartPosition() const { + const auto result = mapFromGlobal(QCursor::pos()) - _st.rippleAreaPosition; + const auto rect = QRect(0, 0, _st.rippleAreaSize, _st.rippleAreaSize); + return rect.contains(result) + ? result + : DisabledRippleStartPosition(); +} + +} // namespace HistoryView::Controls diff --git a/Telegram/SourceFiles/history/view/controls/history_view_compose_ai_button.h b/Telegram/SourceFiles/history/view/controls/history_view_compose_ai_button.h new file mode 100644 index 0000000000..8fe0c299fb --- /dev/null +++ b/Telegram/SourceFiles/history/view/controls/history_view_compose_ai_button.h @@ -0,0 +1,30 @@ +/* +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/widgets/buttons.h" + +namespace HistoryView::Controls { + +class ComposeAiButton final : public Ui::RippleButton { +public: + ComposeAiButton(QWidget *parent, const style::IconButton &st); + +protected: + void paintEvent(QPaintEvent *e) override; + void onStateChanged(State was, StateChangeSource source) override; + + [[nodiscard]] QImage prepareRippleMask() const override; + [[nodiscard]] QPoint prepareRippleStartPosition() const override; + +private: + const style::IconButton &_st; + +}; + +} // namespace HistoryView::Controls diff --git a/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.cpp b/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.cpp index 15edeeddc2..5eeeda7735 100644 --- a/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.cpp +++ b/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.cpp @@ -14,6 +14,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/random.h" #include "base/timer_rpl.h" #include "base/unixtime.h" +#include "boxes/compose_ai_box.h" #include "boxes/edit_caption_box.h" #include "calls/group/ui/calls_group_stars_coloring.h" #include "calls/group/calls_group_stars_box.h" @@ -57,6 +58,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history.h" #include "history/history_item.h" #include "history/view/controls/history_view_characters_limit.h" +#include "history/view/controls/history_view_compose_ai_button.h" #include "history/view/controls/history_view_compose_media_edit_manager.h" #include "history/view/controls/history_view_forward_panel.h" #include "history/view/controls/history_view_draft_options.h" @@ -1007,6 +1009,9 @@ ComposeControls::ComposeControls( , _mode(descriptor.mode) , _wrap(std::make_unique(_parent)) , _send(std::make_shared(_wrap.get(), _st.send)) +, _aiButton(Ui::CreateChild( + _wrap.get(), + st::historyAiComposeButton)) , _like(_features.likes ? Ui::CreateChild(_wrap.get(), _st.like) : nullptr) @@ -1877,6 +1882,10 @@ void ComposeControls::showFinished() { _autocomplete->hideFast(); } updateWrappingVisibility(); + _aiButton->raise(); + if (_aiTooltip) { + _aiTooltip->raise(); + } _voiceRecordBar->orderControls(); } @@ -2007,6 +2016,7 @@ void ComposeControls::init() { initField(); initTabbedSelector(); initSendButton(); + initAiButton(); initWriteRestriction(); initVoiceRecordBar(); initKeyHandler(); @@ -2315,10 +2325,12 @@ void ComposeControls::initField() { _field->heightChanges( ) | rpl::on_next([=] { updateHeight(); + updateAiButtonVisibility(); }, _field->lifetime()); _field->changes( ) | rpl::on_next([=] { fieldChanged(); + updateAiButtonVisibility(); }, _field->lifetime()); #ifdef Q_OS_MAC // Removed an ability to insert text from the menu bar @@ -3171,6 +3183,7 @@ void ComposeControls::initVoiceRecordBar() { changeFocusedControl(); _recording = false; } + updateAiButtonVisibility(); }, _wrap->lifetime()); _voiceRecordBar->setStartRecordingFilter([=] { @@ -3255,6 +3268,38 @@ void ComposeControls::initVoiceRecordBar() { }, _voiceRecordBar->lifetime()); } +void ComposeControls::initAiButton() { + _aiButton->hide(); + _aiButton->setAccessibleName(tr::lng_ai_compose_title(tr::now)); + _aiButton->setClickedCallback([=] { + if (!Core::App().settings().aiComposeTooltipHidden()) { + Core::App().settings().setAiComposeTooltipHidden(true); + Core::App().saveSettingsDelayed(); + } + updateAiButtonVisibility(); + showAiComposeBox(); + }); + + _aiTooltip.reset(Ui::CreateChild( + _wrap.get(), + object_ptr>( + _wrap.get(), + Ui::MakeNiceTooltipLabel( + _wrap.get(), + tr::lng_ai_compose_tooltip(tr::rich), + st::historyMessagesTTLLabel.minWidth, + st::ttlMediaImportantTooltipLabel), + st::defaultImportantTooltip.padding), + st::historyRecordTooltip)); + _aiTooltip->toggleFast(false); + _aiButton->geometryValue( + ) | rpl::on_next([=](const QRect &geometry) { + if (!geometry.isEmpty()) { + updateAiTooltipGeometry(); + } + }, _aiTooltip->lifetime()); +} + void ComposeControls::updateWrappingVisibility() { const auto &restriction = _writeRestriction.current(); const auto restricted = !restriction.empty() && _writeRestricted; @@ -3266,6 +3311,7 @@ void ComposeControls::updateWrappingVisibility() { } _wrap->setVisible(!hidden && !restricted); updateControlsParents(); + updateAiButtonVisibility(); if (!hidden && !restricted) { updateControlsGeometry(_wrap->size()); _wrap->raise(); @@ -3456,6 +3502,7 @@ void ComposeControls::updateControlsGeometry(QSize size) { if (_ttlInfo) { _ttlInfo->move(size.width() - right - _ttlInfo->width(), buttonsTop); } + updateAiButtonGeometry(); _voiceRecordBar->resizeToWidth(size.width()); _voiceRecordBar->moveToLeft( @@ -3494,6 +3541,52 @@ void ComposeControls::updateControlsVisibility() { if (_starsReaction) { _starsReaction->show(); } + updateAiButtonVisibility(); +} + +void ComposeControls::updateAiButtonVisibility() { + const auto shown = hasEnoughLinesForAi() + && _wrap->isVisible() + && !_recording.current() + && _field->isVisible(); + _aiButton->setVisible(shown); + if (_aiTooltip) { + const auto showTooltip = shown + && !Core::App().settings().aiComposeTooltipHidden(); + if (showTooltip) { + updateAiTooltipGeometry(); + } + if (showTooltip != _aiTooltipShown) { + _aiTooltipShown = showTooltip; + _aiTooltip->toggleAnimated(showTooltip); + } + } +} + +void ComposeControls::updateAiButtonGeometry() { + if (_aiButton->isHidden()) { + return; + } + _aiButton->move( + _send->x() + (_send->width() - _aiButton->width()) / 2, + _field->y() + st::historyAiComposeButtonTop); + updateAiTooltipGeometry(); +} + +void ComposeControls::updateAiTooltipGeometry() { + if (!_aiTooltip || _aiButton->isHidden()) { + return; + } + const auto geometry = _aiButton->geometry(); + const auto countPosition = [=](QSize size) { + const auto left = geometry.x() + + geometry.width() + - size.width(); + return QPoint( + std::clamp(left, 0, _wrap->width() - size.width()), + geometry.y() - size.height() - st::historyAiComposeTooltipSkip); + }; + _aiTooltip->pointAt(geometry, RectPart::Top, countPosition); } bool ComposeControls::updateLikeShown() { @@ -3505,6 +3598,38 @@ bool ComposeControls::updateLikeShown() { return false; } +void ComposeControls::showAiComposeBox() { + const auto text = prepareTextForEditMsg(); + if (text.text.isEmpty()) { + return; + } + Controls::ShowComposeAiBox(_show, { + .session = _session, + .text = text, + .apply = crl::guard(_wrap.get(), [=](TextWithEntities &&result) { + setFieldText({ + result.text, + TextUtilities::ConvertEntitiesToTextTags(result.entities), + }, 0, Ui::InputField::HistoryAction::NewEntry); + }), + }); +} + +bool ComposeControls::hasEnoughLinesForAi() const { + if (!_history || _recording.current()) { + return false; + } + const auto &style = _field->st().style; + const auto lineHeight = style.lineHeight + ? style.lineHeight + : style.font->height; + const auto margins = _field->fullTextMargins(); + const auto contentHeight = _field->height() + - margins.top() + - margins.bottom(); + return contentHeight >= (3 * lineHeight); +} + bool ComposeControls::updateBotCommandShown() { auto shown = false; const auto peer = _history ? _history->peer.get() : nullptr; diff --git a/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.h b/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.h index 1160075d84..8ae0bce395 100644 --- a/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.h +++ b/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.h @@ -68,6 +68,7 @@ class EmojiButton; class SendAsButton; class SilentToggle; class DropdownMenu; +class ImportantTooltip; struct PreparedList; struct SendStarButtonState; class ReactionFlyAnimation; @@ -100,6 +101,7 @@ class VoiceRecordBar; class TTLButton; class WebpageProcessor; class CharactersLimitLabel; +class ComposeAiButton; } // namespace HistoryView::Controls namespace HistoryView { @@ -312,6 +314,7 @@ private: void initKeyHandler(); void initLikeButton(); void initEditStarsButton(); + void initAiButton(); void updateControlsParents(); void updateSubmitSettings(); void updateSendButtonType(); @@ -322,6 +325,9 @@ private: void updateWrappingVisibility(); void updateControlsVisibility(); void updateControlsGeometry(QSize size); + void updateAiButtonVisibility(); + void updateAiButtonGeometry(); + void updateAiTooltipGeometry(); bool updateReplaceMediaButton(); void updateOuterGeometry(QRect rect); void paintBackground(QPainter &p, QRect full, QRect clip); @@ -345,12 +351,14 @@ private: void toggleTabbedSelectorMode(); void createTabbedPanel(); void setTabbedPanel(std::unique_ptr panel); + void showAiComposeBox(); [[nodiscard]] bool showRecordButton() const; [[nodiscard]] bool showEditStarsButton() const; [[nodiscard]] int shownStarsPerMessage() const; bool updateBotCommandShown(); bool updateLikeShown(); + [[nodiscard]] bool hasEnoughLinesForAi() const; void cancelInlineBot(); void clearInlineBot(); @@ -427,6 +435,7 @@ private: std::optional _backgroundRect; const std::shared_ptr _send; + Controls::ComposeAiButton * const _aiButton = nullptr; Ui::IconButton *_editStars = nullptr; Ui::IconButton *_like = nullptr; rpl::variable _minStarsCount; @@ -461,6 +470,8 @@ private: friend class FieldHeader; const std::unique_ptr _header; const std::unique_ptr _voiceRecordBar; + base::unique_qptr _aiTooltip; + bool _aiTooltipShown = false; const Fn _sendMenuDetails; const Fn)> _unavailableEmojiPasted; diff --git a/Telegram/SourceFiles/main/main_app_config.cpp b/Telegram/SourceFiles/main/main_app_config.cpp index 088d013901..22241cc40e 100644 --- a/Telegram/SourceFiles/main/main_app_config.cpp +++ b/Telegram/SourceFiles/main/main_app_config.cpp @@ -21,6 +21,20 @@ namespace { constexpr auto kRefreshTimeout = 3600 * crl::time(1000); +[[nodiscard]] auto DefaultAiComposeStyles() +-> std::vector { + const auto emoji = [](const char *value) { + return QString::fromUtf8(value); + }; + return { + { emoji("\xF0\x9F\xA4\x9D"), u"formal"_q }, + { emoji("\xF0\x9F\x8E\xAF"), u"short"_q }, + { emoji("\xF0\x9F\x8D\x96"), u"savage"_q }, + { emoji("\xF0\x9F\x95\xAF"), u"biblical"_q }, + { emoji("\xF0\x9F\x8D\xB7"), u"posh"_q }, + }; +} + } // namespace AppConfig::AppConfig(not_null account) : _account(account) { @@ -337,6 +351,7 @@ void AppConfig::refresh(bool force) { } updateIgnoredRestrictionReasons(std::move(was)); + _aiComposeStyles = {}; _groupCallColorings = {}; DEBUG_LOG(("getAppConfig result handled.")); @@ -547,6 +562,39 @@ bool AppConfig::newRequirePremiumFree() const { false); } +std::vector AppConfig::aiComposeStyles() const { + if (!_aiComposeStyles.empty()) { + return _aiComposeStyles; + } + _aiComposeStyles = getValue(u"ai_compose_styles"_q, [&](const auto &value) { + return value.match([&](const MTPDjsonArray &data) { + auto result = std::vector(); + result.reserve(data.vvalue().v.size()); + for (const auto &entry : data.vvalue().v) { + if (entry.type() != mtpc_jsonArray) { + return DefaultAiComposeStyles(); + } + const auto &list = entry.c_jsonArray().vvalue().v; + if (list.size() < 2 + || (list[0].type() != mtpc_jsonString) + || (list[1].type() != mtpc_jsonString)) { + return DefaultAiComposeStyles(); + } + result.push_back({ + qs(list[0].c_jsonString().vvalue()), + qs(list[1].c_jsonString().vvalue()), + }); + } + return result.empty() + ? DefaultAiComposeStyles() + : result; + }, [&](const auto &) { + return DefaultAiComposeStyles(); + }); + }); + return _aiComposeStyles; +} + auto AppConfig::groupCallColorings() const -> std::vector { if (!_groupCallColorings.empty()) { return _groupCallColorings; diff --git a/Telegram/SourceFiles/main/main_app_config.h b/Telegram/SourceFiles/main/main_app_config.h index 365aa7940e..7200e003eb 100644 --- a/Telegram/SourceFiles/main/main_app_config.h +++ b/Telegram/SourceFiles/main/main_app_config.h @@ -137,6 +137,12 @@ public: [[nodiscard]] int64 stakeDiceNanoTonMax() const; [[nodiscard]] std::vector stakeDiceNanoTonSuggested() const; + struct AiComposeStyle { + QString emoji; + QString key; + }; + [[nodiscard]] std::vector aiComposeStyles() const; + using StarsColoring = Calls::Group::Ui::StarsColoring; [[nodiscard]] std::vector groupCallColorings() const; @@ -192,6 +198,7 @@ private: std::vector _startRefPrefixes; + mutable std::vector _aiComposeStyles; mutable std::vector _groupCallColorings; crl::time _lastFrozenRefresh = 0; diff --git a/Telegram/lib_ui b/Telegram/lib_ui index bcd1aaf92c..af87a4bdb5 160000 --- a/Telegram/lib_ui +++ b/Telegram/lib_ui @@ -1 +1 @@ -Subproject commit bcd1aaf92c80588a6d015521eb9ce662e3731d9a +Subproject commit af87a4bdb56ebd191f2eb1b6221673dbea868624